T22: the macOS menu bar holds the PRD's menus, each item showing its key; Quit, Select All, Bigger, Smaller, Reset Size and Minimize take Ghostty's keys, Ctrl-Shift off macOS where Ctrl with a letter types a control byte; the font's size moves for every session, unsaved; Select All runs from the first character in the history to the last, as Ghostty's; shot lists the menus and says whether the app was active at each key
CI / platforms (windows-latest) (push) Failing after 8s
CI / macos (push) Failing after 8m29s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-15 01:51:46 -04:00
1 parent 149c1b5f02
commit 5ee67bd406
15 files changed
+770 -495

No files matched your search

+7 -6
View File
@@ -13,21 +13,21 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
## Commands
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
- `cargo run --example shot -- [--keys "SPECS"] <png> <program> [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; no permission needed for the app's own window), naming the AppKit views under its content: gpui's blur is a `BlurredView`, and a capture of one window shows its alpha, not the blur behind it. It shows its window without activating the app, so gpui keeps drawing it and keystrokes or clicks meant for other apps stay there. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated:
- `cargo run --example shot -- [--keys "SPECS"] <png> <program> [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; no permission needed for the app's own window), naming the AppKit views under its content: gpui's blur is a `BlurredView`, and a capture of one window shows its alpha, not the blur behind it; it logs the menu bar, each item with its key. It shows its window without activating the app, so gpui keeps drawing it and keystrokes or clicks meant for other apps stay there. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated:
- keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag);
- input method calls: `mark:é` composes, `commit:é` types;
- `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent;
- `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field;
- `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent. macOS may delay or refuse the activation; each `shot: typed` line says whether the app was active;
- `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field. AppKit gives an inactive app's key that a menu item holds to the menu, and gpui sends the item's action to the main window, which an inactive app lacks: only an app-wide action runs, such as Quit, Settings or the font's size;
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
- `scripts/linux.sh ARGS` runs `cargo ARGS` on the working tree in a Linux container, the build kept in Docker volumes; `scripts/linux.sh test --locked` runs the tests as a user, not root, as CI does. The tree goes in as a tar stream, every file dated now: Colima's shared folder can show the VM a file as it was before an edit, and cargo judges by date, so a tree older than the last build would reuse that build. It needs a Docker daemon: on macOS, Colima (`colima start`).
## Layout
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/sessions.rs`: a session and its row. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
- `src/settings`: the model, its ranges, and `SettingsStore`, the global that loads, checks and saves `settings.json`; `window` is the settings window, `window/appearance` its Appearance page, `window/terminal` its Terminal page. `tests/fonts.rs` brings its own main: gpui builds its Mac platform only on the main thread.
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 7 and the mouse modes before the parser. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
- `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title.
- `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks.
- `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
@@ -69,8 +69,9 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
- 2026-10-14: the Git panel shows HEAD's branch, or its short commit while detached; Ely's `ChangesList` of conflicted, staged, unstaged and untracked files; Ely's `CommitInput`; Ely's `CommitList` of the last 50 commits. A row's press opens its diff in Ely's fullscreen `Dialog` under the title bar, in Ely's `DiffViewer`, unified or split; a binary file, an untracked folder and a change of no line say so instead. Every control Ely draws works: stage, unstage, stage all, unstage all, amend, and discard, which asks first in Ely's `ConfirmDialog`: an edit goes back to the index (`git restore`); a new file, untracked or only intended to be added, is deleted (`git clean`, `git rm -f`). Git's clean passes over a repository of its own without a word, so a clean that removed nothing is an error. Actions run off the main thread, each after the one asked before it; a failure shows in Ely's `Alert` with git's words until dismissed or the next action, and after any action Files reads its marks again. A folder change drops the open diff, and one still loading, and blanks the view unless the new folder lies inside the shown work tree, so a cd within it does not blink; no action runs until the shown folder has read, so none reaches the repository left behind. A commit empties only the fields that still hold what it committed, and only once the shown folder has read and is in the same repository: a draft typed while it ran, or while a folder read, stays. A path shows on one line and once: as it is, or quoted as git quotes a name with control characters, quotes, backslashes or bytes not in UTF-8; a folder drops git's trailing slash, since no file shares its name. A section lists 500 files at most, and a line says how many are listed: Ely's list draws every row, and gpui copies every row's hitboxes and paint each frame, so 3000 rows halved the terminal's frames. The Git body is a cached view, so a terminal's output does not draw it again; it comes back from the Files tab with its lists scrolled to the top, since a view cached while hidden drops its widgets' state. Files stays uncached, so its tree keeps its open folders: Ely's `Tree` draws only the rows in view.
- 2026-10-14: Cmd-, opens the settings window, or brings the open one forward, and Cmd-W closes it; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's. Categories sit in Ely's `SettingsLayout`, rows in its `SettingsRow`; a category joins the list with its rows. Each change goes through `SettingsStore::update`, which checks and saves, and every view reads the global, so it applies at once. A shell saves only when it names a program the running user may run, as the system's `access` answers, ACLs too: empty for the login shell, a file at a full path, or a name found in one of `PATH`'s full folders; a relative folder counts for none, since it would hang on each session's own folder. On Windows a name without an extension means its `.exe`, as `CreateProcess` reads it; a name with one keeps it. A program whose path is not UTF-8 counts for none: a session starts its program by text. A session runs that same full path, so the check and the start agree; else the row says why and the saved one stays, since a bad shell stops the next start. The window takes focus inside when it opens, so its keys find its context. The app quits with its last terminal window: settings alone hold no terminal.
- 2026-10-14: settings refuse unknown fields; a bad settings file stops the start and is never overwritten.
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. Blur saves here and reaches the window in T19. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns.
- 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns.
- 2026-10-14: Terminal shows scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, and copy on select. The engine reads each when it needs it, so a change applies at once to every session; a smaller scrollback drops the history past it.
- 2026-10-14: the main window lets the desktop through while Background opacity is under 1, blurred or clear as Background blur says, through gpui's `Blurred` (macOS's NSVisualEffectView, Windows' acrylic) or `Transparent`; at 1 it is `Opaque`. The workspace sets it as the settings change. Ely's `ColumnShell::glass`, added to Ely for this, grounds the columns in the palette's glass, which the opacity sets, so the title bar and every column let the desktop through alike; the palette's `bg` stays solid, so a dialog, the Git diff among them, hides what lies under it. The terminal paints no ground over the glass, or the two would stack; a program's own background (OSC 11) paints over it. Ely's own themes pass their palette too, Ely's colors with the glass set. The settings window stays opaque.
- 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair.
- 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach.
- 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's; copy on select takes it, and a blank grid selects nothing.
-474
View File
@@ -1,474 +0,0 @@
//! Runs a program in the main window, plays keys and mouse gestures on it, photographs it and names its AppKit views; `to:settings` and `to:main` aim what follows.
#[cfg(target_os = "macos")]
fn main() {
use std::time::Duration;
use ely_gpui_component::Assets;
use elyterminal::{app, settings::window::SettingsWindow, term::Launch};
use gpui::AnyWindowHandle;
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info"))
.init();
let mut args = std::env::args().skip(1).peekable();
let keys: Vec<String> = if args.peek().map(String::as_str) == Some("--keys") {
args.next();
let keys = args.next().expect("keys after --keys");
keys.split_whitespace().map(String::from).collect()
} else {
Vec::new()
};
let out = args.next().expect("a png path");
let program = args.next().expect("a program");
let launch = Launch {
program: Some((program, args.collect())),
cwd: std::env::current_dir().expect("a current folder"),
env: Vec::new(),
reports: false,
};
gpui_platform::application()
.with_assets(Assets)
.run(move |cx: &mut gpui::App| {
ely_gpui_component::init(cx).expect("Ely starts");
app::start(cx).expect("the app starts");
let main: AnyWindowHandle = app::open(launch, cx).expect("the window opens").into();
cx.spawn(async move |cx| {
let number = |aim: AnyWindowHandle, cx: &mut gpui::AsyncApp| {
let number = aim.update(cx, |_, window, _| capture::number(window));
number.expect("the window lives").expect("a window number")
};
let mut aim = main;
capture::front(number(aim, cx)).expect("the window on screen");
cx.background_executor().timer(Duration::from_secs(1)).await;
for spec in &keys {
match spec.as_str() {
"to:main" => {
aim = main;
capture::front(number(aim, cx)).expect("the window on screen");
}
"to:settings" => {
let windows = cx.update(|cx| cx.windows());
let settings = windows
.into_iter()
.find(|window| window.downcast::<SettingsWindow>().is_some());
aim = settings.expect("a settings window");
capture::front(number(aim, cx)).expect("the window on screen");
}
spec => typing::post(spec, number(aim, cx)).expect("a key event"),
}
if spec.starts_with("to:") {
log::info!("shot: {spec}");
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
let title = aim.update(cx, |_, window, _| window.window_title());
let title = title.expect("the window lives");
capture::snapshot(number(aim, cx), std::path::Path::new(&out)).expect("a picture");
let views = capture::views(number(aim, cx)).expect("the window's views");
log::info!("shot: wrote {out}, titled {title:?}, over {views:?}");
cx.update(|cx| cx.quit());
})
.detach();
});
}
#[cfg(target_os = "macos")]
mod capture {
use std::path::Path;
use anyhow::{Context as _, bail};
use objc::{class, msg_send, runtime::Object, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// A gpui window's number, as CoreGraphics knows it.
pub fn number(window: &gpui::Window) -> anyhow::Result<u32> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("not an AppKit window");
};
let view = appkit.ns_view.as_ptr() as *mut Object;
let native: *mut Object = unsafe { msg_send![view, window] };
let number: i64 = unsafe { msg_send![native, windowNumber] };
u32::try_from(number).context("window number out of range")
}
/// Shows the window and makes it the app's key window, which AppKit sends keys to, without activating the app: gpui draws it, real input stays put.
pub fn front(number: u32) -> anyhow::Result<()> {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let _: () = msg_send![window, makeKeyWindow];
let _: () = msg_send![window, orderFrontRegardless];
}
Ok(())
}
/// The class of each view under the window's content, as AppKit names it; gpui's blur is a `BlurredView`.
pub fn views(number: u32) -> anyhow::Result<Vec<String>> {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let content: *mut Object = msg_send![window, contentView];
let views: *mut Object = msg_send![content, subviews];
let count: u64 = msg_send![views, count];
let named = (0..count).map(|ix| {
let view: *mut Object = msg_send![views, objectAtIndex: ix];
let class: *mut Object = msg_send![view, className];
let text: *const std::ffi::c_char = msg_send![class, UTF8String];
std::ffi::CStr::from_ptr(text)
.to_string_lossy()
.into_owned()
});
Ok(named.collect())
}
}
/// Writes the window `number` as a PNG; the app's own window needs no permission.
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
};
let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow,
number,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
)
.context("window capture returned nothing")?;
if image.bits_per_pixel() != 32 {
bail!("{} bits per pixel", image.bits_per_pixel());
}
let (width, height, stride) = (image.width(), image.height(), image.bytes_per_row());
let data = image.data();
let bytes = data.bytes();
let mut rgba = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
let at = y * stride + x * 4;
rgba.extend_from_slice(&[bytes[at + 2], bytes[at + 1], bytes[at], bytes[at + 3]]);
}
}
image::save_buffer(
path,
&rgba,
width as u32,
height as u32,
image::ColorType::Rgba8,
)
.with_context(|| format!("cannot write {}", path.display()))
}
}
#[cfg(target_os = "macos")]
mod typing {
use anyhow::Context as _;
use core_graphics::{
event::{CGEvent, CGEventFlags, CGEventType, CGMouseButton, EventField, ScrollEventUnit},
event_source::{CGEventSource, CGEventSourceStateID},
geometry::{CGPoint, CGRect},
};
use foreign_types::ForeignType;
use objc::{
class, msg_send,
runtime::{NO, Object, Sel, YES},
sel, sel_impl,
};
/// US layout key codes and what each key types.
const KEYS: [(&str, u16, &str); 27] = [
(",", 43, ","),
("a", 0, "a"),
("b", 11, "b"),
("c", 8, "c"),
("e", 14, "e"),
("f", 3, "f"),
("i", 34, "i"),
("s", 1, "s"),
("v", 9, "v"),
("t", 17, "t"),
("w", 13, "w"),
("1", 18, "1"),
("2", 19, "2"),
("5", 23, "5"),
("9", 25, "9"),
("[", 33, "["),
("space", 49, " "),
("tab", 48, "\t"),
("enter", 36, "\r"),
("backspace", 51, "\u{7f}"),
("escape", 53, "\u{1b}"),
("up", 126, "\u{F700}"),
("down", 125, "\u{F701}"),
("left", 123, "\u{F702}"),
("f1", 122, "\u{F704}"),
("f5", 96, "\u{F708}"),
("f6", 97, "\u{F709}"),
];
/// AppKit's `NSRange`.
#[repr(C)]
struct Range {
location: u64,
length: u64,
}
fn string(text: &str) -> *mut Object {
let text = std::ffi::CString::new(text).expect("no NUL in key text");
unsafe { msg_send![class!(NSString), stringWithUTF8String: text.as_ptr()] }
}
/// `mark` composes `text` as an input method does; `commit` types it.
fn compose(call: &str, text: &str, number: u32) -> anyhow::Result<()> {
let none = Range {
location: i64::MAX as u64,
length: 0,
};
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let client: *mut Object = msg_send![window, firstResponder];
match call {
"mark" => {
let end = Range {
location: text.encode_utf16().count() as u64,
length: 0,
};
let _: () = msg_send![client, setMarkedText: string(text) selectedRange: end replacementRange: none];
}
"commit" => {
let _: () = msg_send![client, insertText: string(text) replacementRange: none];
}
other => anyhow::bail!("no input method call {other}"),
}
}
log::info!("shot: {call} {text:?}");
Ok(())
}
unsafe extern "C" {
fn CGEventSetLocation(event: *mut core_graphics::sys::CGEvent, location: CGPoint);
}
/// A gesture's steps: each one's name, point and click count.
fn steps(gesture: &str, points: &[CGPoint]) -> anyhow::Result<Vec<(String, CGPoint, i64)>> {
let step = |name: &str, at: CGPoint, clicks| (name.to_string(), at, clicks);
let (at, to) = (points[0], points.get(1).copied());
Ok(match gesture {
"click" | "click2" | "click3" => (1..=gesture[5..].parse().unwrap_or(1))
.flat_map(|clicks| [step("ldown", at, clicks), step("lup", at, clicks)])
.collect(),
"rclick" => vec![step("rdown", at, 1), step("rup", at, 1)],
"mclick" => vec![step("mdown", at, 1), step("mup", at, 1)],
"drag" => {
let to = to.context("drag needs a second point")?;
vec![
step("ldown", at, 1),
step("ldrag", to, 1),
step("lup", to, 1),
]
}
name => vec![step(name, at, 1)],
})
}
/// A step's event type, button and the selector gpui's view takes it by.
fn kind(step: &str) -> anyhow::Result<(CGEventType, CGMouseButton, &'static str)> {
Ok(match step {
"ldown" => (
CGEventType::LeftMouseDown,
CGMouseButton::Left,
"mouseDown:",
),
"lup" => (CGEventType::LeftMouseUp, CGMouseButton::Left, "mouseUp:"),
"ldrag" => (
CGEventType::LeftMouseDragged,
CGMouseButton::Left,
"mouseDragged:",
),
"rdown" => (
CGEventType::RightMouseDown,
CGMouseButton::Right,
"rightMouseDown:",
),
"rup" => (
CGEventType::RightMouseUp,
CGMouseButton::Right,
"rightMouseUp:",
),
"mdown" => (
CGEventType::OtherMouseDown,
CGMouseButton::Center,
"otherMouseDown:",
),
"mup" => (
CGEventType::OtherMouseUp,
CGMouseButton::Center,
"otherMouseUp:",
),
"move" => (CGEventType::MouseMoved, CGMouseButton::Left, "mouseMoved:"),
other => anyhow::bail!("no mouse gesture {other}"),
})
}
/// Hands gpui's view a mouse gesture as AppKit events, even while the window is inactive.
fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
let mut fields = name.split('@');
let gesture = fields.next().context("a gesture")?;
let numbers: Vec<f64> = fields
.flat_map(|field| field.split(','))
.map(str::parse)
.collect::<Result<_, _>>()
.with_context(|| format!("numbers in {name}"))?;
anyhow::ensure!(numbers.len() >= 2, "{name} needs a point");
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let view: *mut Object = msg_send![window, firstResponder];
let content: *mut Object = msg_send![window, contentView];
let frame: CGRect = msg_send![content, frame];
let height = frame.size.height;
let points: Vec<CGPoint> = numbers
.chunks_exact(2)
.map(|pair| CGPoint::new(pair[0], height - pair[1]))
.collect();
let screens: *mut Object = msg_send![class!(NSScreen), screens];
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
let screen: CGRect = msg_send![main, frame];
let source = || {
CGEventSource::new(CGEventSourceStateID::Private)
.map_err(|_| anyhow::anyhow!("no event source"))
};
let deliver = |event: CGEvent, at: CGPoint, selector: &str| -> anyhow::Result<()> {
CGEventSetLocation(
event.as_ptr(),
CGPoint::new(at.x, screen.size.height - at.y),
);
event.set_flags(CGEventFlags::from_bits_truncate(flags));
let native: *mut Object =
msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
anyhow::ensure!(!native.is_null(), "AppKit refused {name}");
let selector = Sel::register(selector);
let _: *mut Object = msg_send![view, performSelector: selector withObject: native];
Ok(())
};
if let Some(up) = gesture.strip_prefix("scroll") {
let lines = *numbers.get(2).context("a scroll needs lines")? as i32;
let lines = if up == "up" { lines } else { -lines };
let event =
CGEvent::new_scroll_event(source()?, ScrollEventUnit::LINE, 1, lines, 0, 0)
.map_err(|_| anyhow::anyhow!("no scroll event"))?;
deliver(event, points[0], "scrollWheel:")?;
} else {
for (step, at, clicks) in steps(gesture, &points)? {
let (kind, button, selector) = kind(&step)?;
let event = CGEvent::new_mouse_event(source()?, kind, at, button)
.map_err(|_| anyhow::anyhow!("no mouse event"))?;
event.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, clicks);
deliver(event, at, selector)?;
}
}
}
log::info!("shot: mouse {name}");
Ok(())
}
/// Posts `spec`, a key, an input method call or a mouse gesture, on window `number`.
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
if let Some((call, text)) = spec.split_once(':') {
return compose(call, text, number);
}
if let "activate" | "deactivate" = spec {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
if spec == "activate" {
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
} else {
let _: () = msg_send![app, deactivate];
}
}
log::info!("shot: {spec}");
return Ok(());
}
let mut parts: Vec<&str> = spec.split('-').collect();
let name = parts.pop().context("an empty key")?;
let mut flags: u64 = 0;
let mut repeat = NO;
for modifier in &parts {
flags |= match *modifier {
"repeat" => {
repeat = YES;
0
}
"shift" => 0x20000 | 0x02,
"ctrl" => 0x40000 | 0x01,
"lopt" => 0x80000 | 0x20,
"ropt" => 0x80000 | 0x40,
"cmd" => 0x100000 | 0x08,
other => anyhow::bail!("no modifier {other}"),
};
}
if name.contains('@') {
return mouse(name, flags, number);
}
let (_, code, plain) = KEYS
.iter()
.find(|(key, ..)| *key == name)
.with_context(|| format!("no key code for {name}"))?;
if plain
.chars()
.next()
.is_some_and(|ch| ('\u{F700}'..='\u{F8FF}').contains(&ch))
{
flags |= 0x800000 | 0x200000;
}
let ignoring = if flags & 0x20000 != 0 {
plain.to_uppercase()
} else {
plain.to_string()
};
let typed = match plain.chars().next() {
_ if flags & 0x80000 != 0 => String::new(),
Some(letter @ 'a'..='z') if flags & 0x40000 != 0 => {
char::from(letter as u8 - b'a' + 1).to_string()
}
_ => ignoring.clone(),
};
for kind in [10u64, 11] {
unsafe {
let event: *mut Object = msg_send![
class!(NSEvent),
keyEventWithType: kind
location: CGPoint::new(0.0, 0.0)
modifierFlags: flags
timestamp: 0.0f64
windowNumber: i64::from(number)
context: std::ptr::null_mut::<Object>()
characters: string(&typed)
charactersIgnoringModifiers: string(&ignoring)
isARepeat: if kind == 10 { repeat } else { NO }
keyCode: *code
];
anyhow::ensure!(!event.is_null(), "AppKit refused the key {spec}");
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let _: () = msg_send![app, postEvent: event atStart: NO];
}
}
log::info!("shot: typed {spec}");
Ok(())
}
}
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("shot photographs windows on macOS only");
std::process::exit(1);
}
+117
View File
@@ -0,0 +1,117 @@
use std::path::Path;
use anyhow::{Context as _, bail};
use objc::{class, msg_send, runtime::Object, sel, sel_impl};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
/// A gpui window's number, as CoreGraphics knows it.
pub fn number(window: &gpui::Window) -> anyhow::Result<u32> {
let handle = HasWindowHandle::window_handle(window)
.map_err(|error| anyhow::anyhow!("no native handle: {error:?}"))?;
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
bail!("not an AppKit window");
};
let view = appkit.ns_view.as_ptr() as *mut Object;
let native: *mut Object = unsafe { msg_send![view, window] };
let number: i64 = unsafe { msg_send![native, windowNumber] };
u32::try_from(number).context("window number out of range")
}
/// Shows the window and makes it the app's key window, which AppKit sends keys to, without activating the app: gpui draws it, real input stays put.
pub fn front(number: u32) -> anyhow::Result<()> {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let _: () = msg_send![window, makeKeyWindow];
let _: () = msg_send![window, orderFrontRegardless];
}
Ok(())
}
/// The class of each view under the window's content, as AppKit names it; gpui's blur is a `BlurredView`.
pub fn views(number: u32) -> anyhow::Result<Vec<String>> {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let content: *mut Object = msg_send![window, contentView];
let views: *mut Object = msg_send![content, subviews];
let count: u64 = msg_send![views, count];
let named = (0..count).map(|ix| {
let view: *mut Object = msg_send![views, objectAtIndex: ix];
let class: *mut Object = msg_send![view, className];
let text: *const std::ffi::c_char = msg_send![class, UTF8String];
std::ffi::CStr::from_ptr(text)
.to_string_lossy()
.into_owned()
});
Ok(named.collect())
}
}
/// The menu bar's items as AppKit holds them: menu, item, then the key and its modifier mask.
pub fn menus() -> Vec<String> {
let text = |string: *mut Object| -> String {
let bytes: *const std::ffi::c_char = unsafe { msg_send![string, UTF8String] };
unsafe { std::ffi::CStr::from_ptr(bytes) }
.to_string_lossy()
.into_owned()
};
let mut lines = Vec::new();
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let bar: *mut Object = msg_send![app, mainMenu];
let count: i64 = msg_send![bar, numberOfItems];
for at in 0..count {
let top: *mut Object = msg_send![bar, itemAtIndex: at];
let menu: *mut Object = msg_send![top, submenu];
let name = text(msg_send![menu, title]);
let items: i64 = msg_send![menu, numberOfItems];
for row in 0..items {
let item: *mut Object = msg_send![menu, itemAtIndex: row];
let key = text(msg_send![item, keyEquivalent]);
let mask: u64 = msg_send![item, keyEquivalentModifierMask];
let title = text(msg_send![item, title]);
lines.push(format!("{name} > {title} [{key:?} {mask:#x}]"));
}
}
}
lines
}
/// Writes the window `number` as a PNG; the app's own window needs no permission.
pub fn snapshot(number: u32, path: &Path) -> anyhow::Result<()> {
use core_graphics::{
geometry::{CGPoint, CGRect, CGSize},
window,
};
let image = window::create_image(
CGRect::new(&CGPoint::new(0.0, 0.0), &CGSize::new(0.0, 0.0)),
window::kCGWindowListOptionIncludingWindow,
number,
window::kCGWindowImageBoundsIgnoreFraming | window::kCGWindowImageBestResolution,
)
.context("window capture returned nothing")?;
if image.bits_per_pixel() != 32 {
bail!("{} bits per pixel", image.bits_per_pixel());
}
let (width, height, stride) = (image.width(), image.height(), image.bytes_per_row());
let data = image.data();
let bytes = data.bytes();
let mut rgba = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
let at = y * stride + x * 4;
rgba.extend_from_slice(&[bytes[at + 2], bytes[at + 1], bytes[at], bytes[at + 3]]);
}
}
image::save_buffer(
path,
&rgba,
width as u32,
height as u32,
image::ColorType::Rgba8,
)
.with_context(|| format!("cannot write {}", path.display()))
}
+91
View File
@@ -0,0 +1,91 @@
//! Runs a program in the main window, plays keys and mouse gestures on it, photographs it and names its AppKit views; `to:settings` and `to:main` aim what follows.
#[cfg(target_os = "macos")]
fn main() {
use std::time::Duration;
use ely_gpui_component::Assets;
use elyterminal::{app, settings::window::SettingsWindow, term::Launch};
use gpui::AnyWindowHandle;
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info"))
.init();
let mut args = std::env::args().skip(1).peekable();
let keys: Vec<String> = if args.peek().map(String::as_str) == Some("--keys") {
args.next();
let keys = args.next().expect("keys after --keys");
keys.split_whitespace().map(String::from).collect()
} else {
Vec::new()
};
let out = args.next().expect("a png path");
let program = args.next().expect("a program");
let launch = Launch {
program: Some((program, args.collect())),
cwd: std::env::current_dir().expect("a current folder"),
env: Vec::new(),
reports: false,
};
gpui_platform::application()
.with_assets(Assets)
.run(move |cx: &mut gpui::App| {
ely_gpui_component::init(cx).expect("Ely starts");
app::start(cx).expect("the app starts");
let main: AnyWindowHandle = app::open(launch, cx).expect("the window opens").into();
cx.spawn(async move |cx| {
let number = |aim: AnyWindowHandle, cx: &mut gpui::AsyncApp| {
let number = aim.update(cx, |_, window, _| capture::number(window));
number.expect("the window lives").expect("a window number")
};
let mut aim = main;
capture::front(number(aim, cx)).expect("the window on screen");
cx.background_executor().timer(Duration::from_secs(1)).await;
for spec in &keys {
match spec.as_str() {
"to:main" => {
aim = main;
capture::front(number(aim, cx)).expect("the window on screen");
}
"to:settings" => {
let windows = cx.update(|cx| cx.windows());
let settings = windows
.into_iter()
.find(|window| window.downcast::<SettingsWindow>().is_some());
aim = settings.expect("a settings window");
capture::front(number(aim, cx)).expect("the window on screen");
}
spec => typing::post(spec, number(aim, cx)).expect("a key event"),
}
if spec.starts_with("to:") {
log::info!("shot: {spec}");
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
cx.background_executor().timer(Duration::from_secs(1)).await;
let title = aim.update(cx, |_, window, _| window.window_title());
let title = title.expect("the window lives");
capture::snapshot(number(aim, cx), std::path::Path::new(&out)).expect("a picture");
let views = capture::views(number(aim, cx)).expect("the window's views");
log::info!("shot: wrote {out}, titled {title:?}, over {views:?}");
for line in capture::menus() {
log::info!("shot: menu {line}");
}
cx.update(|cx| cx.quit());
})
.detach();
});
}
#[cfg(target_os = "macos")]
mod capture;
#[cfg(target_os = "macos")]
mod typing;
#[cfg(not(target_os = "macos"))]
fn main() {
eprintln!("shot photographs windows on macOS only");
std::process::exit(1);
}
+305
View File
@@ -0,0 +1,305 @@
use anyhow::Context as _;
use core_graphics::{
event::{CGEvent, CGEventFlags, CGEventType, CGMouseButton, EventField, ScrollEventUnit},
event_source::{CGEventSource, CGEventSourceStateID},
geometry::{CGPoint, CGRect},
};
use foreign_types::ForeignType;
use objc::{
class, msg_send,
runtime::{BOOL, NO, Object, Sel, YES},
sel, sel_impl,
};
/// US layout key codes and what each key types.
const KEYS: [(&str, u16, &str); 32] = [
(",", 43, ","),
("a", 0, "a"),
("b", 11, "b"),
("c", 8, "c"),
("e", 14, "e"),
("f", 3, "f"),
("i", 34, "i"),
("s", 1, "s"),
("v", 9, "v"),
("t", 17, "t"),
("w", 13, "w"),
("1", 18, "1"),
("2", 19, "2"),
("5", 23, "5"),
("9", 25, "9"),
("0", 29, "0"),
("=", 24, "="),
("minus", 27, "-"),
("m", 46, "m"),
("q", 12, "q"),
("[", 33, "["),
("space", 49, " "),
("tab", 48, "\t"),
("enter", 36, "\r"),
("backspace", 51, "\u{7f}"),
("escape", 53, "\u{1b}"),
("up", 126, "\u{F700}"),
("down", 125, "\u{F701}"),
("left", 123, "\u{F702}"),
("f1", 122, "\u{F704}"),
("f5", 96, "\u{F708}"),
("f6", 97, "\u{F709}"),
];
/// AppKit's `NSRange`.
#[repr(C)]
struct Range {
location: u64,
length: u64,
}
fn string(text: &str) -> *mut Object {
let text = std::ffi::CString::new(text).expect("no NUL in key text");
unsafe { msg_send![class!(NSString), stringWithUTF8String: text.as_ptr()] }
}
/// `mark` composes `text` as an input method does; `commit` types it.
fn compose(call: &str, text: &str, number: u32) -> anyhow::Result<()> {
let none = Range {
location: i64::MAX as u64,
length: 0,
};
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let client: *mut Object = msg_send![window, firstResponder];
match call {
"mark" => {
let end = Range {
location: text.encode_utf16().count() as u64,
length: 0,
};
let _: () = msg_send![client, setMarkedText: string(text) selectedRange: end replacementRange: none];
}
"commit" => {
let _: () = msg_send![client, insertText: string(text) replacementRange: none];
}
other => anyhow::bail!("no input method call {other}"),
}
}
log::info!("shot: {call} {text:?}");
Ok(())
}
unsafe extern "C" {
fn CGEventSetLocation(event: *mut core_graphics::sys::CGEvent, location: CGPoint);
}
/// A gesture's steps: each one's name, point and click count.
fn steps(gesture: &str, points: &[CGPoint]) -> anyhow::Result<Vec<(String, CGPoint, i64)>> {
let step = |name: &str, at: CGPoint, clicks| (name.to_string(), at, clicks);
let (at, to) = (points[0], points.get(1).copied());
Ok(match gesture {
"click" | "click2" | "click3" => (1..=gesture[5..].parse().unwrap_or(1))
.flat_map(|clicks| [step("ldown", at, clicks), step("lup", at, clicks)])
.collect(),
"rclick" => vec![step("rdown", at, 1), step("rup", at, 1)],
"mclick" => vec![step("mdown", at, 1), step("mup", at, 1)],
"drag" => {
let to = to.context("drag needs a second point")?;
vec![
step("ldown", at, 1),
step("ldrag", to, 1),
step("lup", to, 1),
]
}
name => vec![step(name, at, 1)],
})
}
/// A step's event type, button and the selector gpui's view takes it by.
fn kind(step: &str) -> anyhow::Result<(CGEventType, CGMouseButton, &'static str)> {
Ok(match step {
"ldown" => (
CGEventType::LeftMouseDown,
CGMouseButton::Left,
"mouseDown:",
),
"lup" => (CGEventType::LeftMouseUp, CGMouseButton::Left, "mouseUp:"),
"ldrag" => (
CGEventType::LeftMouseDragged,
CGMouseButton::Left,
"mouseDragged:",
),
"rdown" => (
CGEventType::RightMouseDown,
CGMouseButton::Right,
"rightMouseDown:",
),
"rup" => (
CGEventType::RightMouseUp,
CGMouseButton::Right,
"rightMouseUp:",
),
"mdown" => (
CGEventType::OtherMouseDown,
CGMouseButton::Center,
"otherMouseDown:",
),
"mup" => (
CGEventType::OtherMouseUp,
CGMouseButton::Center,
"otherMouseUp:",
),
"move" => (CGEventType::MouseMoved, CGMouseButton::Left, "mouseMoved:"),
other => anyhow::bail!("no mouse gesture {other}"),
})
}
/// Hands gpui's view a mouse gesture as AppKit events, even while the window is inactive.
fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
let mut fields = name.split('@');
let gesture = fields.next().context("a gesture")?;
let numbers: Vec<f64> = fields
.flat_map(|field| field.split(','))
.map(str::parse)
.collect::<Result<_, _>>()
.with_context(|| format!("numbers in {name}"))?;
anyhow::ensure!(numbers.len() >= 2, "{name} needs a point");
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let window: *mut Object = msg_send![app, windowWithWindowNumber: i64::from(number)];
anyhow::ensure!(!window.is_null(), "no window {number}");
let view: *mut Object = msg_send![window, firstResponder];
let content: *mut Object = msg_send![window, contentView];
let frame: CGRect = msg_send![content, frame];
let height = frame.size.height;
let points: Vec<CGPoint> = numbers
.chunks_exact(2)
.map(|pair| CGPoint::new(pair[0], height - pair[1]))
.collect();
let screens: *mut Object = msg_send![class!(NSScreen), screens];
let main: *mut Object = msg_send![screens, objectAtIndex: 0u64];
let screen: CGRect = msg_send![main, frame];
let source = || {
CGEventSource::new(CGEventSourceStateID::Private)
.map_err(|_| anyhow::anyhow!("no event source"))
};
let deliver = |event: CGEvent, at: CGPoint, selector: &str| -> anyhow::Result<()> {
CGEventSetLocation(
event.as_ptr(),
CGPoint::new(at.x, screen.size.height - at.y),
);
event.set_flags(CGEventFlags::from_bits_truncate(flags));
let native: *mut Object = msg_send![class!(NSEvent), eventWithCGEvent: event.as_ptr()];
anyhow::ensure!(!native.is_null(), "AppKit refused {name}");
let selector = Sel::register(selector);
let _: *mut Object = msg_send![view, performSelector: selector withObject: native];
Ok(())
};
if let Some(up) = gesture.strip_prefix("scroll") {
let lines = *numbers.get(2).context("a scroll needs lines")? as i32;
let lines = if up == "up" { lines } else { -lines };
let event = CGEvent::new_scroll_event(source()?, ScrollEventUnit::LINE, 1, lines, 0, 0)
.map_err(|_| anyhow::anyhow!("no scroll event"))?;
deliver(event, points[0], "scrollWheel:")?;
} else {
for (step, at, clicks) in steps(gesture, &points)? {
let (kind, button, selector) = kind(&step)?;
let event = CGEvent::new_mouse_event(source()?, kind, at, button)
.map_err(|_| anyhow::anyhow!("no mouse event"))?;
event.set_integer_value_field(EventField::MOUSE_EVENT_CLICK_STATE, clicks);
deliver(event, at, selector)?;
}
}
}
log::info!("shot: mouse {name}");
Ok(())
}
/// Posts `spec`, a key, an input method call or a mouse gesture, on window `number`.
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
if let Some((call, text)) = spec.split_once(':') {
return compose(call, text, number);
}
if let "activate" | "deactivate" = spec {
unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
if spec == "activate" {
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
} else {
let _: () = msg_send![app, deactivate];
}
}
log::info!("shot: {spec}");
return Ok(());
}
let mut parts: Vec<&str> = spec.split('-').collect();
let name = parts.pop().context("an empty key")?;
let mut flags: u64 = 0;
let mut repeat = NO;
for modifier in &parts {
flags |= match *modifier {
"repeat" => {
repeat = YES;
0
}
"shift" => 0x20000 | 0x02,
"ctrl" => 0x40000 | 0x01,
"lopt" => 0x80000 | 0x20,
"ropt" => 0x80000 | 0x40,
"cmd" => 0x100000 | 0x08,
other => anyhow::bail!("no modifier {other}"),
};
}
if name.contains('@') {
return mouse(name, flags, number);
}
let (_, code, plain) = KEYS
.iter()
.find(|(key, ..)| *key == name)
.with_context(|| format!("no key code for {name}"))?;
if plain
.chars()
.next()
.is_some_and(|ch| ('\u{F700}'..='\u{F8FF}').contains(&ch))
{
flags |= 0x800000 | 0x200000;
}
let ignoring = if flags & 0x20000 != 0 {
plain.to_uppercase()
} else {
plain.to_string()
};
let typed = match plain.chars().next() {
_ if flags & 0x80000 != 0 => String::new(),
Some(letter @ 'a'..='z') if flags & 0x40000 != 0 => {
char::from(letter as u8 - b'a' + 1).to_string()
}
_ => ignoring.clone(),
};
for kind in [10u64, 11] {
unsafe {
let event: *mut Object = msg_send![
class!(NSEvent),
keyEventWithType: kind
location: CGPoint::new(0.0, 0.0)
modifierFlags: flags
timestamp: 0.0f64
windowNumber: i64::from(number)
context: std::ptr::null_mut::<Object>()
characters: string(&typed)
charactersIgnoringModifiers: string(&ignoring)
isARepeat: if kind == 10 { repeat } else { NO }
keyCode: *code
];
anyhow::ensure!(!event.is_null(), "AppKit refused the key {spec}");
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
let _: () = msg_send![app, postEvent: event atStart: NO];
}
}
let active: BOOL = unsafe {
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
msg_send![app, isActive]
};
let state = if active == YES { "active" } else { "inactive" };
log::info!("shot: typed {spec}, the app {state}");
Ok(())
}
+2 -1
View File
@@ -10,7 +10,7 @@ use gpui::{
#[cfg(target_os = "macos")]
use crate::term::mac;
use crate::{
blur,
blur, menus,
settings::{self, SettingsStore},
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
themes,
@@ -43,6 +43,7 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
input::bind_keys(cx);
workspace::bind_keys(cx);
settings::window::bind_keys(cx);
menus::init(cx);
// Settings alone hold no terminal, so they go with the last one.
cx.on_window_closed(|cx, _| {
let windows = cx.windows();
+1
View File
@@ -2,6 +2,7 @@ pub mod app;
pub mod blur;
pub mod files;
pub mod git;
pub mod menus;
pub mod sessions;
pub mod settings;
pub mod term;
+110
View File
@@ -0,0 +1,110 @@
use gpui::{App, InteractiveElement, KeyBinding, Menu, MenuItem, OsAction, actions};
use crate::{
settings::window::OpenSettings,
term::{
input::{Copy, Find, Paste, SelectAll},
zoom,
},
workspace::{CloseSession, NewSession, TogglePanel, ToggleSessions},
};
actions!(
elyterminal,
[Quit, Bigger, Smaller, ResetSize, Minimize, Zoom]
);
/// The menu bar the PRD lists; macOS draws it and names the first menu after the app.
pub fn menus() -> Vec<Menu> {
let menu = |name: &str, items| Menu {
name: name.to_string().into(),
items,
disabled: false,
};
vec![
menu(
"ELYTerminal",
vec![
MenuItem::action("Settings…", OpenSettings),
MenuItem::separator(),
MenuItem::action("Quit ELYTerminal", Quit),
],
),
menu(
"Shell",
vec![
MenuItem::action("New Session", NewSession),
MenuItem::action("Close Session", CloseSession),
],
),
menu(
"Edit",
vec![
MenuItem::os_action("Copy", Copy, OsAction::Copy),
MenuItem::os_action("Paste", Paste, OsAction::Paste),
MenuItem::os_action("Select All", SelectAll, OsAction::SelectAll),
MenuItem::separator(),
MenuItem::action("Find", Find),
],
),
menu(
"View",
vec![
MenuItem::action("Toggle Sessions", ToggleSessions),
MenuItem::action("Toggle Right Panel", TogglePanel),
MenuItem::separator(),
MenuItem::action("Bigger", Bigger),
MenuItem::action("Smaller", Smaller),
MenuItem::action("Reset Size", ResetSize),
],
),
// macOS lists the open windows here itself.
menu(
"Window",
vec![
MenuItem::action("Minimize", Minimize),
MenuItem::action("Zoom", Zoom),
],
),
]
}
/// Ghostty's keys for Quit and the font's size, Cmd on macOS and Ctrl elsewhere, Cmd-M to minimize; what they do; then the menus, which show the keys bound by now.
pub fn init(cx: &mut App) {
let (quit, modifier) = if cfg!(target_os = "macos") {
("cmd-q", "cmd")
} else {
("ctrl-shift-q", "ctrl")
};
cx.bind_keys([
KeyBinding::new(quit, Quit, None),
KeyBinding::new(&format!("{modifier}-="), Bigger, None),
KeyBinding::new(&format!("{modifier}-+"), Bigger, None),
KeyBinding::new(&format!("{modifier}--"), Smaller, None),
KeyBinding::new(&format!("{modifier}-0"), ResetSize, None),
]);
if cfg!(target_os = "macos") {
cx.bind_keys([KeyBinding::new("cmd-m", Minimize, None)]);
}
cx.set_global(zoom::Enlarged::default());
cx.on_action(|_: &Quit, cx| {
log::info!("app: quit asked");
cx.quit();
});
cx.on_action(|_: &Bigger, cx| zoom::step(Some(1.0), cx));
cx.on_action(|_: &Smaller, cx| zoom::step(Some(-1.0), cx));
cx.on_action(|_: &ResetSize, cx| zoom::step(None, cx));
cx.set_menus(menus());
}
/// Minimize and Zoom on a window's root: only a window answers them.
pub fn window_actions<E: InteractiveElement>(root: E) -> E {
root.on_action(|_: &Minimize, window, _| {
log::info!("window: minimized");
window.minimize_window();
})
.on_action(|_: &Zoom, window, _| {
log::info!("window: zoomed");
window.zoom_window();
})
}
+3 -2
View File
@@ -13,11 +13,11 @@ use gpui::{
App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
Subscription, Task, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions,
actions, div, px, size,
actions, div, prelude::FluentBuilder, px, size,
};
use super::{Settings, SettingsStore, StartIn};
use crate::term::BLINK;
use crate::{menus, term::BLINK};
mod appearance;
mod terminal;
@@ -325,6 +325,7 @@ impl Render for SettingsWindow {
div()
.size_full()
.key_context(CONTEXT)
.map(menus::window_actions)
.on_action(|_: &CloseSettings, window, _| {
log::info!("settings: the window closes");
window.remove_window();
+11 -5
View File
@@ -11,7 +11,7 @@ use super::{
view::TerminalView,
};
actions!(elyterminal, [Copy, Paste, Find, FindNewer]);
actions!(elyterminal, [Copy, Paste, Find, FindNewer, SelectAll]);
/// The key context a terminal's root sets.
pub const CONTEXT: &str = "Terminal";
@@ -19,16 +19,22 @@ pub const CONTEXT: &str = "Terminal";
/// The key context of a terminal's find box.
pub const FIND: &str = "TerminalFind";
/// Copy, paste and find; Ely's focus keys go to the terminal's listeners instead.
/// Copy, paste, select all and find; Ely's focus keys go to the terminal's listeners instead.
pub fn bind_keys(cx: &mut App) {
let (copy, paste, find) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v", "cmd-f")
let (copy, paste, all, find) = if cfg!(target_os = "macos") {
("cmd-c", "cmd-v", "cmd-a", "cmd-f")
} else {
("ctrl-shift-c", "ctrl-shift-v", "ctrl-shift-f")
(
"ctrl-shift-c",
"ctrl-shift-v",
"ctrl-shift-a",
"ctrl-shift-f",
)
};
cx.bind_keys([
KeyBinding::new(copy, Copy, Some(CONTEXT)),
KeyBinding::new(paste, Paste, Some(CONTEXT)),
KeyBinding::new(all, SelectAll, Some(CONTEXT)),
KeyBinding::new(find, Find, Some(CONTEXT)),
KeyBinding::new(
"shift-enter",
+1
View File
@@ -16,6 +16,7 @@ mod tap;
#[cfg(unix)]
mod unix;
mod view;
pub mod zoom;
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
+57 -2
View File
@@ -1,8 +1,8 @@
use alacritty_terminal::{
grid::Scroll,
grid::{Dimensions, Scroll},
index::{Column, Line, Point as Cell, Side},
selection::{Selection, SelectionType},
term::TermMode,
term::{Term, TermMode},
};
use gpui::{
ClipboardItem, Context, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
@@ -298,6 +298,25 @@ impl TerminalView {
(!text.is_empty()).then_some(text)
}
/// Selects the history and the screen, blanks around them left out; copy on select takes the text.
pub(super) fn select_all(&mut self, cx: &mut Context<Self>) {
{
let mut term = self.pty.term.lock();
let Some(all) = everything(&*term) else {
log::debug!("terminal {}: nothing to select", self.pty.pid);
return;
};
term.selection = Some(all);
}
let selected = self.selected();
let count = selected.as_ref().map_or(0, |text| text.chars().count());
log::debug!("terminal {}: all {count} characters selected", self.pty.pid);
if let Some(text) = selected.filter(|_| SettingsStore::get(cx).copy_on_select) {
self.copy_text(text, true, cx);
}
cx.notify();
}
/// The selection to the clipboard.
pub(super) fn copy(&mut self, cx: &mut Context<Self>) {
match self.selected() {
@@ -347,3 +366,39 @@ impl TerminalView {
self.input(keys::paste(&text, bracketed), cx);
}
}
/// Ghostty's select all: the first cell holding more than a space or tab, through history and screen, to the last.
fn everything<T>(term: &Term<T>) -> Option<Selection> {
let grid = term.grid();
let cells = (grid.topmost_line().0..=grid.bottommost_line().0).flat_map(|line| {
(0..grid.columns()).map(move |column| Cell::new(Line(line), Column(column)))
});
let inked = |cell: &Cell| !matches!(grid[*cell].c, ' ' | '\t');
let first = cells.clone().find(inked)?;
let last = cells.rev().find(inked)?;
let mut all = Selection::new(SelectionType::Simple, first, Side::Left);
all.update(last, Side::Right);
Some(all)
}
#[cfg(test)]
mod tests {
use alacritty_terminal::{
event::VoidListener,
term::{Config, test::TermSize},
vte::ansi::Processor,
};
use super::*;
#[test]
fn everything_runs_from_the_first_character_in_the_history_to_the_last() {
let mut term = Term::new(Config::default(), &TermSize::new(10, 3), VoidListener);
assert!(everything(&term).is_none(), "a blank grid selects nothing");
let mut parser: Processor = Processor::new();
parser.advance(&mut term, b"\r\n one\r\n\r\ntwo six\t\r\n\r\n");
term.selection = everything(&term);
let text = term.selection_to_string().expect("a selection");
assert_eq!(text, "one\n\ntwo six");
}
}
+9 -5
View File
@@ -18,11 +18,12 @@ use super::{
find::Finder,
frame::frame,
ink::Ink,
input::{self, Copy, Find, Paste},
input::{self, Copy, Find, Paste, SelectAll},
mouse::MouseMode,
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
zoom,
};
use crate::{
settings::{Settings, SettingsStore},
@@ -71,6 +72,7 @@ pub struct TerminalView {
_blink: Task<()>,
_focus: [Subscription; 3],
_settings: Subscription,
_zoom: Subscription,
}
impl EventEmitter<TerminalEvent> for TerminalView {}
@@ -120,6 +122,7 @@ impl TerminalView {
view.search(false, cx);
cx.notify();
}),
_zoom: cx.observe_global::<zoom::Enlarged>(|_, cx| cx.notify()),
}
}
@@ -266,8 +269,8 @@ pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
Ok(id)
}
/// The font, cell and metrics the settings give at this window's scale.
fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result<Look> {
/// The font, cell and metrics the settings give at `points` and this window's scale.
fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow::Result<Look> {
let font = Font {
family: settings.font_family.clone().into(),
features: if settings.ligatures {
@@ -279,7 +282,7 @@ fn look(settings: &Settings, ink: &Ink, window: &Window) -> anyhow::Result<Look>
weight: FontWeight::NORMAL,
style: FontStyle::Normal,
};
let text = px(settings.font_size);
let text = px(points);
let system = window.text_system();
let id = load_font(&font, system)?;
let advance = system
@@ -349,6 +352,7 @@ impl Render for TerminalView {
.on_key_up(cx.listener(Self::key_up))
.on_action(cx.listener(|view, _: &Copy, _, cx| view.copy(cx)))
.on_action(cx.listener(|view, _: &Paste, _, cx| view.paste(cx)))
.on_action(cx.listener(|view, _: &SelectAll, _, cx| view.select_all(cx)))
.on_action(cx.listener(|view, _: &Find, window, cx| view.find(window, cx)))
.on_any_mouse_down(cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
@@ -367,7 +371,7 @@ impl Render for TerminalView {
root.on_mouse_up(button, cx.listener(Self::mouse_up))
.on_mouse_up_out(button, cx.listener(Self::mouse_up))
});
let root = match look(&settings, &ink, window) {
let root = match look(&settings, zoom::size(cx), &ink, window) {
Ok(look) => {
self.cell = (
f32::from(look.cell.width).round() as u16,
+54
View File
@@ -0,0 +1,54 @@
use gpui::{App, Global};
use crate::settings::{FONT_SIZE, SettingsStore};
/// Points the terminal's font stands above the Size setting, below it when negative; Bigger and Smaller move it, nothing saves it.
#[derive(Default)]
pub struct Enlarged(f32);
impl Global for Enlarged {}
/// The size the terminal draws its font at: the setting, moved, inside the range.
pub fn size(cx: &App) -> f32 {
shown(SettingsStore::get(cx).font_size, cx.global::<Enlarged>().0)
}
/// Moves the font `by` points, or back to the setting when `by` is none.
pub fn step(by: Option<f32>, cx: &mut App) {
let setting = SettingsStore::get(cx).font_size;
let enlarged = by.map_or(0.0, |by| moved(setting, cx.global::<Enlarged>().0, by));
cx.set_global(Enlarged(enlarged));
log::info!(
"zoom: the terminal's font is {} points, {enlarged:+} on the setting",
shown(setting, enlarged)
);
}
fn shown(setting: f32, enlarged: f32) -> f32 {
(setting + enlarged).clamp(*FONT_SIZE.start(), *FONT_SIZE.end())
}
/// What `enlarged` becomes once the font moves `by` points from where it shows.
fn moved(setting: f32, enlarged: f32, by: f32) -> f32 {
shown(shown(setting, enlarged) + by, 0.0) - setting
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_step_moves_the_font_inside_the_range() {
assert_eq!(moved(13.0, 0.0, 1.0), 1.0);
assert_eq!(moved(13.0, 2.0, -1.0), 1.0);
assert_eq!(moved(71.0, 0.0, 1.0), 1.0);
assert_eq!(moved(72.0, 0.0, 1.0), 0.0, "the top holds");
assert_eq!(moved(6.0, 0.0, -1.0), 0.0, "the bottom holds");
assert_eq!(
shown(70.0, 5.0),
72.0,
"a larger setting under a zoom stays in range"
);
assert_eq!(moved(70.0, 5.0, -1.0), 1.0, "a step starts from what shows");
}
}
+2
View File
@@ -16,6 +16,7 @@ use crate::{
blur::Blur,
files::Files,
git::panel::{Acted, GitPanel},
menus,
sessions::{self, Session},
settings::{PanelTab, Settings, SettingsStore, StartIn},
term::{
@@ -404,6 +405,7 @@ impl Render for Workspace {
div()
.size_full()
.key_context(CONTEXT)
.map(menus::window_actions)
.on_action(cx.listener(|this, _: &ToggleSessions, _, cx| {
this.toggle(
|settings| settings.show_sessions = !settings.show_sessions,