Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a54f4ff42d
|
||
|
|
a9be8ab8c4
|
||
|
|
9d9144f25e
|
||
|
|
c3f1422cbc
|
||
|
|
e4b0107a88
|
||
|
|
47826ee262
|
||
|
|
f7e5563a71
|
||
|
|
8ddf470f73
|
||
|
|
c1cdddf07e
|
||
|
|
5fd4f8faa3
|
||
|
|
cdafdc2836
|
||
|
|
2d9180e777
|
||
|
|
1daa2599f7
|
||
|
|
6df7534e7f
|
||
|
|
9aeb3f3552
|
||
|
|
b35eb52d9a
|
||
|
|
c30bd51fba
|
||
|
|
af66a70947
|
||
|
|
ae3de244c2
|
||
|
|
6b2ec654dc
|
||
|
|
3f47134928
|
||
|
|
6f8bfaeff8
|
||
|
|
fc10c4d491
|
||
|
|
8d7e730a78
|
||
|
|
3ee525a008
|
||
|
|
eb9610988e
|
||
|
|
1e08cae4c4
|
||
|
|
7569f0011f
|
||
|
|
336c05beb7
|
||
|
|
c62c17fa7c
|
||
|
|
140c2a5741
|
||
|
|
22752a28db
|
||
|
|
52576bb8ee
|
||
|
|
efda50f142
|
||
|
|
3aaeb36bd1
|
||
|
|
31de5e44ef
|
||
|
|
53b8978dff
|
||
|
|
85bcbf466f
|
||
|
|
b504bfb9b8
|
||
|
|
7d56063df9
|
||
|
|
a175b18743
|
||
|
|
72acbe4bb0
|
||
|
|
628ed6b8c5
|
||
|
|
ef33a0d34e
|
||
|
|
30e65e3435
|
||
|
|
4a269cb57a
|
||
|
|
42b272ece6
|
||
|
|
99db56bab1
|
||
|
|
11ec0effa0
|
||
|
|
e2bb615a19
|
||
|
|
310c219386
|
||
|
|
15e83f5b7e
|
||
|
|
d3700f5278
|
||
|
|
2c25116703
|
||
|
|
d4bc6e69fa
|
||
|
|
0a22200725
|
||
|
|
c53b540f42
|
||
|
|
cccfb2c82b
|
||
|
|
3ea8b616f5
|
||
|
|
2173c74fc5
|
||
|
|
da55942050
|
||
|
|
6336c05528
|
||
|
|
f85e4c1169
|
||
|
|
3b5bf46093
|
||
|
|
d7dc1af6b9
|
||
|
|
7cb2dffc45
|
||
|
|
9a0c0d9dbb
|
||
|
|
2a1060ce48
|
||
|
|
436480248a
|
||
|
|
4068784f66
|
||
|
|
9729d24605
|
||
|
|
2f58a0670e
|
||
|
|
1bf420ce41
|
||
|
|
a5dbfbcbae
|
||
|
|
2af45cbfb9
|
||
|
|
3e30f95543
|
||
|
|
6c610fdc6e
|
||
|
|
8bc94edf8d
|
||
|
|
741e48bc4a
|
||
|
|
c25ca92921
|
||
|
|
1faf8ca0be
|
||
|
|
aae7fba6a9
|
||
|
|
d8ebadea2d
|
||
|
|
340c7a92ea
|
||
|
|
2e9ed4de75
|
||
|
|
194c7bb9d5
|
No files matched your search
@@ -7,16 +7,19 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- Rust 1.95.0, as `rust-toolchain.toml` pins it, edition 2024. One binary crate, `elyterminal`.
|
||||
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `base64`, the version alacritty pulls in for OSC 52: OSC 7501's text. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
||||
- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes, waits on herdr's pipe, reads the keyboard for win32-input-mode, and keeps Ely's window on the main one, which `raw-window-handle` finds.
|
||||
- `resvg`, a dev dependency at the version gpui uses, renders the icon's SVG for the macOS app and the Windows `.ico`. `embed-resource`, a build dependency at the version gpui uses, puts the icon into the Windows executable.
|
||||
|
||||
## 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; the app it runs under needs Screen Recording, or the picture may hold the window's ground alone, and `shot` warns), naming the AppKit views under its content, an effect view with its blur radius, `none` when it draws no blur, 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 keystrokes or clicks meant for other apps stay there; gpui draws a window only while macOS shows it, so one on another Space or under a full-screen app keeps its last frame. 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);
|
||||
- `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, and one of each child window beside it, Ely's among them, with each one's frame in the log (macOS; the app it runs under needs Screen Recording, or the picture may hold the window's ground alone, and `shot` warns), naming the AppKit views under its content, an effect view with its blur radius, `none` when it draws no blur, 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 keystrokes or clicks meant for other apps stay there; gpui draws a window only while macOS shows it, so one on another Space or under a full-screen app keeps its last frame. 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); `down:` or `up:` before a key posts only its press or its release, so a modifier may change between them;
|
||||
- input method calls: `mark:é` composes, `commit:é` types;
|
||||
- `wait` pauses a second, so a program can start before the keys after it;
|
||||
- `snap` writes each child window's picture at that moment, `PNG.childN.snapK.png`, so a child that moves shows its frames;
|
||||
- `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`.
|
||||
@@ -30,11 +33,12 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
## Layout
|
||||
|
||||
- `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs.
|
||||
- `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/macos` gives gpui's NSVisualEffectView a material that blurs; `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, at the size the settings keep, which it keeps as it settles. `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/macos` gives gpui's NSVisualEffectView a material that blurs; `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/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it; `sessions/orb` draws the mark, 3 by 3 dots on one clock. `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/pet.rs`: Ely, peeking over the main window's top edge from a window of her own while the settings want her; she draws at every frame the display gives her window; `pet/rig` draws her from the icon's paths in layers of one color, each moved by a matrix of its own, as the films rig her; `pet/look` reads her mood from the sessions' marks and her pose at each moment of it, `look/curves` the timings of her moves; and `pet/macos`, `pet/windows` and `pet/x11` keep her window on the main window's edge where the platform does not.
|
||||
- `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. `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.
|
||||
- `src/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets, the mouse modes and, on Windows, win32-input-mode (`tap/modes`) before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session, and tells the view herdr leads, so herdr opens its links: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `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, the repeat flag and the key code of AppKit's current event, and the key's own character from the layout, as gpui reads key names; it keeps held keys repeating, and hands Command key-ups to the focused view. `win32` sends a key as Windows' own record, win32-input-mode, and finds a key's own character in Windows' layout. `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; each marks prompts and commands with OSC 133 where the shell does not.
|
||||
- `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).
|
||||
- `assets/icons`: the icon, an original figure, Ely, holding a terminal: `elyterminal-macos.svg` on macOS's rounded square, `elyterminal.svg` the figure alone, for Windows and Linux; the macOS file holds a copy of the figure's markup, so an edit to the figure is made in both. `elyterminal.ico` is the figure at 16 to 256 px for Windows, and `elyterminal.rc` names it resource 1.
|
||||
@@ -61,14 +65,14 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-14: keys follow Ghostty's encoder (MIT). Keys the program reads go out from the key listener; plain text falls through to the input handler, so IME and dead keys compose. Ctrl+[, Ctrl+I and Ctrl+M go as fixterms CSI u, as in Ghostty. Keypad keys always type their characters: Ghostty keeps mode 1035 on, and gpui names keypad keys as main keys on every platform. Ely binds Tab, Shift-Tab, F6 and Shift-F6 to focus moves, and bindings run before listeners; `NoAction` bindings under the `Terminal` context hide them, so these keys reach the listeners with press, repeat and release.
|
||||
- 2026-10-14: each kitty flag reshapes only what it names, as kitty's encoder does: functional keys keep legacy bytes until disambiguate, events or all is set, and text keys type until all is set. Cmd is kitty's Super, bit 8; a Cmd combo no binding takes reaches the program.
|
||||
- 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it.
|
||||
- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it.
|
||||
- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it). gpui names a key Shift turns into a symbol by that symbol and clears Shift; on macOS and Windows the layout gives the key back, as below, and on Linux the symbol goes as its key, without Shift.
|
||||
- 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window.
|
||||
- 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. The tap's scanner reads controls inside a sequence as vte does: C0 runs, DEL and high bytes are skipped, CAN and SUB cancel, ESC restarts, subparameters are skipped. Motion reports the first held button: left, right, middle. The pointer over the grid is the arrow, as the owner asked, not Ghostty's I-beam.
|
||||
- 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise.
|
||||
- 2026-10-14: Cmd-C and Cmd-V copy and paste on macOS, Ctrl-Shift-C and Ctrl-Shift-V elsewhere; on Windows Ctrl-V pastes too, as in Windows Terminal, since Windows shells take it so, while on Linux it stays the program's, as in GNOME Terminal and Ghostty; Cmd-press, Ctrl-press off macOS, opens an OSC 8 target or a URL Ely's `links` finds in the wrapped line. Copies drop the newline alacritty ends a whole line with, as Ghostty's do. Bracketed pastes lose ESC and Ctrl-C, as alacritty's do; plain pastes end lines with CR. Typing and pasting clear the selection. Copy on select and OSC 52 writes to `p` or `s` go to the primary selection on Linux, as Ghostty's do; macOS and Windows have none, so they go to the clipboard.
|
||||
- 2026-10-14: a key's release goes out only when the terminal saw its press, so a key a binding takes sends no release, as in kitty.
|
||||
- 2026-10-14: find opens on Cmd-F (Ctrl-Shift-F off macOS) beside the grid, outside the `Terminal` key context, so its keys never reach the program. Matches wash in Ely's `warning` color, the current one stronger; the newest is current first, Enter steps older and Shift-Enter newer. Shift-Enter binds under `TerminalFind > ElyInput`, the context of Ely's text input, whose own Shift-Enter binding takes the key. Find reads the grid as it shows: combining marks ride their cells, hidden cells are blank, wrapped rows join, words are Unicode; alacritty's own search reads no marks and knows only ASCII word edges. New output, a resize and a settings change search again; the current match stays the same occurrence: its line kept, grown or cut at its end, held under the same line above or in its place, before such a line elsewhere, nearest where scrolling moved it; else the nearest by line, then column. Searching and following take one pass over the text and the matches.
|
||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. A full reset, `ESC c`, empties alacritty's title and sends only `CursorBlinkingChange`, in the parser's order, synchronized updates too; on that event the view replays the title through `set_options`, which sends it.
|
||||
- 2026-10-14: the program hears focus in and out under mode 1004 when the terminal's focus or the window's activation changes. The window wears the program's title, the app's name while it names none. A bell is logged and makes no sound. The title comes from the tap, in the output's order, read as vte and alacritty read it: OSC 0 and 2 set it, 15 parts past the number at most, since vte keeps 16 parameters, and 65536 bytes at most, past which it drops, logged; CSI 22 t and 23 t save it and bring it back; a full reset, `ESC c`, empties it. alacritty's own title events come after the tap's, and send nothing at a full reset, so they pass by.
|
||||
- 2026-10-14: the title bar is transparent, as Ghostty's: the content runs under it, Ely's column headers drag the window, and the terminal's header shows the program's title. macOS draws its traffic lights at Ely's origin; elsewhere Ely draws the controls, so windows ask for client decorations, which only Linux reads, and Ely's `ResizeBorder` gives the edges. Cmd-B and Cmd-Alt-B (Ctrl-Shift-B and Ctrl-Shift-Alt-B off macOS) toggle the sessions and right columns, kept in the settings. A button at each end of the title bar does the same: `PanelLeft` leads the first column, right after the traffic lights, and `PanelRight` ends the last, before the window buttons Ely draws on Windows and Linux, so neither moves when a column hides; its tooltip says Hide or Show. On Windows and Linux, which have no menu bar, a gear just inside `PanelRight` opens Settings, as Ctrl-, does. It sends `OpenSettings` through `Window::dispatch_action`, which waits for the click to finish: `App::dispatch_action` updates the active window at once, which fails inside that window's own handler, and only gpui's log says so. The Git and Files switch tops the right column, centered below the title bar: on Windows the caption buttons left the last header no room for it.
|
||||
- 2026-10-14: session keys are Ghostty's: Cmd-T, Cmd-W, Cmd-1 to Cmd-8, Cmd-9 for the last, Ctrl-Tab and Ctrl-Shift-Tab; off macOS Ctrl-Shift-T, Ctrl-Shift-W and Alt with the digits. A digit past the last session goes on to the program. A new session runs the Shell setting in home or in the shown session's folder, as Start in says. A session whose program ends closes its row; the last row closes the window, and with no window left the app quits. A bell in a hidden session marks its row until it is shown. A hidden terminal hears no mouse release, so hiding it ends its mouse gesture as releases would: the selection drag ends, and the program hears each held button come up. A row's title is the program's, or the app's name while it names none.
|
||||
- 2026-10-14: Files shows the shown session's folder in Ely's `Tree`, not `FileTree`, which takes every path up front and drops empty folders. A folder is read off the main thread when it first opens; the root and the open folders are read again whenever the session reports a folder, so at each prompt. Git's marks come from `git status --porcelain=v2 -z -- .`, run with `--no-optional-locks`, so it never holds `index.lock` against the user's own git, and under the user's `status.showUntrackedFiles`, so a home folder kept in git stays quick. An untracked folder marks what it holds. On macOS, whose file systems ignore Unicode forms, a name keeps its form on disk and meets Git's mark in composed form, as git's `core.precomposeunicode` writes it. A label built from a path shows each control character as its picture, `␊` for a newline: gpui shapes no newline, and a file may be named with one. A name not in UTF-8 keeps its bytes: its label shows it lossily, and its tree key adds the bytes after a NUL, which no path holds, so no two names share a key. A program without shell integration gets a line in the right column until it reports a folder.
|
||||
@@ -77,7 +81,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 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 the first of two columns of Ely's `ColumnShell`, an Ely `SelectableList` as `SettingsLayout` draws its own, and the open one's rows, Ely's `SettingsRow`, in the second; each column scrolls on its own, which `SettingsLayout`, one box for both, cannot. 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. 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 the shell and where sessions start, which a General page held until the owner dropped it, then scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, and copy on select, then every shortcut in Ely's `KeyboardShortcutsList`, read from the keymap: the bindings of each listed command that apply in the main window's terminal, matched at any depth of its context stack as gpui matches them, so the list cannot drift from the keys. 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: Terminal shows the shell and where sessions start, which a General page held until the owner dropped it, then scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, copy on select, and Ely, then every shortcut in Ely's `KeyboardShortcutsList`, read from the keymap: the bindings of each listed command that apply in the main window's terminal, matched at any depth of its context stack as gpui matches them, so the list cannot drift from the keys. 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.
|
||||
@@ -90,3 +94,18 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-15: the Linux tarball builds on Debian 12, in `rust:1.95.0-bookworm`: a binary asks for the glibc symbol versions it was linked against, so it is linked against an old glibc, not the builder's. `scripts/linux-tar.sh` prints the glibc its build asks for, and the README names it: 2.35 for v0.1.0, which runs on Ubuntu 22.04.
|
||||
- 2026-10-15: on Windows gpui answers the system's hit test from the last frame: under the pointer, a `WindowControlArea::Drag` hitbox that nothing occludes makes it the caption, and Windows keeps the press, so no click reaches the app. In v0.1.0 every title bar control was dead so. Ely now occludes the drag region under its window buttons, which then answer as close, maximize and minimize, and under each column header's item row, whose controls get plain presses; the row stays the scroll container, and its title is a drag region of its own.
|
||||
- 2026-10-15: a release's macOS disk image comes from the Release workflow, dispatched once the draft holds the Windows and Linux builds. It runs on `macos-signing`, the Mac mini's runner in the owner's login session, whose keychain holds the Developer ID identity; at the owner's choice that runner moved from AgentCompany alone to the owner's level, so every repository reaches it. The job reads the App Store Connect key from the owner's user-level secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`, writes the .p8 into the runner's temp folder for notarytool alone, and puts the stapled image on the draft through `scripts/release-asset.sh` with the job's own token. One notarization covers the image and the app inside it; the image carries the ticket, and the app copied out finds its own online on its first open.
|
||||
- 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. ELYTerminal reads the file twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name, and wears it whenever its text changes and parses; a check that fails is logged once while it lasts and tried again, so a file made readable again is worn though nothing else about it changed. The Omarchy theme is the default unless that file is surely absent, so a folder that cannot be read makes a fresh install wear it and the start name the error; another pick stops following. At start a missing file means no Omarchy unless the settings wear it, a file that cannot be read stops the start, and one that does not parse stops it only when worn, else it is logged and no Omarchy card shows. While running, a missing file is a switch in progress, and any other failure keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim.
|
||||
- 2026-10-16: on Hyprland 0.56 a window blurs only while Hyprland's own `decoration:blur:enabled` is on: its `shouldBlur` reads that option first, then the window's `no_blur`, RGBX and opaque rules, then the blur region a surface sets through `ext_background_effect_v1`, which Hyprland offers with the blur capability whatever the option says. Omarchy 4.0.4's default `looknfeel.lua` turns the option off, so on Omarchy no window blurs until the user turns it on; ELYTerminal leaves the compositor's settings alone. On the owner's Omarchy the log named `ext_background_effect_v1` and the pane's region, and the wallpaper behind the window blurred with the option on and showed sharp with it off.
|
||||
- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master) and what executable that group's leader runs: an exec keeps the group and swaps the executable, so `exec herdr` from a shell is a new program, and one that did not read is tried again; on Linux the ` (deleted)` mark an update leaves on a running executable is set aside. When that executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`; a relative socket lies in the client's folder, as herdr reads it (`/proc/PID/cwd`; `proc_pidinfo` on macOS). It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground; an answer that lands after the shell reported a folder is dropped, since the shell reports only once herdr has left. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. On Windows, which has no foreground group, a Toolhelp32 snapshot lists the `herdr.exe` processes at or under the session's shell, none under another herdr, since what a herdr starts, its server too, is its own; when that list changes, `sysinfo` reads their arguments and variables, nearest first, names in upper case, as Windows matches them, and the first terminal client is followed, a server or a command beside it passed by; its folder is found as herdr 0.9 finds it there, `XDG_CONFIG_HOME`, then `APPDATA`, then `USERPROFILE\AppData\Roaming`, then `HOME\.config`; and its server answers on the pipe herdr names `\\.\pipe\` and the socket's path as written, relative or not. A busy pipe is waited for, as herdr's client waits, and the pipe's server may identify ELYTerminal but never act as it. The pipe has no timeout: a herdr that never answers holds that session's follow until it answers or exits.
|
||||
- 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range does.
|
||||
- 2026-10-16: the sessions and right columns resize by a drag on their border toward the terminal, which the owner asked for on macOS, Windows and Linux. Ely's `ShellColumn::resizable`, added to Ely for this, puts Ely's `resize_handle` on the border toward the filling column, the sessions column's right and the right column's left: the handle draws that border, a filled hairline, since gpui paints a border no wider than half its box, and the line it drew at first showed half a pixel on macOS and none on X11; it occludes the header under it, so a press there drags the border, not the window, on Windows too, where an unoccluded drag region takes the press as the caption. A drag sets off from the wider of the width drawn and the one kept: a header's parts may hold a column wider, and a window too narrow for both widths squeezes them, where a drag from the squeezed width jumped back against the pointer; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width it had. A set width in Ely became a flex basis, so a column holds its header's own parts: at a set width under them, Windows' caption buttons ran out of the window. The widths, 160 to 960 pixels, show at once and are saved in `sessions_width` and `panel_width` through `SettingsStore::update` 0.5 s after a drag rests, as the window's size is; a drag in the half second before the window closes is lost, and a save the store refuses puts the columns back. A window too narrow for both yields the terminal its header's width, as Ely's set widths give way.
|
||||
- 2026-10-16: prompts and commands carry OSC 133 marks, which the owner asked for, read as Ghostty reads them: A or N starts a prompt, B or I ends it, C starts a command's output, and D ends the command with its status, D's first option, none when that is no number. The tap reads any program's marks before the parser, so it never sees the cursor: A's fresh line and L, which need it, are not done, and P adds nothing to A. Each session keeps the status its last command ended with. The integration marks what the shell leaves unmarked, and the user's prompt still sees the command's status. zsh: precmd reads `$?` and sends D after a command ran, preexec sends C, and a last precmd hook wraps PS1 in A and B under `prompt_percent`, which the first prompt says while it is off; it moves to the end of `precmd_functions` at each prompt, since a theme that sets PS1 in a hook of its own loads after it, and at the first prompt `sched +0` wraps it once more, since zsh runs due events after every precmd hook; that call returns the command's status, which the prompt shows, but it would reset `$pipestatus`, so it serves the first prompt alone. bash: PROMPT_COMMAND's first entry reads `$?`, which each entry resets, and its last wraps PS1. From bash 4.4 PS0 sends C and its subscript notes that a command ran, since bash prints no PS0 for an empty line, and the first entry sends D for it; both need `promptvars`, on by default. macOS's bash 3.2 has no PS0 and nothing else between reading a line and running it: its DEBUG trap runs for a key binding's command (`bind -x`) as for a typed one, with nothing to tell the two apart, runs for no line that is only a subshell, and under `extdebug` its status decides whether a command runs. So bash 3.2 sends no C, and Ghostty's automatic integration skips bash 3.2. Its D comes from PS1 once `\#`, the command number, has moved, which neither a key binding nor an empty line does; readline sends a prompt's escapes again at each redraw, so that D goes again with it, the same status. Every mark of a command rides an expansion, which `promptvars` turns on, so while it is off at start the script says so rather than mark nothing without a word. fish 4.3 marks its own prompts and commands, fish 4.0 all but B, older ones none: the script adds what the running fish lacks, around the prompt shown, and wraps it again when the user's changes. PowerShell: the prompt reads `$?` first and sets it back for the user's prompt through `Write-Error '' -ErrorAction Ignore`, which adds nothing to `$Error`. A failure ends with `$LASTEXITCODE` when a program set it during the line, since each run stores a new object, equal or not, which `[object]::ReferenceEquals` tells apart, unless the line surely ends in PowerShell: its syntax tree, read as PSReadLine hands the line over, ends in a cmdlet, as `Get-Command` resolves the name, aliases through, in a name not found, or in no command; such a line ends with 1, a cmdlet's failure under `-ErrorAction Ignore` too. A line that ends in a block, a function, a script or a call through a variable takes the code of a program it ran: only a hook on every command lookup would name the last command run, and it slowed a loop of commands by half. `$LASTEXITCODE` is read through `Get-Variable`, since strict mode refuses an unset variable and a prompt that fails falls back to `PS>`. C goes out when PSReadLine hands over a line that is not blank; without PSReadLine the script says it marks no command. herdr passes no OSC 133 on, so a session running herdr keeps the status from before it.
|
||||
- 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree.
|
||||
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
|
||||
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `a`, `c` and `w` are kept for posting, and `o` passes by; a lone body is the title, and one with neither passes by. The query and alive are answered as the notifications below say; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. The terminal posts the notifications, as below.
|
||||
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
|
||||
- 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first: 3 by 3 dots in the state's color, which the owner asked for, moving, after the S1 orb of aicss.dev; the dots and their motion are ELYTerminal's own. Working ripples out from the centre to the edges, then the corners, once every 1.2 s; with progress the outer ring fills clockwise from the top, as Ely's `ProgressRing` did, and the next dot pulses, the centre last. Blocked, in the theme's warning color, stays lit and dips twice every 1.6 s, whatever its kind, which hover names. Error, a program's or a failed last command's, draws an X in the danger color; done a check in the success color; idle a dimmer ring in the muted color. A settled shape lights dot by dot, then holds still: one that kept moving would draw the window for as long as it stands. The dots outside a shape stay faint. Every mark steps on one clock, every 80 ms, so the marks move together, and the window draws again at each step only while a mark it shows moves: a working program costs 12.5 frames a second, not one at every refresh of the display, which kept the loader still before. Every mark takes a 24-pixel box, so the title stays put as the state changes; the dots fill 16 of it, an icon's room. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status.
|
||||
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
|
||||
- 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a repeat or a release goes as a record where its press did, a repeat that types after its modifiers came up too, and a release goes so only there; a full reset ends the mode, as Windows Terminal's does. A key that is down is known by its code, AppKit's key code on macOS and the virtual key on Windows, since gpui names a release by the modifiers left down: a release after Shift came up still goes out. Linux has only gpui's name. A console record holds no Windows key, so no Windows terminal passes herdr a `cmd` binding, and a keypad key goes as its main key, since gpui names it so. On Linux gpui keeps neither the key code nor its xkb state, and names a non-ASCII key by its place on a US keyboard, so a key's own character cannot be found: Shift with a symbol and Ctrl, Alt or Super goes as the symbol without Shift, which herdr matches to a binding on the symbol, `ctrl+{`.
|
||||
- 2026-10-18: inside herdr, herdr opens its panes' links, as the owner's Cmd-press and Ctrl-press ask. herdr 0.9.5 wraps a long URL at its pane's edge with no wrap flag and writes its panes' OSC 8 links to the terminal without their targets, so ELYTerminal's own finder opened a cut URL or nothing; herdr itself opens the link under a Ctrl-press, whole from its own pane, a wrapped URL or an OSC 8 target, and underlines it while Ctrl is held. So while herdr's client leads the session and tracks the mouse, every link press goes to herdr as the mouse report it is, and on macOS herdr hears Cmd as Ctrl, the wheel aside. herdr's rules come with it, by its design: while it takes the mouse it keeps its panes' OSC 8 targets from the terminal, so that it alone answers Ctrl-press, and it opens only `http` and `https` links; a `file://` link inside herdr stays shut, since the text the terminal sees may be the label of another link. Shift with the press, which takes the mouse back from a program, is the way past herdr its docs name: the terminal then opens what it sees, a `file://` link too. With herdr's mouse capture off, herdr writes its links' targets out and the terminal opens them itself. Elsewhere ELYTerminal opens the link itself, as before.
|
||||
- 2026-10-18: Ely peeks over the main window's top edge, which the owner asked for after the 0.1.3 film, her head and fingers drawn from the icon's own paths and cut at the edge she holds, in a transparent window of her own, 112 by 107 points with 16 above her for her hops, 140 in from the pane's left edge, less on a pane too narrow for that, so she holds its edge at any width, and the main window seems to hide the rest of her. Her window never takes the keys from the terminal: on macOS her panel becomes key only if needed, which gpui's view never asks; on Windows her window answers a press's activation with none, every other message passed on to gpui's own procedure; on X11 and Wayland a press moves no focus. gpui moves no window and places its popups only on Wayland, so each platform keeps her there its own way: on macOS gpui's popup panel is made the main window's child, at its level and in its Spaces, without a shadow, so AppKit moves it and hides it with the window; on Windows gpui's popup is owned by the main window and loses the topmost place gpui gives it, so it stays just above the main window and hides with it; gpui makes it an overlapped window, which Windows gives a caption and a frame, and opens it as large as the display when the middle of the box asked for lies off every display, as hers does over a window near the screen's top: in 0.1.4 a user found her at the bottom of a framed window the display's size, which took the clicks meant for the windows behind; so it becomes a bare popup, without caption, frame, corners or shadow, and the glue posts her window her bottom left corner on the pane's top edge as the main window moves, since Windows sends a new size at once and gpui hears it only outside its own updates, as gpui defers its own resize; her window sizes her at the DPI it has when the place lands, so her height is her own DPI's and her bottom the pane's top, whatever the two windows' DPI, and a place posted before her DPI changed sizes her at the new one; at a new DPI gpui takes the rect Windows suggests, and once the move that brought it is done she takes her box at that DPI on the corner her last place gave her, while that DPI is still hers and the box lies mostly on a display of it, as Windows reckons a window's display, else Windows' rect stands; so a keep changes no DPI and posts no other, where between two displays a corner kept always, or a keep whose DPI had passed while places piled up, set her DPI back, and back again; on X11 gpui's popup, which no window manager places, is moved onto the pane, stacked just above the window manager's frame as the window moves or comes forward, and unmapped while it hides; on Wayland she is gpui's anchored popup, an xdg_popup on the pane's top edge, which the compositor keeps there; gpui moves a popup's anchor never, so a new width that moves her place opens her again. She opens with the sessions' mood and moves all the time, as the films move her, which the owner asked for after 0.1.4, where she stood still but for a press. She is rigged from the icon's paths as the films rig her, in layers of one color, each an SVG mask gpui rasterizes once at its size and moves with a matrix of its own after its pixels are placed, so she moves between pixels; her head bobs with her breath while her neck, collar and hands stay on the edge, her side locks sway, her ahoge breathes, and she blinks on her own, at times twice. Her mood sets her act: calm, she glances left, then right, her head after her eyes, and dozes after 5 minutes, her eyes falling shut, her head and ahoge drooping, zs floating up; a program that works runs her eyes along the terminal's lines while her fingers tap by turns; one that waits on you widens her eyes, opens her mouth, rocks a mark beside her and hops her twice every 2.6 s; an error raises her brows and shakes her, more softly every 4.5 s after, while a drop runs down; a finish cheers her for 4 s with smiling eyes, a hop and sparkles; a press winks at you while a heart rises. Each act begins with her eyes opening and her mouth popping, and a change of act, a change of mood though the act stays, as inside a wink, and a press each ease her from the pose she was in, in 0.3 s, her brows with her, the marks she leaves fading. She draws at every frame the display gives her window while it shows, and gpui gives a hidden window none; her time is folded into each move's round in f64, so weeks of one mood keep her steps fine. Settings › Terminal › Ely turns her off; she is on by default.
|
||||
Generated
+30
-3
@@ -1701,7 +1701,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git?rev=cdc5ce8247b58a876ffed9626953c3cb490a67ed#cdc5ce8247b58a876ffed9626953c3cb490a67ed"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git?rev=da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721#da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -1747,10 +1747,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "elyterminal"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
"base64",
|
||||
"block",
|
||||
"core-graphics 0.24.0",
|
||||
"dirs 7.0.0",
|
||||
@@ -1774,11 +1775,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"signal-hook",
|
||||
"sysinfo 0.39.6",
|
||||
"unicode-normalization",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-server",
|
||||
"windows-sys 0.61.2",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
@@ -4463,6 +4466,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -6509,6 +6522,20 @@ dependencies = [
|
||||
"windows 0.57.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.39.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-fonts"
|
||||
version = "0.1.2"
|
||||
@@ -8555,7 +8582,7 @@ dependencies = [
|
||||
"rand 0.8.8",
|
||||
"screencapturekit",
|
||||
"screencapturekit-sys",
|
||||
"sysinfo",
|
||||
"sysinfo 0.31.4",
|
||||
"tao-core-video-sys",
|
||||
"windows 0.61.3",
|
||||
"windows-capture",
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "elyterminal"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
@@ -11,8 +11,9 @@ publish = false
|
||||
[dependencies]
|
||||
alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
dirs = "7"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "cdc5ce8247b58a876ffed9626953c3cb490a67ed" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721" }
|
||||
env_logger = "0.11"
|
||||
futures = "0.3"
|
||||
gethostname = "1.1.0"
|
||||
@@ -57,6 +58,14 @@ core-graphics = { version = "0.24", features = ["highsierra"] }
|
||||
foreign-types = "0.5"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("cargo-clippy"))'] }
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
||||
|
||||
- One window. Sessions on the left, the terminal in the middle, Git or Files on the right.
|
||||
- Every setting has a control in the settings window. No setting lives only in a file.
|
||||
- Silent: no sound, no bell, no notifications, no update checks, no telemetry, no tips, no onboarding.
|
||||
- Silent: no sound, no bell, no notifications but a session's, below, no update checks, no telemetry, no tips, no onboarding.
|
||||
- Clean: no subtitle, no tagline, no logging options, no status bar, no badges that do not carry state.
|
||||
- Built-in themes, each shown as a small preview, Catppuccin among them. A theme colors the terminal and the chrome.
|
||||
- The window background blurs on macOS, Windows, Wayland and X11.
|
||||
@@ -20,7 +20,7 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
||||
- `alacritty_terminal` 0.26 for the grid, the parser and the pseudo-terminal, as Ely uses it.
|
||||
- Logs through `log` to stderr, `RUST_LOG` sets the level. No log setting in the app.
|
||||
- CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test.
|
||||
- Private repo `ELYTerminal` on git.zacharyzhang.com. No credential in any file or commit.
|
||||
- Repo `ELYTerminal` on git.zacharyzhang.com, private until v0.1.1, public since. No credential in any file or commit.
|
||||
|
||||
## 3. Settings model and storage
|
||||
|
||||
@@ -36,22 +36,29 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
||||
- A theme holds background, foreground, cursor, cursor text, selection background and foreground, and the 16 ANSI colors. Values come from the theme's upstream files (iTerm2-Color-Schemes' Ghostty export, or Ely's palette).
|
||||
- A theme maps onto Ely's `Palette` through `Theme::set_palette`, so panels, inputs and lists wear the theme.
|
||||
- Dark mode: one theme, or a second theme for when the system is dark.
|
||||
- On Omarchy 4 (Linux), an Omarchy theme wears Omarchy's current theme and changes with each switch. It is the default where Omarchy runs; picking another theme stops following.
|
||||
|
||||
## 5. Terminal engine
|
||||
|
||||
Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `alacritty_terminal`; Ely's `Terminal` is a gallery widget without mouse reporting, IME, kitty keys or style settings.
|
||||
|
||||
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC 7 scanner. The scanner reports the working directory.
|
||||
- PTY: alacritty's `tty` and event loop. The pty is wrapped so its reader passes bytes through an OSC scanner. The scanner reports the working directory and prompt marks.
|
||||
- Shell integration for zsh, bash, fish and PowerShell, injected at launch as Ghostty does: each prompt sends OSC 7 and a title. A shell without integration reports nothing, and the panels say so.
|
||||
- Prompt marks, OSC 133: the shell integration marks where each prompt starts (A) and ends (B), where a command starts (C), and where it ends with its exit status (D), all but C on macOS's bash 3.2, which has no hook for it; the terminal reads any program's OSC 133. Cmd-Up and Cmd-Down, and Cmd-Shift-Up and Cmd-Shift-Down, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt before or after the view's top, as in Ghostty.
|
||||
- Program status, OSC 7501 revision 0.4: each terminal keeps the records programs report (state, id, kind, progress, app, title, msg), checked as the spec checks them; a new prompt drops working, blocked and idle records, a full reset drops them all; the terminal answers the support query with the states and kinds it takes.
|
||||
- Progress, OSC 9;4: set, error, indeterminate and pause stand as the root record until a program reports OSC 7501, until a full reset.
|
||||
- Notifications: OSC 9 (iTerm2), OSC 777 notify (rxvt) and OSC 99 (kitty: title and body in chunks, base64, ids, focus and report on activation, close, expiry, which ones live, and its query; `o` passes by, since a session posts only while unseen, below); ConEmu's other OSC 9 commands are no notifications.
|
||||
- Inside herdr, which keeps its panes' OSC 7, the session's folder is that of herdr's focused pane, read from herdr's socket while herdr's client runs in the session, on macOS, Linux and Windows.
|
||||
- Inside herdr, which passes no OSC 7501 on, herdr's agent states (idle, working, blocked, done) are the session's status records.
|
||||
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
|
||||
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
- Cursor: block, bar, underline, hollow block; blinking per setting and per DECSCUSR; hollow when the window is not focused.
|
||||
- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default.
|
||||
- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags; a key Shift makes a symbol of goes as its own key with Shift, the symbol its alternate, as Ghostty sends it, on macOS and Windows. On Windows keys go as win32-input-mode records while ConPTY asks, as Windows Terminal sends them. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default.
|
||||
- IME: marked text drawn at the cursor; committed text sent to the program.
|
||||
- Mouse: select by character, word (double press) and line (triple press); Shift extends; mouse reporting (X10, normal, button, any; SGR and UTF-8); wheel scrolls back, or reports, or sends arrows on the alternate screen.
|
||||
- Clipboard: copy, paste (bracketed when asked), copy on select per setting, OSC 52 writes.
|
||||
- Links: OSC 8 and plain URLs; Cmd-press (Ctrl-press off macOS) opens them.
|
||||
- Links: OSC 8 and plain URLs; Cmd-press (Ctrl-press off macOS) opens them. Inside herdr, while it takes the mouse, herdr opens them, web links only, as herdr does, whole where its pane wraps them, and underlines them while the key is held; with Shift the terminal opens what it sees.
|
||||
- Focus reporting, title (OSC 0/2), scrollback limit per setting, find in scrollback (Ely's `FindWidget`).
|
||||
- Bell: no sound. The session row marks it until the session is shown.
|
||||
|
||||
@@ -59,8 +66,11 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
- Ely's `ColumnShell`: sessions column, terminal column, right column. The window opens with blur when the setting is on.
|
||||
- The sessions column and the right column toggle, each by a button at its end of the title bar or by key, and keep their state in settings, so the window opens as it was left.
|
||||
- The sessions column and the right column resize by a drag on their border toward the terminal, on macOS, Windows and Linux, from 160 to 960 pixels; the widths are kept in settings once a drag rests, so the window opens with them.
|
||||
- The window opens at the size it was left at, on macOS, Windows and Linux: the size is kept in settings once it settles; a maximized or full-screen window keeps the size from before; a kept size past the screen shrinks to it; the window is no smaller than 100 by 100, the least the settings keep.
|
||||
- The title shows the active session's title, and no other text.
|
||||
- The pointer over the terminal is the arrow.
|
||||
- Ely, the icon's girl, peeks over the window's top edge from behind it, on macOS, Windows and Linux. She moves all the time, as she does in the films: she breathes, sways and blinks, and how the sessions are doing, waiting, an error, work, a finish or calm, moves her. A press pets her. A switch in Settings, Terminal, turns her off.
|
||||
- The title bar is transparent, as Ghostty's: the window's background, opacity and blur run under it, so it wears the terminal's color.
|
||||
- Every control in the title bar takes its own press on every platform, the window buttons Ely draws on Windows and Linux too.
|
||||
|
||||
@@ -70,6 +80,9 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
- New session (+ and Cmd-T), close (row's ×, Cmd-W), select (press, Cmd-1…9, Ctrl-Tab and Ctrl-Shift-Tab).
|
||||
- A session whose program ends closes its row. The last row closing closes the window.
|
||||
- A bell in a hidden session marks its row.
|
||||
- A row shows its session's status: working, with its progress where known; waiting on the user, for a permission, a question or a login; done; error; and a mark when the last command failed. Hover shows each record's program, title and message. A hidden session that starts waiting marks its row until it is shown.
|
||||
- The mark is 3 by 3 dots that move: work ripples, or fills by its progress; waiting dips; a settled state draws its shape, then holds still.
|
||||
- System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99 notification, post one while the window is not active or the session is hidden, one a session every 3 seconds at most; it names the session, and a press on it shows the window and the session. On macOS only the app bundle posts them.
|
||||
|
||||
## 8. Right panel: Files
|
||||
|
||||
@@ -92,7 +105,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in the title bar. Categories on the left, rows on the right, each side scrolling on its own, Ely's components. Changes apply at once to every session and save.
|
||||
|
||||
- Appearance
|
||||
- Theme: a grid of previews.
|
||||
- Theme: a grid of previews; on Omarchy, the Omarchy theme first.
|
||||
- Separate dark theme: a switch; when on, a second grid for dark mode.
|
||||
- Font family: a list of installed fonts. Font size. Line height. Ligatures.
|
||||
- Cursor: style (block, bar, underline, hollow), blink, with a live preview.
|
||||
@@ -104,13 +117,14 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
||||
- Scrollback lines.
|
||||
- Option as Alt: off, left, right, both (macOS only).
|
||||
- Copy on select.
|
||||
- Ely: a switch.
|
||||
- Shortcuts: every shortcut, as this platform's keys, with a search.
|
||||
|
||||
## 11. Background blur
|
||||
|
||||
- macOS: gpui's `WindowBackgroundAppearance::Blurred` (NSVisualEffectView).
|
||||
- Windows: gpui's `Blurred` (acrylic accent).
|
||||
- Wayland: gpui's `Blurred` speaks `org_kde_kwin_blur`; ELYTerminal adds `ext_background_effect_v1` on the window's surface, which GNOME 51 and Plasma 6.7 support.
|
||||
- Wayland: gpui's `Blurred` speaks `org_kde_kwin_blur`; ELYTerminal adds `ext_background_effect_v1` on the window's surface, which GNOME 51, Plasma 6.7 and Hyprland support. Hyprland honors it only while its own blur is on, which Omarchy 4.0.4 turns off.
|
||||
- X11: ELYTerminal sets `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32) on the window, as Ghostty does; removes it when blur turns off.
|
||||
- A compositor with neither protocol shows transparency without blur. The app logs which path it took.
|
||||
|
||||
@@ -139,4 +153,4 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
||||
|
||||
## Deferred
|
||||
|
||||
Splits, tabs inside a session, profiles, key rebinding, kitty graphics, sixel, OSC 133 prompt marks, session restore, push and pull, quick terminal.
|
||||
Splits, tabs inside a session, profiles, key rebinding, kitty graphics, sixel, session restore, push and pull, quick terminal.
|
||||
@@ -41,14 +41,17 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
||||
|
||||
## What it does
|
||||
|
||||
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback.
|
||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title.
|
||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound.
|
||||
- A button at each end of the title bar hides or shows its side column; the window opens as it was left. On Windows and Linux a gear beside the right one opens Settings.
|
||||
- Files shows the session's folder as a tree with Git's marks.
|
||||
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol, and on Windows win32-input-mode, as Windows Terminal sends them, so herdr's shortcuts work; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; inside herdr, herdr opens them, web links only, whole where its pane wraps them, and with Shift the terminal opens what it sees; find searches the scrollback.
|
||||
- Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status.
|
||||
- Ely peeks over the window's top edge and moves all the time: she breathes, sways and blinks. She follows your sessions: she hops when a program waits on you, shakes at an error, cheers a finish, reads along while a program works, and dozes when all is calm for long. Press her and she winks. Settings › Terminal › Ely turns her off.
|
||||
- Sessions: new, close and switch. A bell marks a hidden session's row and makes no sound. A row's mark shows what its programs report through OSC 7501, OSC 9;4 or herdr: waiting on you, an error, work with its progress, done or idle, else a failed last command. The mark is 3 by 3 dots: work ripples and waiting dips while they last. Hover over the mark lists each report.
|
||||
- Notifications: while the window is in the background or the session hidden, a session that starts waiting, finishes or fails, and a program's OSC 9, 777 or 99, post a system notification, one a session every 3 seconds at most; a press shows the session. On macOS only the app bundle posts them, not `cargo run`.
|
||||
- Cmd-Up and Cmd-Down, Ctrl-Shift-Up and Ctrl-Shift-Down off macOS, jump to the prompt before or after the view's top.
|
||||
- A button at each end of the title bar hides or shows its side column, and a drag on the border between a side column and the terminal resizes it; the window opens as it was left, at the size and widths it was left at. On Windows and Linux a gear beside the right one opens Settings.
|
||||
- Files shows the session's folder as a tree with Git's marks. Inside herdr the folder is that of herdr's focused pane.
|
||||
- Git shows the branch, stages, unstages, discards, commits, lists recent commits and opens diffs, all through the `git` command line.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark.
|
||||
- Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads.
|
||||
- Themes: Ely Dark, Ely Light, Catppuccin Mocha, Macchiato, Frappé and Latte, Tokyo Night, Dracula, Nord, Gruvbox Dark, Rosé Pine, Solarized Light, One Light and Kanagawa Wave. A second theme can take over while the system is dark. On Omarchy 4, an Omarchy theme follows the system's theme as it switches, and is the default there.
|
||||
- Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`.
|
||||
|
||||
## Keys
|
||||
|
||||
|
||||
@@ -71,4 +71,61 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
||||
|
||||
## 16. Fixes from the owner's first run
|
||||
|
||||
- [ ] T26 On Windows every title bar control takes its press, Ely's window buttons too: in Ely, window buttons and header items occlude the drag region; the Git and Files switch moves below the title bar, centered; on Windows and Linux a gear in the title bar opens Settings; on Windows Ctrl-V pastes, as in Windows Terminal; the pointer over the terminal is the arrow; the settings window drops General, whose Shell and Start in lead Terminal, which lists every shortcut, and its categories and page scroll apart; the release's macOS disk image signed with Developer ID and notarized on the signing runner; release v0.1.1 with the three builds.
|
||||
- [x] T26 On Windows every title bar control takes its press, Ely's window buttons too: in Ely, window buttons and header items occlude the drag region; the Git and Files switch moves below the title bar, centered; on Windows and Linux a gear in the title bar opens Settings; on Windows Ctrl-V pastes, as in Windows Terminal; the pointer over the terminal is the arrow; the settings window drops General, whose Shell and Start in lead Terminal, which lists every shortcut, and its categories and page scroll apart; the release's macOS disk image signed with Developer ID and notarized on the signing runner; release v0.1.1 with the three builds.
|
||||
|
||||
## 17. Omarchy
|
||||
|
||||
- [x] T27 On Omarchy 4, an Omarchy theme wears the system's current theme and follows each switch, the default there; the v0.1.1 Linux tarball rebuilt with it, in place of the old one on the release.
|
||||
- [x] T28 Blur on Hyprland, which the owner found absent on Omarchy: Hyprland blurs behind a window only while its own `decoration:blur:enabled` is on, which Omarchy 4.0.4 turns off; the PRD, README, AGENTS.md, the acceptance table and the release notes say so, and how to turn it on.
|
||||
|
||||
## 18. herdr
|
||||
|
||||
- [x] T29 Inside herdr the right column follows herdr's focused pane, which the owner found frozen: herdr keeps its panes' OSC 7, so on macOS and Linux ELYTerminal reads the folder from herdr's socket while herdr's client runs in the session's foreground.
|
||||
- [x] T30 The main window opens at the size it was left at, on macOS, Windows and Linux, which the owner asked for: the size is kept in the settings.
|
||||
- [x] T31 Release v0.1.2 with T29 and T30: version 0.1.2; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes say it fixes herdr compatibility and keeps the window's size.
|
||||
- [x] T32 On Windows the right column follows herdr's focused pane too, which the owner found frozen there: ELYTerminal finds herdr's client under the session's shell, reads its arguments and variables, and asks herdr's named pipe.
|
||||
|
||||
## 19. Column widths
|
||||
|
||||
- [x] T33 The sessions and right columns resize by a drag on their border toward the terminal, on macOS, Windows and Linux, which the owner asked for: in Ely, a `ShellColumn` resizable between a least and a greatest width; the two widths kept in the settings.
|
||||
|
||||
## 20. Prompts, program status and notifications
|
||||
|
||||
- [x] T34 OSC 133 from the shell integration, which the owner asked for with everything below: zsh, bash, fish and PowerShell mark each prompt's start (A) and end (B), a command's start (C), and its end with the exit status (D); the tap reads any program's OSC 133; each session keeps its last command's status.
|
||||
- [x] T35 Jump to prompt: a prompt's first row is marked once the prompt is drawn; Cmd-Up and Cmd-Down, and Cmd-Shift-Up and Cmd-Shift-Down, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt before or after the view's top, as in Ghostty.
|
||||
- [x] T36 OSC 7501 program status, revision 0.4: reports checked as the spec checks them; records per terminal, by id, with clear and a cap of 256; a new prompt drops working, blocked and idle records, a full reset all; the support query answered with the states and kinds.
|
||||
- [x] T37 OSC 9;4 progress as the root record until an OSC 7501 report; OSC 9, 777 and 99 notifications read, OSC 99's chunks, base64, ids, occasions, actions, close and query included; ConEmu's other OSC 9 commands are no notifications.
|
||||
- [x] T38 Inside herdr, herdr's agent states from its snapshot are the session's records.
|
||||
- [x] T39 Session rows show the status: working with its progress, waiting by kind, done, error, a failed last command; hover shows each record's program, title and message; a hidden session that starts waiting marks its row.
|
||||
- [x] T40 System notifications on macOS, Windows and Linux through gpui's: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99 requests, while the window is inactive or the session hidden, rate-limited; a press shows the window and the session.
|
||||
|
||||
## 21. Release
|
||||
|
||||
- [x] T41 Release v0.1.3 with T32 to T40: version 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
|
||||
|
||||
## 22. Status marks that move
|
||||
|
||||
- [x] T42 The status marks move, which the owner asked for after aicss.dev's orbs: 3 by 3 dots in the state's color; work ripples, or fills by its progress, waiting dips, a settled state draws its shape, then holds; the window draws again only at the marks' steps while one moves.
|
||||
|
||||
## 23. Release
|
||||
|
||||
- [x] T43 v0.1.3 rebuilt with T42, which the owner asked for in place of a v0.1.4: the version stays 0.1.3; the macOS disk image, the Windows zip and the Linux tarball, each from its script, replace the old ones on the release; the tag moves to their commit; the notes say what changed.
|
||||
|
||||
## 24. Keys, links and Ely
|
||||
|
||||
- [x] T44 herdr's shortcuts work, which a user found dead: `cmd+shift+ctrl+[` and `]` on macOS, and the same keys as herdr reads them on Linux and Windows: under kitty's flags a key Shift makes a symbol of goes as its own key with Shift, the symbol its alternate, on macOS and Windows; on Windows keys go as win32-input-mode records, which ConPTY asks for and herdr reads; on Linux gpui keeps no key code, which the docs say.
|
||||
- [x] T45 Cmd-press on macOS, Ctrl-press on Windows and Linux opens the link under the pointer, which the owner asked for, wherever the terminal shows one: inside herdr, which wraps long URLs at its pane's edge and passes no OSC 8 target on, while herdr takes the mouse the press goes to herdr, which opens its pane's link whole, web links only, as herdr does, Cmd heard as Ctrl on macOS.
|
||||
- [x] T46 Ely peeks over the main window's top edge from behind it, which the owner asked for after the 0.1.3 film, with several faces and moves; a switch in Settings, Terminal, turns her off: a window of her own kept on the edge, macOS's child window, Windows' owned popup, X11's popup moved and stacked, Wayland's anchored popup; ten faces from the icon's paths, following the sessions' marks.
|
||||
|
||||
## 25. Release
|
||||
|
||||
- [x] T47 Release v0.1.4 with T44 to T46: version 0.1.4; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
|
||||
|
||||
## 26. Ely on Windows
|
||||
|
||||
- [x] T48 Ely on Windows stands on the main window's top edge in a box her own size, which a user found broken in 0.1.4: gpui opened her window as large as the display when her box's middle lay off it, so she sat at its bottom and the rest of it took the clicks meant for the windows behind; her window is a bare popup, without the caption, border, corners and shadow gpui's popup carries, and the glue sets her size from her own DPI as well as her place on the pane's top edge.
|
||||
- [x] T49 Ely moves all the time, as she does in the films, which the owner asked for after 0.1.4, where she stood still and only a press made her wink: rigged from the icon's paths as the films rig her, she bobs, her side locks sway and her ahoge breathes, she blinks and glances on her own, and each mood moves her through that rig, smoothly.
|
||||
|
||||
## 27. Release
|
||||
|
||||
- [ ] T50 v0.1.4 rebuilt with T48 and T49, which the owner asked for in place of a v0.1.5: the version stays 0.1.4; the macOS disk image, the Windows zip and the Linux tarball, each from its script, replace the old ones on the release; the tag moves to their commit; the notes give the new SHA-256 and what changed.
|
||||
@@ -1,4 +1,4 @@
|
||||
# ELYTerminal: load the usual startup files, report folder and title.
|
||||
# ELYTerminal: load the usual startup files, report folder and title, mark prompts and commands.
|
||||
if [ -n "${ELY_BASH_LOGIN-}" ]; then
|
||||
builtin unset ELY_BASH_LOGIN
|
||||
[ -r /etc/profile ] && builtin source /etc/profile
|
||||
@@ -12,8 +12,20 @@ if [ -n "${ELY_BASH_LOGIN-}" ]; then
|
||||
else
|
||||
[ -r ~/.bashrc ] && builtin source ~/.bashrc
|
||||
fi
|
||||
_ely_ran=
|
||||
_ely_n=0
|
||||
_ely_ps0=
|
||||
if (( BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1] >= 404 )); then
|
||||
# PS0 runs in the shell, so its subscript can note that a command ran.
|
||||
_ely_ps0='${_ely_note[_ely_ran=1]-}\e]133;C\a'
|
||||
fi
|
||||
_ely_prompt() {
|
||||
builtin local ret=$? LC_ALL=C char url= i title
|
||||
if [ -n "$_ely_ran" ]; then
|
||||
builtin printf '\e]133;D;%s\a' "$ret"
|
||||
_ely_ran=
|
||||
fi
|
||||
[ -n "$_ely_ps0" ] || _ely_d=([1]=$'\e]133;D;'"$ret"$'\a')
|
||||
for (( i = 0; i < ${#PWD}; i++ )); do
|
||||
char=${PWD:i:1}
|
||||
case $char in
|
||||
@@ -25,4 +37,20 @@ _ely_prompt() {
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${title//[[:cntrl:]]/}"
|
||||
builtin return $ret
|
||||
}
|
||||
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
|
||||
# Last, after anything that sets PS1 or PS0.
|
||||
_ely_mark() {
|
||||
if [[ $PS1 != *'\e]133;A'* ]]; then
|
||||
if [ -z "$_ely_ps0" ] && builtin shopt -q promptvars; then
|
||||
# bash 3.2 has no PS0; \#, the command number, moves once a command ran.
|
||||
PS1='\[${_ely_d[_ely_n && \# != _ely_n]-}${_ely_note[_ely_n = \#]-}\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
|
||||
else
|
||||
PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
|
||||
fi
|
||||
fi
|
||||
if [ -n "$_ely_ps0" ] && builtin shopt -q promptvars && [[ ${PS0-} != *"$_ely_ps0"* ]]; then
|
||||
PS0=$_ely_ps0${PS0-}
|
||||
fi
|
||||
}
|
||||
PROMPT_COMMAND="_ely_prompt${PROMPT_COMMAND:+;$PROMPT_COMMAND};_ely_mark"
|
||||
# PS0 and the prompt mark a command only through expansions, which promptvars turns on.
|
||||
builtin shopt -q promptvars || builtin printf 'ELYTerminal: bash marks no command while promptvars is off (shopt -s promptvars)\n' >&2
|
||||
@@ -1,4 +1,4 @@
|
||||
# ELYTerminal: restore XDG_DATA_DIRS, report the folder; fish_title keeps the title.
|
||||
# ELYTerminal: restore XDG_DATA_DIRS, report the folder, mark what this fish leaves unmarked; fish_title keeps the title.
|
||||
if test -n "$ELY_FISH_XDG_DATA_DIRS"
|
||||
set -gx XDG_DATA_DIRS $ELY_FISH_XDG_DATA_DIRS
|
||||
else
|
||||
@@ -9,4 +9,33 @@ if status is-interactive
|
||||
function _ely_prompt --on-event fish_prompt
|
||||
printf '\e]7;file://%s\a' (string escape --style=url -- $PWD)
|
||||
end
|
||||
# fish 4.3 marks its own prompts and commands; 4.0 all but the prompt's end; older ones none.
|
||||
set -l v (string split ' ' -- (string replace -r '^(\d+)\.(\d+).*' '$1 $2' -- $version))
|
||||
if test $v[1] -lt 4
|
||||
set -g _ely_marks A B
|
||||
function _ely_preexec --on-event fish_preexec
|
||||
printf '\e]133;C\a'
|
||||
end
|
||||
function _ely_postexec --on-event fish_postexec
|
||||
printf '\e]133;D;%s\a' $status
|
||||
end
|
||||
else if test $v[1] -eq 4 -a $v[2] -lt 3
|
||||
set -g _ely_marks B
|
||||
end
|
||||
if set -q _ely_marks
|
||||
# The user's prompt loads late and may change; the marks go around the one shown.
|
||||
function _ely_wrap --on-event fish_prompt
|
||||
if functions -q _ely_user_prompt; and string match -q '*_ely_user_prompt*' -- (functions fish_prompt)
|
||||
return
|
||||
end
|
||||
functions -q _ely_user_prompt; and functions -e _ely_user_prompt
|
||||
functions -c fish_prompt _ely_user_prompt
|
||||
function fish_prompt
|
||||
# First, so the user's prompt sees the command's status.
|
||||
set -l text (_ely_user_prompt | string collect -N)
|
||||
contains A $_ely_marks; and printf '\e]133;A\a'
|
||||
printf '%s\e]133;B\a' "$text"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,24 @@
|
||||
# ELYTerminal: wrap the prompt to report folder and title.
|
||||
# ELYTerminal: wrap the prompt to report folder and title and to mark prompts and commands.
|
||||
$global:ElyPrompt = $function:prompt
|
||||
$global:ElyRan = $false
|
||||
function global:prompt {
|
||||
# First: every statement sets $? again.
|
||||
$ok = $?
|
||||
$esc, $bel = [char]27, [char]7
|
||||
if ($global:ElyRan) {
|
||||
$code = 0
|
||||
if (-not $ok) {
|
||||
# Each native run sets a new $LASTEXITCODE, equal or not; strict mode refuses an unset one.
|
||||
$exit = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction Ignore
|
||||
$ran = $null -ne $exit -and -not [object]::ReferenceEquals($exit, $global:ElyExit)
|
||||
$code = if (-not $global:ElyShell -and $ran -and $exit -ne 0) { $exit } else { 1 }
|
||||
}
|
||||
[Console]::Write("$esc]133;D;$code$bel")
|
||||
$global:ElyRan = $false
|
||||
}
|
||||
[Console]::Write("$esc]133;A$bel")
|
||||
# The user's prompt sees a failed command's $? still.
|
||||
if (-not $ok) { Write-Error '' -ErrorAction Ignore }
|
||||
$text = & $global:ElyPrompt
|
||||
$folder = $executionContext.SessionState.Path.CurrentLocation
|
||||
if ($folder.Provider.Name -eq 'FileSystem') {
|
||||
@@ -8,8 +26,40 @@ function global:prompt {
|
||||
$path = ($parts | ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
|
||||
if (-not $path.StartsWith('/')) { $path = "/$path" }
|
||||
$leaf = (Split-Path -Leaf $folder.ProviderPath) -replace '\p{Cc}', ''
|
||||
$esc, $bel = [char]27, [char]7
|
||||
[Console]::Write("$esc]7;file://$path$bel$esc]2;$leaf$bel")
|
||||
}
|
||||
$text
|
||||
"$text$esc]133;B$bel"
|
||||
}
|
||||
# A command starts once PSReadLine hands over a line that is not blank.
|
||||
if (Test-Path Function:\PSConsoleHostReadLine) {
|
||||
$global:ElyReadLine = $function:PSConsoleHostReadLine
|
||||
function global:PSConsoleHostReadLine {
|
||||
$line = & $global:ElyReadLine
|
||||
if (-not [string]::IsNullOrWhiteSpace($line)) {
|
||||
$global:ElyRan = $true
|
||||
$global:ElyExit = Get-Variable LASTEXITCODE -Scope Global -ValueOnly -ErrorAction Ignore
|
||||
# A program's code is the line's, unless the line surely ends in PowerShell: a cmdlet, a name not found, or no command.
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseInput($line, [ref]$null, [ref]$null)
|
||||
# Strict mode refuses .Count on one statement alone, so the statements stay a list.
|
||||
$statements = @(if ($ast.EndBlock) { $ast.EndBlock.Statements })
|
||||
$last = if ($statements.Count) { $statements[$statements.Count - 1] }
|
||||
if ($last -is [System.Management.Automation.Language.PipelineAst]) {
|
||||
$elements = $last.PipelineElements
|
||||
$last = $elements[$elements.Count - 1]
|
||||
}
|
||||
$name = if ($last -is [System.Management.Automation.Language.CommandAst]) { $last.GetCommandName() }
|
||||
$global:ElyShell = if ($name) {
|
||||
$pattern = [System.Management.Automation.WildcardPattern]::Escape($name)
|
||||
$info = Get-Command -Name $pattern -ErrorAction Ignore | Select-Object -First 1
|
||||
while ($info -and $info.CommandType -eq 'Alias') { $info = $info.ResolvedCommand }
|
||||
$null -eq $info -or $info.CommandType -eq 'Cmdlet'
|
||||
} else {
|
||||
$null -eq $last -or -not $last.Find({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)
|
||||
}
|
||||
[Console]::Write("$([char]27)]133;C$([char]7)")
|
||||
}
|
||||
$line
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine('ELYTerminal: PowerShell marks no command without PSReadLine')
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
# ELYTerminal: restore ZDOTDIR, load the user's .zshenv, report folder and title.
|
||||
# ELYTerminal: restore ZDOTDIR, load the user's .zshenv, report folder and title, mark prompts and commands.
|
||||
if [[ -n "${ELY_ZSH_ZDOTDIR+set}" ]]; then
|
||||
ZDOTDIR=$ELY_ZSH_ZDOTDIR
|
||||
builtin unset ELY_ZSH_ZDOTDIR
|
||||
@@ -7,8 +7,14 @@ else
|
||||
fi
|
||||
[[ -r "${ZDOTDIR:-$HOME}/.zshenv" ]] && builtin source "${ZDOTDIR:-$HOME}/.zshenv"
|
||||
if [[ -o interactive ]]; then
|
||||
_ely_ran=
|
||||
_ely_first=1
|
||||
_ely_precmd() {
|
||||
builtin local ret=$? LC_ALL=C char url=
|
||||
if [[ -n $_ely_ran ]]; then
|
||||
builtin printf '\e]133;D;%s\a' $ret
|
||||
_ely_ran=
|
||||
fi
|
||||
for char in ${(s::)PWD}; do
|
||||
case $char in
|
||||
[[:cntrl:]]|%) builtin printf -v char '%%%02X' "'$char" ;;
|
||||
@@ -16,12 +22,28 @@ if [[ -o interactive ]]; then
|
||||
url+=$char
|
||||
done
|
||||
builtin printf '\e]7;file://%s\a\e]2;%s\a' "$url" "${${${PWD:t}:-/}//[[:cntrl:]]/}"
|
||||
# Themes set PS1 in later hooks: the marks go on after them, at the first prompt through sched.
|
||||
precmd_functions=(${precmd_functions:#_ely_mark} _ely_mark)
|
||||
if (( _ely_first )); then
|
||||
_ely_first=0
|
||||
[[ -o prompt_percent ]] || builtin print -ru2 'ELYTerminal: zsh marks no prompt while prompt_percent is off (setopt prompt_percent)'
|
||||
builtin zmodload zsh/sched && builtin sched +0 _ely_mark $ret
|
||||
fi
|
||||
builtin return ret
|
||||
}
|
||||
_ely_mark() {
|
||||
if [[ -o prompt_percent && $PS1 != *$'\e]133;A'* ]]; then
|
||||
PS1=$'%{\e]133;A\a%}'$PS1$'%{\e]133;B\a%}'
|
||||
fi
|
||||
# sched's command leaves its status for the prompt.
|
||||
builtin return ${1:-0}
|
||||
}
|
||||
_ely_preexec() {
|
||||
builtin printf '\e]2;%s\a' "${1//[[:cntrl:]]/}"
|
||||
_ely_ran=1
|
||||
builtin printf '\e]133;C\a\e]2;%s\a' "${1//[[:cntrl:]]/}"
|
||||
}
|
||||
builtin autoload -Uz add-zsh-hook
|
||||
add-zsh-hook precmd _ely_precmd
|
||||
add-zsh-hook precmd _ely_mark
|
||||
add-zsh-hook preexec _ely_preexec
|
||||
fi
|
||||
@@ -21,6 +21,40 @@ pub fn number(window: &gpui::Window) -> anyhow::Result<u32> {
|
||||
u32::try_from(number).context("window number out of range")
|
||||
}
|
||||
|
||||
/// The window and each of its child windows: number and frame, in AppKit's screen points.
|
||||
pub fn frames(number: u32) -> anyhow::Result<Vec<(u32, [f64; 4])>> {
|
||||
use core_graphics::geometry::CGRect;
|
||||
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 children: *mut Object = msg_send![window, childWindows];
|
||||
let count: u64 = if children.is_null() {
|
||||
0
|
||||
} else {
|
||||
msg_send![children, count]
|
||||
};
|
||||
let children = (0..count).map(|ix| msg_send![children, objectAtIndex: ix]);
|
||||
std::iter::once(window)
|
||||
.chain(children)
|
||||
.map(|window: *mut Object| {
|
||||
let number: i64 = msg_send![window, windowNumber];
|
||||
let frame: CGRect = msg_send![window, frame];
|
||||
let corners = [
|
||||
frame.origin.x,
|
||||
frame.origin.y,
|
||||
frame.size.width,
|
||||
frame.size.height,
|
||||
];
|
||||
Ok((
|
||||
u32::try_from(number).context("window number out of range")?,
|
||||
corners,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the window as key, the app left inactive.
|
||||
pub fn front(number: u32) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
|
||||
@@ -38,10 +38,25 @@ fn main() {
|
||||
number.expect("the window lives").expect("a window number")
|
||||
};
|
||||
let mut aim = main;
|
||||
let mut snaps = 0;
|
||||
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() {
|
||||
"wait" => cx.background_executor().timer(Duration::from_secs(1)).await,
|
||||
"snap" => {
|
||||
snaps += 1;
|
||||
let frames = capture::frames(number(aim, cx)).expect("the frames");
|
||||
for (index, (child, _)) in frames.into_iter().skip(1).enumerate() {
|
||||
let path =
|
||||
out.replace(".png", &format!(".child{index}.snap{snaps}.png"));
|
||||
capture::snapshot(child, std::path::Path::new(&path))
|
||||
.expect("a child's picture");
|
||||
log::info!(
|
||||
"shot: snap {snaps}, child window {child}, wrote {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
"to:main" => {
|
||||
aim = main;
|
||||
capture::front(number(aim, cx)).expect("the window on screen");
|
||||
@@ -67,6 +82,14 @@ fn main() {
|
||||
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 frames = capture::frames(number(aim, cx)).expect("the window's frames");
|
||||
log::info!("shot: the window at {:?}", frames[0].1);
|
||||
for (index, (child, at)) in frames.into_iter().skip(1).enumerate() {
|
||||
let path = out.replace(".png", &format!(".child{index}.png"));
|
||||
capture::snapshot(child, std::path::Path::new(&path))
|
||||
.expect("a child's picture");
|
||||
log::info!("shot: child window {child} at {at:?}, wrote {path}");
|
||||
}
|
||||
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() {
|
||||
|
||||
+14
-3
@@ -12,7 +12,7 @@ use objc::{
|
||||
};
|
||||
|
||||
/// US layout key codes and what each key types.
|
||||
const KEYS: [(&str, u16, &str); 32] = [
|
||||
const KEYS: [(&str, u16, &str); 34] = [
|
||||
(",", 43, ","),
|
||||
("a", 0, "a"),
|
||||
("b", 11, "b"),
|
||||
@@ -32,8 +32,10 @@ const KEYS: [(&str, u16, &str); 32] = [
|
||||
("=", 24, "="),
|
||||
("minus", 27, "-"),
|
||||
("m", 46, "m"),
|
||||
("n", 45, "n"),
|
||||
("q", 12, "q"),
|
||||
("[", 33, "["),
|
||||
("]", 30, "]"),
|
||||
("space", 49, " "),
|
||||
("tab", 48, "\t"),
|
||||
("enter", 36, "\r"),
|
||||
@@ -217,7 +219,11 @@ fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
|
||||
/// Posts one spec 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);
|
||||
return match call {
|
||||
"down" => key(text, number, &[10]),
|
||||
"up" => key(text, number, &[11]),
|
||||
call => compose(call, text, number),
|
||||
};
|
||||
}
|
||||
if let "activate" | "deactivate" = spec {
|
||||
unsafe {
|
||||
@@ -231,6 +237,11 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
||||
log::info!("shot: {spec}");
|
||||
return Ok(());
|
||||
}
|
||||
key(spec, number, &[10, 11])
|
||||
}
|
||||
|
||||
/// Posts key `spec`, or a mouse gesture, as AppKit events of `kinds`: 10 down, 11 up.
|
||||
fn key(spec: &str, number: u32, kinds: &[u64]) -> anyhow::Result<()> {
|
||||
let mut parts: Vec<&str> = spec.split('-').collect();
|
||||
let name = parts.pop().context("an empty key")?;
|
||||
let mut flags: u64 = 0;
|
||||
@@ -275,7 +286,7 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
||||
}
|
||||
_ => ignoring.clone(),
|
||||
};
|
||||
for kind in [10u64, 11] {
|
||||
for &kind in kinds {
|
||||
unsafe {
|
||||
let event: *mut Object = msg_send![
|
||||
class!(NSEvent),
|
||||
|
||||
+108
-6
@@ -1,10 +1,10 @@
|
||||
use std::borrow::Cow;
|
||||
use std::{borrow::Cow, cell::RefCell, rc::Rc, time::Duration};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::theme::ActiveTheme;
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, TitlebarOptions, WindowBounds, WindowDecorations, WindowHandle,
|
||||
WindowOptions, px, size,
|
||||
App, AppContext, Bounds, Context, Pixels, Size, Task, TitlebarOptions, Window, WindowBounds,
|
||||
WindowDecorations, WindowHandle, WindowOptions, px, size,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -17,6 +17,9 @@ use crate::{
|
||||
workspace::{self, TITLE, Workspace},
|
||||
};
|
||||
|
||||
/// How long the window's size holds before it is kept, so a drag saves once.
|
||||
const SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// The id the macOS bundle and Linux desktop entry share.
|
||||
pub const APP_ID: &str = "com.zacharyzhang.elyterminal";
|
||||
|
||||
@@ -47,6 +50,23 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
workspace::bind_keys(cx);
|
||||
settings::window::bind_keys(cx);
|
||||
menus::init(cx);
|
||||
cx.set_app_identity(APP_ID, TITLE);
|
||||
// A press on a notification goes to the window whose session posted it.
|
||||
cx.on_system_notification_response(|response, cx| {
|
||||
let tag = response.tag;
|
||||
let pressed = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
.filter_map(|window| window.downcast::<Workspace>())
|
||||
.any(|workspace| {
|
||||
workspace
|
||||
.update(cx, |this, window, cx| this.pressed(&tag, window, cx))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !pressed {
|
||||
log::info!("app: notification {tag} pressed, but no session holds it");
|
||||
}
|
||||
});
|
||||
// Settings alone hold no terminal, so they go with the last one.
|
||||
cx.on_window_closed(|cx, _| {
|
||||
let windows = cx.windows();
|
||||
@@ -64,6 +84,8 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
mac::repeat_held_keys();
|
||||
mac::deliver_command_key_ups()?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
themes::omarchy::start(cx)?;
|
||||
themes::apply(cx);
|
||||
let chosen = |settings: &settings::Settings| {
|
||||
let theme = (settings.theme.clone(), settings.dark_theme.clone());
|
||||
@@ -83,10 +105,16 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
|
||||
/// Opens the main window running `launch`.
|
||||
pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspace>> {
|
||||
let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx);
|
||||
let kept = opening_size(cx);
|
||||
// macOS grows a window by a point on a side whose edge falls on a half point.
|
||||
let centered = Bounds::centered(None, kept, cx);
|
||||
let bounds = Bounds::new(centered.origin.map(|at| at.round()), kept);
|
||||
let background = blur::appearance(SettingsStore::get(cx));
|
||||
let least = px(*settings::WINDOW.start());
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
// No smaller than the settings keep.
|
||||
window_min_size: Some(size(least, least)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some(TITLE.into()),
|
||||
appears_transparent: true,
|
||||
@@ -103,11 +131,85 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
|
||||
window
|
||||
.observe_window_appearance(|_, cx| themes::apply(cx))
|
||||
.detach();
|
||||
cx.new(|cx| Workspace::new(pty, events, launch, window, cx))
|
||||
cx.new(|cx| {
|
||||
keep_size(window, cx);
|
||||
Workspace::new(pty, events, launch, window, cx)
|
||||
})
|
||||
})?;
|
||||
crate::pet::follow(window, cx);
|
||||
log::info!(
|
||||
"app: main window open, blur: {background:?} on {}",
|
||||
"app: main window open at {} by {}, blur: {background:?} on {}",
|
||||
f32::from(kept.width),
|
||||
f32::from(kept.height),
|
||||
std::env::consts::OS
|
||||
);
|
||||
Ok(window)
|
||||
}
|
||||
|
||||
/// The kept size, within the primary display's visible area where that is known.
|
||||
fn opening_size(cx: &App) -> Size<Pixels> {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let room = cx
|
||||
.primary_display()
|
||||
.map(|display| display.visible_bounds().size);
|
||||
let fit = |want: f32, side: Option<Pixels>| match side.map(f32::from) {
|
||||
Some(side) if side > 0.0 && want > side => {
|
||||
log::info!(
|
||||
"app: a kept side of {want} px passes the screen's {side}; the window takes {side}"
|
||||
);
|
||||
px(side)
|
||||
}
|
||||
_ => px(want),
|
||||
};
|
||||
size(
|
||||
fit(settings.window_width, room.map(|area| area.width)),
|
||||
fit(settings.window_height, room.map(|area| area.height)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Keeps the window's size in the settings once it settles; maximized, full screen or hidden, the size before stays.
|
||||
fn keep_size(window: &mut Window, cx: &mut Context<Workspace>) {
|
||||
let settling: Rc<RefCell<Option<Task<()>>>> = Rc::default();
|
||||
let settle = move |window: &mut Window, cx: &mut Context<Workspace>| {
|
||||
let task = cx.spawn_in(window, async move |_, cx| {
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
// Read once settled: X11 marks a window maximized late, and unmaximized while hidden.
|
||||
let kept = cx.update(|window, cx| {
|
||||
if !window.is_visible() || window.is_maximized() || window.is_fullscreen() {
|
||||
log::debug!(
|
||||
"app: the window is hidden, maximized or full screen; its kept size stays"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let area = window.window_bounds().get_bounds().size;
|
||||
let (width, height) = (
|
||||
f32::from(area.width).round(),
|
||||
f32::from(area.height).round(),
|
||||
);
|
||||
SettingsStore::update(cx, |settings| {
|
||||
settings.window_width = width;
|
||||
settings.window_height = height;
|
||||
})
|
||||
.with_context(|| {
|
||||
format!("app: the window's size, {width} by {height}, is not kept")
|
||||
})
|
||||
});
|
||||
match kept {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => log::error!("{error:#}"),
|
||||
Err(_) => log::debug!("app: the window closed before its size settled"),
|
||||
}
|
||||
});
|
||||
settling.replace(Some(task));
|
||||
};
|
||||
let shown = settle.clone();
|
||||
cx.observe_window_bounds(window, move |_, window, cx| settle(window, cx))
|
||||
.detach();
|
||||
// A size skipped while the window hid is read again once it shows.
|
||||
cx.observe_window_visibility(window, move |_, visibility, window, cx| {
|
||||
if visibility.is_visible() {
|
||||
shown(window, cx);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
+6
-5
@@ -1,6 +1,4 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
use gpui::{Bounds, Decorations, Pixels, Size, point};
|
||||
use gpui::{Window, WindowBackgroundAppearance};
|
||||
use gpui::{Bounds, Decorations, Pixels, Size, Window, WindowBackgroundAppearance, point};
|
||||
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -91,8 +89,11 @@ impl Blur {
|
||||
}
|
||||
|
||||
/// The window's pane in its surface: all of it, less the inset on each free edge of a client-drawn window, as Ely's `ResizeBorder` pads it.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pane(size: Size<Pixels>, inset: Option<Pixels>, decorations: Decorations) -> Bounds<Pixels> {
|
||||
pub(crate) fn pane(
|
||||
size: Size<Pixels>,
|
||||
inset: Option<Pixels>,
|
||||
decorations: Decorations,
|
||||
) -> Bounds<Pixels> {
|
||||
let mut pane = Bounds::new(point(Pixels::ZERO, Pixels::ZERO), size);
|
||||
let (Decorations::Client { tiling }, Some(inset)) = (decorations, inset) else {
|
||||
return pane;
|
||||
|
||||
+1
-36
@@ -1,8 +1,3 @@
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
};
|
||||
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection as _,
|
||||
@@ -10,37 +5,7 @@ use x11rb::{
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// An X server of the test's own, gone with it.
|
||||
struct Xvfb {
|
||||
child: Child,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl Xvfb {
|
||||
fn start() -> Self {
|
||||
let mut child = Command::new("Xvfb")
|
||||
.args(["-displayfd", "1", "-nolisten", "tcp"])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Xvfb starts: the X11 tests need it");
|
||||
let mut number = String::new();
|
||||
let stdout = child.stdout.take().expect("Xvfb's stdout");
|
||||
BufReader::new(stdout)
|
||||
.read_line(&mut number)
|
||||
.expect("Xvfb names its display");
|
||||
let display = format!(":{}", number.trim());
|
||||
Self { child, display }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Xvfb {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.child.kill().and_then(|()| self.child.wait().map(drop)) {
|
||||
eprintln!("Xvfb {} did not stop: {error}", self.display);
|
||||
}
|
||||
}
|
||||
}
|
||||
use crate::testx11::Xvfb;
|
||||
|
||||
#[test]
|
||||
fn the_window_names_its_pane_while_blurred() {
|
||||
|
||||
@@ -3,10 +3,13 @@ pub mod blur;
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod menus;
|
||||
pub mod pet;
|
||||
pub mod sessions;
|
||||
pub mod settings;
|
||||
pub mod term;
|
||||
#[cfg(all(test, unix))]
|
||||
mod testlog;
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod testx11;
|
||||
pub mod themes;
|
||||
pub mod workspace;
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
//! Ely, the icon's girl, peeking over the main window's top edge from a window of her own.
|
||||
|
||||
mod look;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod rig;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11;
|
||||
|
||||
use std::{cell::RefCell, rc::Rc, time::Instant};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, Context, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Render, Styled, Subscription, Window, WindowBackgroundAppearance,
|
||||
WindowBounds, WindowHandle, WindowKind, WindowOptions, canvas, div, point, popup, px, size,
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use self::macos as platform;
|
||||
#[cfg(windows)]
|
||||
use self::windows as platform;
|
||||
#[cfg(target_os = "linux")]
|
||||
use self::x11 as platform;
|
||||
use self::{
|
||||
look::{Act, EASE, Mood, Pose, blend, mood, pose},
|
||||
rig::Rig,
|
||||
};
|
||||
use crate::{settings::SettingsStore, workspace::Workspace};
|
||||
|
||||
/// Her head's box, in points, and the room above it her hops take.
|
||||
const WIDTH: f32 = 112.0;
|
||||
const HEAD: f32 = 107.4;
|
||||
const ROOM: f32 = 16.0;
|
||||
/// How far in from the pane's left edge she holds its top edge.
|
||||
const LEFT: f32 = 140.0;
|
||||
|
||||
/// Ely in her window: she moves all the time, her acts follow the sessions' marks, and a press pets her.
|
||||
pub struct Pet {
|
||||
rig: Rc<Rig>,
|
||||
mood: Mood,
|
||||
/// When the mood began, when she came, and when she was last pressed.
|
||||
since: Instant,
|
||||
shown: Instant,
|
||||
petted: Option<Instant>,
|
||||
/// What she was doing, her last pose, and the pose a change of act eases her from, with its time.
|
||||
act: Act,
|
||||
last: Option<Pose>,
|
||||
eased: Option<(Pose, Instant)>,
|
||||
/// How far in from the pane's left edge she opened.
|
||||
left: f32,
|
||||
/// What keeps her window on the main window's edge, where the platform does not.
|
||||
glue: Option<platform::Glue>,
|
||||
_watch: Vec<Subscription>,
|
||||
}
|
||||
|
||||
/// Shows Ely over `main` while the settings want her.
|
||||
pub fn follow(main: WindowHandle<Workspace>, cx: &mut App) {
|
||||
let shown: Rc<RefCell<Option<WindowHandle<Pet>>>> = Rc::default();
|
||||
let sync = {
|
||||
let shown = shown.clone();
|
||||
move |cx: &mut App| {
|
||||
let open = *shown.borrow();
|
||||
match (SettingsStore::get(cx).ely, open) {
|
||||
(true, None) => *shown.borrow_mut() = open_pet(main, cx),
|
||||
(false, Some(pet)) => {
|
||||
*shown.borrow_mut() = None;
|
||||
close(pet, "as the settings say", cx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
};
|
||||
sync(cx);
|
||||
cx.observe_global::<SettingsStore>(sync).detach();
|
||||
// A Wayland popup keeps the place it opened at, so she opens again where a new width puts her.
|
||||
let moved = main.update(cx, |_, window, cx| {
|
||||
cx.observe_window_bounds(window, move |_, window, cx| {
|
||||
let Some(pet) = *shown.borrow() else {
|
||||
return;
|
||||
};
|
||||
let at = left(pane(window).size.width);
|
||||
if wayland(window) && pet.read(cx).is_ok_and(|pet| pet.left != at) {
|
||||
let shown = shown.clone();
|
||||
cx.defer(move |cx| {
|
||||
close(pet, "to stand where the new width puts her", cx);
|
||||
*shown.borrow_mut() = open_pet(main, cx);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
match moved {
|
||||
Ok(moved) => moved.detach(),
|
||||
Err(error) => log::error!("pet: the main window's width goes unwatched: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens her window, logging why when it does not open.
|
||||
fn open_pet(main: WindowHandle<Workspace>, cx: &mut App) -> Option<WindowHandle<Pet>> {
|
||||
show(main, cx)
|
||||
.inspect_err(|error| log::error!("pet: Ely does not show: {error:#}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn close(pet: WindowHandle<Pet>, why: &str, cx: &mut App) {
|
||||
log::info!("pet: Ely goes, {why}");
|
||||
if pet
|
||||
.update(cx, |_, window, _| window.remove_window())
|
||||
.is_err()
|
||||
{
|
||||
log::debug!("pet: her window had gone");
|
||||
}
|
||||
}
|
||||
|
||||
/// How far in from the pane's left edge she stands on a pane `width` points wide: `LEFT`, or less on a narrow pane, so she holds its edge.
|
||||
fn left(width: Pixels) -> f32 {
|
||||
LEFT.min(f32::from(width) - WIDTH).max(0.0)
|
||||
}
|
||||
|
||||
/// Opens her window on `main`'s top edge: a popup Wayland's compositor places, else a window kept there.
|
||||
fn show(main: WindowHandle<Workspace>, cx: &mut App) -> anyhow::Result<WindowHandle<Pet>> {
|
||||
let (bounds, pane, wayland) = main.update(cx, |_, window, _| {
|
||||
(window.bounds(), pane(window), wayland(window))
|
||||
})?;
|
||||
let at = left(pane.size.width);
|
||||
let room = size(px(WIDTH), px(HEAD + ROOM));
|
||||
let kind = if wayland {
|
||||
WindowKind::AnchoredPopup(popup::PopupOptions {
|
||||
parent: main.into(),
|
||||
anchor_rect: Bounds::new(
|
||||
point(pane.origin.x + px(at), pane.origin.y),
|
||||
size(px(1.), px(1.)),
|
||||
),
|
||||
anchor: popup::PopupAnchor::TopLeft,
|
||||
gravity: popup::PopupGravity::TopRight,
|
||||
constraint_adjustment: popup::PopupConstraintAdjustment::empty(),
|
||||
offset: point(px(0.), px(0.)),
|
||||
grab: false,
|
||||
})
|
||||
} else {
|
||||
WindowKind::PopUp
|
||||
};
|
||||
let origin = bounds.origin + pane.origin + point(px(at), -room.height);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(Bounds::new(origin, room))),
|
||||
titlebar: None,
|
||||
focus: false,
|
||||
show: true,
|
||||
kind,
|
||||
is_movable: false,
|
||||
is_resizable: false,
|
||||
is_minimizable: false,
|
||||
window_background: WindowBackgroundAppearance::Transparent,
|
||||
..Default::default()
|
||||
};
|
||||
let workspace = main.entity(cx)?;
|
||||
let feeling = |workspace: &Workspace, cx: &App| {
|
||||
mood(
|
||||
workspace
|
||||
.sessions()
|
||||
.iter()
|
||||
.filter_map(|session| session.looks(cx)),
|
||||
)
|
||||
};
|
||||
let start = feeling(workspace.read(cx), cx);
|
||||
let pet = cx.open_window(options, |_, cx| {
|
||||
cx.new(|cx| {
|
||||
let feel = cx.observe(&workspace, move |pet: &mut Pet, workspace, cx| {
|
||||
let mood = feeling(workspace.read(cx), cx);
|
||||
if mood != pet.mood {
|
||||
pet.feel(mood, Instant::now());
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
Pet {
|
||||
rig: Rc::new(Rig::new()),
|
||||
mood: start,
|
||||
since: Instant::now(),
|
||||
shown: Instant::now(),
|
||||
petted: None,
|
||||
act: Act::Calm,
|
||||
last: None,
|
||||
eased: None,
|
||||
left: at,
|
||||
glue: None,
|
||||
_watch: vec![feel],
|
||||
}
|
||||
})
|
||||
})?;
|
||||
if !wayland {
|
||||
glue(main, pet, cx)?;
|
||||
}
|
||||
let way = if wayland {
|
||||
"a Wayland popup"
|
||||
} else {
|
||||
"a window kept there"
|
||||
};
|
||||
log::info!("pet: Ely peeks over the main window, {way}, feeling {start:?}");
|
||||
Ok(pet)
|
||||
}
|
||||
|
||||
/// Keeps her window on `main`'s top edge as it moves, comes forward and hides.
|
||||
fn glue(main: WindowHandle<Workspace>, pet: WindowHandle<Pet>, cx: &mut App) -> anyhow::Result<()> {
|
||||
main.update(cx, |_, main, cx| {
|
||||
pet.update(cx, |pet, window, cx| {
|
||||
let glue = platform::Glue::new(window, main)?;
|
||||
glue.place(main)?;
|
||||
pet.glue = Some(glue);
|
||||
pet._watch
|
||||
.push(cx.observe_window_bounds(main, |pet, main, _| pet.place(main)));
|
||||
pet._watch
|
||||
.push(cx.observe_window_activation(main, |pet, main, _| pet.place(main)));
|
||||
#[cfg(target_os = "linux")]
|
||||
pet._watch.push(
|
||||
cx.observe_window_visibility(main, |pet, visibility, main, _| {
|
||||
pet.shown(visibility.is_visible(), main)
|
||||
}),
|
||||
);
|
||||
anyhow::Ok(())
|
||||
})?
|
||||
})?
|
||||
}
|
||||
|
||||
/// The main window's pane, in its own points.
|
||||
fn pane(window: &Window) -> Bounds<Pixels> {
|
||||
crate::blur::pane(
|
||||
window.viewport_size(),
|
||||
window.client_inset(),
|
||||
window.window_decorations(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether `window` is a Wayland surface, where the compositor places windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wayland(window: &Window) -> bool {
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
HasWindowHandle::window_handle(window)
|
||||
.is_ok_and(|handle| matches!(handle.as_raw(), RawWindowHandle::Wayland(_)))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn wayland(_: &Window) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl Pet {
|
||||
/// Takes up `mood` at `now`, its act beginning there, eased from where she is.
|
||||
fn feel(&mut self, mood: Mood, now: Instant) {
|
||||
log::debug!("pet: Ely feels {mood:?}, was {:?}", self.mood);
|
||||
(self.mood, self.since) = (mood, now);
|
||||
self.ease(now);
|
||||
}
|
||||
|
||||
/// A press at `now`, which winks her, eased from where she is.
|
||||
fn press(&mut self, now: Instant) {
|
||||
log::debug!("pet: Ely is pressed");
|
||||
self.petted = Some(now);
|
||||
self.ease(now);
|
||||
}
|
||||
|
||||
/// Eases her from her last pose, from `now`, as a move restarts.
|
||||
fn ease(&mut self, now: Instant) {
|
||||
self.eased = self.last.clone().map(|last| (last, now));
|
||||
}
|
||||
|
||||
/// Her pose now, eased from where she was while a change of act or mood, or a press, is fresh.
|
||||
fn pose(&mut self, now: Instant) -> Pose {
|
||||
let since = |at: Instant| now.saturating_duration_since(at).as_secs_f64();
|
||||
let mut pose = pose(
|
||||
self.mood,
|
||||
since(self.since),
|
||||
since(self.shown),
|
||||
self.petted.map(since),
|
||||
);
|
||||
if pose.act != self.act {
|
||||
log::debug!("pet: Ely goes from {:?} to {:?}", self.act, pose.act);
|
||||
self.act = pose.act;
|
||||
self.ease(now);
|
||||
}
|
||||
if let Some((from, at)) = &self.eased {
|
||||
let into = since(*at) as f32;
|
||||
if into < EASE {
|
||||
pose = blend(from, pose, into);
|
||||
} else {
|
||||
self.eased = None;
|
||||
}
|
||||
}
|
||||
self.last = Some(pose.clone());
|
||||
pose
|
||||
}
|
||||
|
||||
fn place(&self, main: &Window) {
|
||||
if let Some(glue) = &self.glue
|
||||
&& let Err(error) = glue.place(main)
|
||||
{
|
||||
log::error!("{error:#}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn shown(&self, visible: bool, main: &Window) {
|
||||
if let Some(glue) = &self.glue {
|
||||
match glue.shown(visible) {
|
||||
Ok(()) => log::debug!("pet: Ely shows {visible} with the main window"),
|
||||
Err(error) => log::error!("{error:#}"),
|
||||
}
|
||||
}
|
||||
if visible {
|
||||
self.place(main);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Pet {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pose = self.pose(Instant::now());
|
||||
// gpui gives a hidden window no frames, so she rests while unseen.
|
||||
window.request_animation_frame();
|
||||
let rig = self.rig.clone();
|
||||
let drawn = canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, cx| {
|
||||
let head = point(bounds.origin.x, bounds.origin.y + px(ROOM));
|
||||
rig.paint(head, &pose, window, cx);
|
||||
},
|
||||
);
|
||||
div().size_full().child(drawn.size_full()).on_mouse_down(
|
||||
MouseButton::Left,
|
||||
cx.listener(|pet, _, _, cx| {
|
||||
pet.press(Instant::now());
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{look::Mark, *};
|
||||
|
||||
/// Her, `mood` since `since`, come long before.
|
||||
fn ely(mood: Mood, since: Instant) -> Pet {
|
||||
Pet {
|
||||
rig: Rc::new(Rig::new()),
|
||||
mood,
|
||||
since,
|
||||
shown: since - Duration::from_secs(60),
|
||||
petted: None,
|
||||
act: Act::Calm,
|
||||
last: None,
|
||||
eased: None,
|
||||
left: LEFT,
|
||||
glue: None,
|
||||
_watch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_mood_or_a_press_eases_her_from_where_she_is_though_the_act_stays() {
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
// A wait hops her, a press winks, and a program starts working inside the wink.
|
||||
let mut pet = ely(Mood::Waiting, start);
|
||||
pet.press(at(2250));
|
||||
let before = pet.pose(at(2750));
|
||||
pet.feel(Mood::Working, at(2750));
|
||||
let after = pet.pose(at(2751));
|
||||
assert_eq!((before.act, after.act), (Act::Wink, Act::Wink));
|
||||
assert!(
|
||||
(after.lift - before.lift).abs() < 1.0,
|
||||
"no drop: {} then {}",
|
||||
before.lift,
|
||||
after.lift
|
||||
);
|
||||
let marks: Vec<Mark> = after.marks.iter().map(|(mark, _)| *mark).collect();
|
||||
assert!(
|
||||
marks.contains(&Mark::Bang),
|
||||
"the wait's mark fades: {marks:?}"
|
||||
);
|
||||
// An old finish clears mid-glance: calm before and after, her eyes turn back at her pace.
|
||||
let mut pet = ely(Mood::Done, start);
|
||||
let before = pet.pose(at(13_600));
|
||||
pet.feel(Mood::Calm, at(13_600));
|
||||
let after = pet.pose(at(13_601));
|
||||
assert_eq!((before.act, after.act), (Act::Calm, Act::Calm));
|
||||
assert!(
|
||||
before.look.0 < -5.0 && (after.look.0 - before.look.0).abs() < 0.5,
|
||||
"no snap: {:?} then {:?}",
|
||||
before.look,
|
||||
after.look
|
||||
);
|
||||
let later = pet.pose(at(14_000));
|
||||
assert!(
|
||||
pet.eased.is_none() && later.look.0.abs() < 0.5,
|
||||
"eased in by then"
|
||||
);
|
||||
// A second press mid-hop restarts the wink without a drop.
|
||||
let mut pet = ely(Mood::Calm, start);
|
||||
pet.press(at(1000));
|
||||
let before = pet.pose(at(1300));
|
||||
pet.press(at(1300));
|
||||
let after = pet.pose(at(1301));
|
||||
assert!(
|
||||
before.lift > 10.0 && (after.lift - before.lift).abs() < 1.0,
|
||||
"no drop: {} then {}",
|
||||
before.lift,
|
||||
after.lift
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_holds_the_edge_of_any_pane() {
|
||||
assert_eq!(left(px(1200.)), LEFT);
|
||||
assert_eq!(left(px(200.)), 200.0 - WIDTH);
|
||||
assert_eq!(
|
||||
left(px(100.)),
|
||||
0.0,
|
||||
"narrower than she is, she stands at its left"
|
||||
);
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
//! What Ely feels from the sessions' marks, and her pose at each moment of it, moving as the films move her.
|
||||
|
||||
mod curves;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use self::curves::{
|
||||
SHUT, between, blink, early, floating, fold, hops, opening, overshoot, popping, shaking,
|
||||
smooth, tap, twinkling, wiggle,
|
||||
};
|
||||
use crate::sessions::orb::Look;
|
||||
|
||||
/// What the sessions ask of her, least pressing first.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Mood {
|
||||
Calm,
|
||||
Done,
|
||||
Working,
|
||||
Error,
|
||||
Waiting,
|
||||
}
|
||||
|
||||
/// The most pressing of the marks' looks; calm with none.
|
||||
pub fn mood(looks: impl IntoIterator<Item = Look>) -> Mood {
|
||||
looks
|
||||
.into_iter()
|
||||
.map(|look| match look {
|
||||
Look::Waiting => Mood::Waiting,
|
||||
Look::Error => Mood::Error,
|
||||
Look::Working(_) => Mood::Working,
|
||||
Look::Done => Mood::Done,
|
||||
Look::Idle => Mood::Calm,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(Mood::Calm)
|
||||
}
|
||||
|
||||
/// Seconds she cheers a program that finished, and calm lasts before she dozes.
|
||||
const CHEER: f64 = 4.0;
|
||||
const DOZE: f64 = 300.0;
|
||||
/// Seconds a press keeps her wink.
|
||||
const PETTED: f32 = 1.5;
|
||||
/// Seconds she takes to rise from behind the edge, from as deep as she is tall.
|
||||
const RISE: f32 = 0.6;
|
||||
const DEPTH: f32 = 280.0;
|
||||
/// Seconds she takes to ease from one act into the next.
|
||||
pub const EASE: f32 = 0.3;
|
||||
|
||||
/// What she is doing; a change eases her from her last pose.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Act {
|
||||
Calm,
|
||||
Doze,
|
||||
Cheer,
|
||||
Work,
|
||||
Wait,
|
||||
Fret,
|
||||
Wink,
|
||||
}
|
||||
|
||||
/// Her eyes: open, smiling arcs, sleeping lines, or a wink of the left.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Eyes {
|
||||
Open,
|
||||
Arcs,
|
||||
Lines,
|
||||
Wink,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mouth {
|
||||
Smile,
|
||||
Grin,
|
||||
Gasp,
|
||||
Flat,
|
||||
Frown,
|
||||
}
|
||||
|
||||
/// A mark beside her head.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
Bang,
|
||||
Z1,
|
||||
Z2,
|
||||
Heart,
|
||||
SparkleLeft,
|
||||
SparkleRight,
|
||||
Drop,
|
||||
}
|
||||
|
||||
/// How a mark moves from its place: by `at` units, grown by `scale`, turned `turn` degrees, at `alpha`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Motion {
|
||||
pub at: (f32, f32),
|
||||
pub scale: f32,
|
||||
pub turn: f32,
|
||||
pub alpha: f32,
|
||||
}
|
||||
|
||||
/// Her pose at one moment, in the icon's units and degrees: lift and bob go up, marks and hands move as the icon's y does, down.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Pose {
|
||||
pub act: Act,
|
||||
/// All of her up, and aside.
|
||||
pub lift: f32,
|
||||
pub shake: f32,
|
||||
/// Her head up with her breath, and turned.
|
||||
pub bob: f32,
|
||||
pub tilt: f32,
|
||||
pub sway: f32,
|
||||
pub ahoge: f32,
|
||||
pub look: (f32, f32),
|
||||
/// Each eye's height, left then right, and both eyes' size.
|
||||
pub open: [f32; 2],
|
||||
pub wide: f32,
|
||||
pub eyes: Eyes,
|
||||
pub mouth: Mouth,
|
||||
/// The mouth's size, which pops as it changes.
|
||||
pub pop: f32,
|
||||
/// How much her worried brows show.
|
||||
pub brows: f32,
|
||||
pub flush: f32,
|
||||
/// Each hand pressed down, left then right.
|
||||
pub hands: [f32; 2],
|
||||
pub marks: Vec<(Mark, Motion)>,
|
||||
}
|
||||
|
||||
/// Her pose `held` seconds into `mood`, `shown` seconds after she came, and `petted` seconds after a press.
|
||||
pub fn pose(mood: Mood, held: f64, shown: f64, petted: Option<f64>) -> Pose {
|
||||
let mut pose = match mood {
|
||||
Mood::Waiting => waiting(held),
|
||||
Mood::Error => fretting(held),
|
||||
Mood::Working => working(held),
|
||||
Mood::Done if held < CHEER => cheering(held),
|
||||
Mood::Calm | Mood::Done if held >= DOZE => dozing(held - DOZE),
|
||||
Mood::Calm => calm(held),
|
||||
Mood::Done => calm(held - CHEER),
|
||||
};
|
||||
if shown < RISE as f64 {
|
||||
pose.lift -= DEPTH * (1.0 - shown as f32 / RISE).powi(3);
|
||||
}
|
||||
match petted.map(early) {
|
||||
Some(since) if since < PETTED => winking(&mut pose, since),
|
||||
Some(since) => pose.open[0] = pose.open[0].min(opening(since - PETTED)),
|
||||
None => {}
|
||||
}
|
||||
pose
|
||||
}
|
||||
|
||||
/// `to`, `since` seconds after a change of act or mood, or a press, eased from `from`, her pose then: her place, her moves and her brows blend, the marks `to` lacks fade, and the shapes are `to`'s.
|
||||
pub fn blend(from: &Pose, to: Pose, since: f32) -> Pose {
|
||||
let k = smooth((since / EASE).clamp(0.0, 1.0));
|
||||
let mix = |a: f32, b: f32| a + (b - a) * k;
|
||||
let fading: Vec<(Mark, Motion)> = (from.marks.iter())
|
||||
.filter(|(mark, _)| to.marks.iter().all(|(kept, _)| kept != mark))
|
||||
.map(|&(mark, motion)| {
|
||||
let alpha = motion.alpha * (1.0 - k);
|
||||
(mark, Motion { alpha, ..motion })
|
||||
})
|
||||
.collect();
|
||||
let mut pose = Pose {
|
||||
lift: mix(from.lift, to.lift),
|
||||
shake: mix(from.shake, to.shake),
|
||||
bob: mix(from.bob, to.bob),
|
||||
tilt: mix(from.tilt, to.tilt),
|
||||
sway: mix(from.sway, to.sway),
|
||||
ahoge: mix(from.ahoge, to.ahoge),
|
||||
look: (mix(from.look.0, to.look.0), mix(from.look.1, to.look.1)),
|
||||
wide: mix(from.wide, to.wide),
|
||||
brows: mix(from.brows, to.brows),
|
||||
flush: mix(from.flush, to.flush),
|
||||
hands: [
|
||||
mix(from.hands[0], to.hands[0]),
|
||||
mix(from.hands[1], to.hands[1]),
|
||||
],
|
||||
..to
|
||||
};
|
||||
pose.marks.extend(fading);
|
||||
pose
|
||||
}
|
||||
|
||||
/// Life under every act, `t` seconds into it: a bob of `amp` units every `period` seconds, swaying locks, a breathing ahoge, and blinks, her eyes opening and her mouth popping as the act begins.
|
||||
fn idle(act: Act, t: f64, period: f32, amp: f32) -> Pose {
|
||||
let phase = 2.0 * PI * fold(t, period) / period;
|
||||
Pose {
|
||||
act,
|
||||
lift: 0.0,
|
||||
shake: 0.0,
|
||||
bob: phase.sin() * amp,
|
||||
tilt: 0.0,
|
||||
sway: (PI * fold(t, 2.0 * period) / period + 1.1).sin() * 2.2,
|
||||
ahoge: (phase + 0.6).sin() * 4.0,
|
||||
look: (0.0, 0.0),
|
||||
open: [blink(t).min(opening(early(t))); 2],
|
||||
wide: 1.0,
|
||||
eyes: Eyes::Open,
|
||||
mouth: Mouth::Smile,
|
||||
pop: popping(early(t)),
|
||||
brows: 0.0,
|
||||
flush: 0.0,
|
||||
hands: [0.0; 2],
|
||||
marks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Calm: she glances left, then right, her head after her eyes, and her ahoge wiggles.
|
||||
fn calm(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Calm, t, 2.6, 4.0);
|
||||
let at = fold(t, 20.0);
|
||||
let look = 6.0 * (between(at, 16.0, 17.2) - between(at, 9.0, 10.2));
|
||||
pose.look = (look, 0.0);
|
||||
pose.tilt = look * 0.5;
|
||||
pose.ahoge += wiggle(at - 12.4);
|
||||
pose
|
||||
}
|
||||
|
||||
/// Dozing: her eyes fall shut, her head and ahoge droop, and zs float up.
|
||||
fn dozing(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Doze, t, 4.2, 3.0);
|
||||
let settle = smooth((early(t) / 0.8).min(1.0));
|
||||
if t < 0.6 {
|
||||
pose.open = [1.0 - (1.0 - SHUT) * smooth(early(t) / 0.6); 2];
|
||||
} else {
|
||||
pose.eyes = Eyes::Lines;
|
||||
pose.open = [1.0; 2];
|
||||
}
|
||||
pose.mouth = Mouth::Flat;
|
||||
pose.tilt = settle * 5.0 + (2.0 * PI * fold(t, 8.4) / 8.4).sin() * 1.5;
|
||||
pose.ahoge += settle * 14.0;
|
||||
pose.marks = vec![(Mark::Z1, floating(t, 0.0)), (Mark::Z2, floating(t, 0.5))];
|
||||
pose
|
||||
}
|
||||
|
||||
/// Cheering: smiling eyes and mouth, a hop, and twinkling sparkles.
|
||||
fn cheering(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Cheer, t, 2.2, 4.0);
|
||||
let t = early(t);
|
||||
pose.eyes = Eyes::Arcs;
|
||||
pose.mouth = Mouth::Grin;
|
||||
pose.flush = 0.6;
|
||||
pose.lift = hops(t, 1.0);
|
||||
pose.ahoge += wiggle(t - 0.1);
|
||||
pose.marks = vec![
|
||||
(Mark::SparkleLeft, twinkling(t, 0.0)),
|
||||
(Mark::SparkleRight, twinkling(t, 0.45)),
|
||||
];
|
||||
pose
|
||||
}
|
||||
|
||||
/// Working: her eyes run along the terminal's lines, and her fingers tap by turns.
|
||||
fn working(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Work, t, 2.0, 2.5);
|
||||
let at = fold(t, 2.25);
|
||||
let scan = if at < 2.0 {
|
||||
-5.0 + 10.0 * smooth(at / 2.0)
|
||||
} else {
|
||||
5.0 - 10.0 * smooth((at - 2.0) / 0.25)
|
||||
};
|
||||
pose.look = (scan, 5.0);
|
||||
pose.mouth = Mouth::Flat;
|
||||
pose.tilt = -2.0;
|
||||
let beat = fold(t, 3.0);
|
||||
if beat < 1.8 {
|
||||
let n = (beat / 0.3) as usize;
|
||||
pose.hands[n % 2] = tap(beat - n as f32 * 0.3);
|
||||
}
|
||||
pose
|
||||
}
|
||||
|
||||
/// Waiting on you: wide eyes, a gasp, a mark that rocks, and two hops every 2.6 s.
|
||||
fn waiting(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Wait, t, 1.6, 3.0);
|
||||
let beat = 2.0 * PI * fold(t, 0.8) / 0.8;
|
||||
pose.wide = 1.0 + 0.12 * overshoot((early(t) / 0.12).min(1.0)) + 0.02 * beat.sin();
|
||||
pose.mouth = Mouth::Gasp;
|
||||
pose.look = (0.0, -2.0);
|
||||
let at = fold(t, 2.6);
|
||||
pose.lift = hops(at, 2.0);
|
||||
pose.ahoge += wiggle(at);
|
||||
let bang = Motion {
|
||||
at: (0.0, 0.0),
|
||||
scale: popping(early(t)),
|
||||
turn: beat.sin() * 8.0,
|
||||
alpha: 1.0,
|
||||
};
|
||||
pose.marks = vec![(Mark::Bang, bang)];
|
||||
pose
|
||||
}
|
||||
|
||||
/// An error: brows up, a frown, a shake, a smaller one every 4.5 s, and a drop that runs down.
|
||||
fn fretting(t: f64) -> Pose {
|
||||
let mut pose = idle(Act::Fret, t, 2.4, 1.5);
|
||||
pose.brows = 1.0;
|
||||
pose.mouth = Mouth::Frown;
|
||||
pose.look = (0.0, 3.0);
|
||||
pose.tilt = -3.0;
|
||||
pose.shake = shaking(early(t), 10.0) + shaking(fold(t, 4.5) - 3.9, 4.0);
|
||||
let f = fold(t, 2.0) / 2.0;
|
||||
let drop = Motion {
|
||||
at: (0.0, 18.0 * smooth(f)),
|
||||
scale: 1.0,
|
||||
turn: 0.0,
|
||||
alpha: (f * PI).sin(),
|
||||
};
|
||||
pose.marks = vec![(Mark::Drop, drop)];
|
||||
pose
|
||||
}
|
||||
|
||||
/// A press, `since` seconds ago: a wink, a grin, a blush, a small hop, a wiggle and a heart that rises; an eye she slept with opens.
|
||||
fn winking(pose: &mut Pose, since: f32) {
|
||||
let woke = pose.eyes != Eyes::Open;
|
||||
pose.act = Act::Wink;
|
||||
pose.eyes = Eyes::Wink;
|
||||
pose.open = [
|
||||
opening(since),
|
||||
if woke { opening(since) } else { pose.open[1] },
|
||||
];
|
||||
pose.mouth = Mouth::Grin;
|
||||
pose.pop = popping(since);
|
||||
pose.flush = 1.0;
|
||||
pose.lift += hops(since, 1.0) * 0.6;
|
||||
pose.ahoge += wiggle(since);
|
||||
let heart = Motion {
|
||||
at: (0.0, -26.0 * since / PETTED),
|
||||
scale: popping(since),
|
||||
turn: 0.0,
|
||||
alpha: ((PETTED - since) / 0.4).min(1.0),
|
||||
};
|
||||
pose.marks.push((Mark::Heart, heart));
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The timings and eases Ely's moves take, after the films' tweens.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
|
||||
use super::Motion;
|
||||
|
||||
/// How far her eyes close in a blink.
|
||||
pub(super) const SHUT: f32 = 0.08;
|
||||
/// A hop's height: 12 points.
|
||||
const HOP: f32 = 31.0;
|
||||
/// Seconds between blinks, a round she repeats; the short gap blinks twice.
|
||||
const BLINKS: [f32; 6] = [3.4, 4.6, 0.32, 5.2, 2.8, 4.1];
|
||||
|
||||
/// How open an eye is `t` seconds into the blink round: shut in 70 ms, open again in 120 ms.
|
||||
pub(super) fn blink(t: f64) -> f32 {
|
||||
let round: f32 = BLINKS.iter().sum();
|
||||
let at = fold(t, round);
|
||||
// A round ends in a blink; the first has none before it.
|
||||
let mut last = if t < round as f64 {
|
||||
f32::NEG_INFINITY
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut time = 0.0;
|
||||
for gap in BLINKS {
|
||||
time += gap;
|
||||
if time <= at {
|
||||
last = time;
|
||||
}
|
||||
}
|
||||
match at - last {
|
||||
s if s < 0.07 => 1.0 - (1.0 - SHUT) * (s / 0.07).powi(2),
|
||||
s if s < 0.1 => SHUT,
|
||||
s if s < 0.22 => SHUT + (1.0 - SHUT) * (1.0 - (1.0 - (s - 0.1) / 0.12).powi(2)),
|
||||
_ => 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape that opens from a line in 150 ms, `t` seconds after it came.
|
||||
pub(super) fn opening(t: f32) -> f32 {
|
||||
SHUT + (1.0 - SHUT) * smooth((t / 0.15).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// A mouth or mark that pops in over 180 ms, `t` seconds after it came.
|
||||
pub(super) fn popping(t: f32) -> f32 {
|
||||
0.6 + 0.4 * overshoot((t / 0.18).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// `count` hops in 0.6 s, `since` they began.
|
||||
pub(super) fn hops(since: f32, count: f32) -> f32 {
|
||||
if !(0.0..0.6).contains(&since) {
|
||||
return 0.0;
|
||||
}
|
||||
(since / 0.6 * count * PI).sin().abs() * HOP
|
||||
}
|
||||
|
||||
/// A shake of `amp` units that dies away in 0.6 s, `since` it began.
|
||||
pub(super) fn shaking(since: f32, amp: f32) -> f32 {
|
||||
if !(0.0..0.6).contains(&since) {
|
||||
return 0.0;
|
||||
}
|
||||
let u = since / 0.6;
|
||||
(u * 6.0 * PI).sin() * amp * (1.0 - u)
|
||||
}
|
||||
|
||||
/// The ahoge's wiggle, `since` it began: -16°, 11°, -6°, then still, in 0.7 s.
|
||||
pub(super) fn wiggle(since: f32) -> f32 {
|
||||
const KEYS: [(f32, f32); 5] = [
|
||||
(0.0, 0.0),
|
||||
(0.12, -16.0),
|
||||
(0.28, 11.0),
|
||||
(0.46, -6.0),
|
||||
(0.7, 0.0),
|
||||
];
|
||||
let Some(i) = (KEYS.windows(2)).position(|pair| (pair[0].0..pair[1].0).contains(&since)) else {
|
||||
return 0.0;
|
||||
};
|
||||
let ((t0, v0), (t1, v1)) = (KEYS[i], KEYS[i + 1]);
|
||||
v0 + (v1 - v0) * smooth((since - t0) / (t1 - t0))
|
||||
}
|
||||
|
||||
/// A finger's tap, `since` it began: down 4 units in 60 ms, up in 160 ms.
|
||||
pub(super) fn tap(since: f32) -> f32 {
|
||||
if since < 0.06 {
|
||||
4.0 * (since / 0.06).powi(2)
|
||||
} else {
|
||||
4.0 * (1.0 - smooth(((since - 0.06) / 0.16).min(1.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// A z that floats up from its place and fades every 2.4 s, `phase` of a round late.
|
||||
pub(super) fn floating(t: f64, phase: f32) -> Motion {
|
||||
let f = (fold(t, 2.4) / 2.4 + phase) % 1.0;
|
||||
Motion {
|
||||
at: (f * 6.0, -f * 22.0),
|
||||
scale: 0.8 + 0.4 * f,
|
||||
turn: 0.0,
|
||||
alpha: (f * PI).sin() * (early(t) / 0.8).min(1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// A sparkle that grows in and twinkles, `phase` of a 0.9 s round late.
|
||||
pub(super) fn twinkling(t: f32, phase: f32) -> Motion {
|
||||
let pulse = (2.0 * PI * (t / 0.9 + phase)).sin();
|
||||
Motion {
|
||||
at: (0.0, 0.0),
|
||||
scale: overshoot((t / 0.25).min(1.0)) * (0.85 + 0.25 * pulse),
|
||||
turn: pulse * 12.0,
|
||||
alpha: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `t` folded into a round of `period` seconds, which keeps its steps fine after weeks.
|
||||
pub(super) fn fold(t: f64, period: f32) -> f32 {
|
||||
(t % period as f64) as f32
|
||||
}
|
||||
|
||||
/// `t` while it is short; past a minute, what follows a start has long ended.
|
||||
pub(super) fn early(t: f64) -> f32 {
|
||||
t.min(60.0) as f32
|
||||
}
|
||||
|
||||
/// 1 inside `from..to`, 0 outside, eased in and out over 0.35 s.
|
||||
pub(super) fn between(at: f32, from: f32, to: f32) -> f32 {
|
||||
smooth(
|
||||
((at - from) / 0.35)
|
||||
.clamp(0.0, 1.0)
|
||||
.min(((to - at) / 0.35).clamp(0.0, 1.0)),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn smooth(x: f32) -> f32 {
|
||||
x * x * (3.0 - 2.0 * x)
|
||||
}
|
||||
|
||||
/// An ease that runs past its end and settles back, as GSAP's back.out.
|
||||
pub(super) fn overshoot(x: f32) -> f32 {
|
||||
const C: f32 = 1.70158;
|
||||
1.0 + (C + 1.0) * (x - 1.0).powi(3) + C * (x - 1.0).powi(2)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_most_pressing_mark_sets_her_mood() {
|
||||
assert_eq!(mood([]), Mood::Calm);
|
||||
assert_eq!(mood([Look::Idle, Look::Done]), Mood::Done);
|
||||
assert_eq!(
|
||||
mood([Look::Done, Look::Working(Some(40)), Look::Idle]),
|
||||
Mood::Working
|
||||
);
|
||||
assert_eq!(mood([Look::Working(None), Look::Error]), Mood::Error);
|
||||
assert_eq!(
|
||||
mood([Look::Error, Look::Waiting, Look::Done]),
|
||||
Mood::Waiting
|
||||
);
|
||||
}
|
||||
|
||||
/// Her pose `held` seconds into `mood`, long after she came, unpressed.
|
||||
fn at(mood: Mood, held: f64) -> Pose {
|
||||
pose(mood, held, 10.0, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_moves_in_every_mood_even_after_weeks() {
|
||||
let weeks = 3.0 * 7.0 * 86_400.0;
|
||||
for (mood, held) in [
|
||||
(Mood::Calm, 30.0),
|
||||
(Mood::Calm, 400.0),
|
||||
(Mood::Done, 50.0),
|
||||
(Mood::Working, 30.0),
|
||||
(Mood::Waiting, 30.0),
|
||||
(Mood::Error, 30.0),
|
||||
(Mood::Working, weeks),
|
||||
(Mood::Calm, weeks),
|
||||
] {
|
||||
let (now, soon) = (at(mood, held), at(mood, held + 0.3));
|
||||
assert_ne!(now.bob, soon.bob, "{mood:?} at {held}: her breath");
|
||||
assert_ne!(now.ahoge, soon.ahoge, "{mood:?} at {held}: her ahoge");
|
||||
assert_ne!(now.sway, soon.sway, "{mood:?} at {held}: her locks");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_blinks_on_her_own_twice_at_the_short_gap() {
|
||||
let open = |held: f64| at(Mood::Calm, held).open;
|
||||
assert_eq!(open(2.0), [1.0, 1.0], "open between blinks");
|
||||
assert!(open(3.48)[0] < 0.1, "shut in the blink at 3.4 s");
|
||||
assert_eq!(open(3.7), [1.0, 1.0], "open again");
|
||||
assert!(
|
||||
open(8.08)[0] < 0.1 && open(8.4)[0] < 0.1,
|
||||
"twice at the short gap"
|
||||
);
|
||||
assert!(
|
||||
open(3.43)[0] > open(3.46)[0],
|
||||
"she shuts them, not snaps them"
|
||||
);
|
||||
assert!(
|
||||
open(0.0)[0] < 0.1 && open(0.2) == [1.0, 1.0],
|
||||
"her eyes open as an act begins"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calm_glances_left_then_right_with_her_head() {
|
||||
let calm = |held: f64| at(Mood::Calm, held);
|
||||
assert_eq!(calm(5.0).look, (0.0, 0.0));
|
||||
assert_eq!(calm(9.6).look, (-6.0, 0.0));
|
||||
assert_eq!(calm(16.6).look, (6.0, 0.0));
|
||||
assert!(
|
||||
calm(16.6).tilt > 0.0 && calm(9.6).tilt < 0.0,
|
||||
"her head follows"
|
||||
);
|
||||
assert!(calm(9.1).look.0 > -6.0, "her eyes ease over");
|
||||
let breath = idle(Act::Calm, 12.6, 2.6, 4.0).ahoge;
|
||||
assert!(
|
||||
(calm(12.6).ahoge - breath).abs() > 1.0,
|
||||
"her ahoge wiggles after her glances"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calm_dozes_after_five_minutes_with_zs() {
|
||||
let dozing = at(Mood::Calm, 301.0);
|
||||
assert_eq!((dozing.act, dozing.eyes), (Act::Doze, Eyes::Lines));
|
||||
let marks: Vec<Mark> = dozing.marks.iter().map(|(mark, _)| *mark).collect();
|
||||
assert_eq!(marks, [Mark::Z1, Mark::Z2]);
|
||||
assert!(
|
||||
at(Mood::Calm, 300.3).open[0] < 1.0,
|
||||
"her eyes fall shut first"
|
||||
);
|
||||
let z = |held: f64| at(Mood::Calm, held).marks[0].1;
|
||||
assert!(z(305.6).at.1 < z(305.0).at.1, "the zs float up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finish_cheers_her_with_a_hop_then_she_calms() {
|
||||
let cheer = at(Mood::Done, 0.3);
|
||||
assert_eq!(
|
||||
(cheer.act, cheer.eyes, cheer.mouth),
|
||||
(Act::Cheer, Eyes::Arcs, Mouth::Grin)
|
||||
);
|
||||
assert!(cheer.lift > 10.0, "a hop");
|
||||
assert_eq!(cheer.marks.len(), 2, "two sparkles");
|
||||
let calm = at(Mood::Done, 5.0);
|
||||
assert_eq!(
|
||||
(calm.act, calm.eyes, calm.mouth),
|
||||
(Act::Calm, Eyes::Open, Mouth::Smile)
|
||||
);
|
||||
assert!(
|
||||
at(Mood::Done, 4.0).open[0] < 0.1,
|
||||
"her eyes open again as she calms"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_runs_her_eyes_along_the_lines_and_taps_her_fingers() {
|
||||
let work = |held: f64| at(Mood::Working, held);
|
||||
assert_eq!(work(1.0).act, Act::Work);
|
||||
assert_eq!(work(1.0).look.1, 5.0, "her eyes on the terminal");
|
||||
assert!(work(0.4).look.0 < work(1.6).look.0, "along a line");
|
||||
assert!(
|
||||
work(0.03).hands[0] > 0.0 && work(0.33).hands[1] > 0.0,
|
||||
"by turns"
|
||||
);
|
||||
assert_eq!(work(2.5).hands, [0.0, 0.0], "then a rest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wait_widens_her_eyes_and_hops_her_twice_every_round() {
|
||||
let wait = |held: f64| at(Mood::Waiting, held);
|
||||
assert!(wait(1.0).wide > 1.1);
|
||||
assert_eq!((wait(1.0).act, wait(1.0).mouth), (Act::Wait, Mouth::Gasp));
|
||||
assert!(wait(0.15).lift > 20.0 && wait(0.45).lift > 20.0, "two hops");
|
||||
assert!(wait(2.75).lift > 20.0, "again next round");
|
||||
assert_eq!(wait(1.5).lift, 0.0);
|
||||
assert_eq!(wait(1.0).marks[0].0, Mark::Bang);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_frets_her_with_a_shake_and_a_drop() {
|
||||
let fret = |held: f64| at(Mood::Error, held);
|
||||
assert_eq!(fret(1.0).brows, 1.0);
|
||||
assert_eq!((fret(1.0).act, fret(1.0).mouth), (Act::Fret, Mouth::Frown));
|
||||
assert!(fret(0.15).shake.abs() > 1.0, "a shake");
|
||||
assert_eq!(fret(1.0).shake, 0.0);
|
||||
assert!(fret(4.05).shake.abs() > 1.0, "a smaller one later");
|
||||
assert!(
|
||||
fret(1.5).marks[0].1.at.1 > fret(0.5).marks[0].1.at.1,
|
||||
"the drop runs down"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_press_winks_with_a_heart_that_rises() {
|
||||
let pressed = |since: f64| pose(Mood::Calm, 30.0, 30.0, Some(since));
|
||||
assert_eq!(
|
||||
(pressed(0.5).act, pressed(0.5).eyes),
|
||||
(Act::Wink, Eyes::Wink)
|
||||
);
|
||||
assert_eq!(pressed(0.5).marks[0].0, Mark::Heart);
|
||||
assert!(pressed(1.0).marks[0].1.at.1 < pressed(0.5).marks[0].1.at.1);
|
||||
assert_eq!(
|
||||
(pressed(1.6).act, pressed(1.6).eyes),
|
||||
(Act::Calm, Eyes::Open),
|
||||
"the wink lasts 1.5 s"
|
||||
);
|
||||
assert!(pressed(1.55).open[0] < 0.5, "her eye opens after it");
|
||||
let asleep = pose(Mood::Calm, 400.0, 400.0, Some(0.5));
|
||||
assert!(asleep.open[1] > 0.9, "a press wakes her other eye");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_rises_from_behind_the_edge_when_she_comes() {
|
||||
assert!(pose(Mood::Calm, 0.0, 0.0, None).lift <= -DEPTH);
|
||||
assert!(pose(Mood::Calm, 0.3, 0.3, None).lift < 0.0);
|
||||
assert_eq!(pose(Mood::Calm, 5.0, 0.6, None).lift, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_act_eases_her_from_her_last_pose() {
|
||||
let working = at(Mood::Working, 1.0);
|
||||
let early = blend(&working, at(Mood::Calm, 0.1), 0.1);
|
||||
assert!(
|
||||
early.tilt < 0.0 && early.tilt > working.tilt,
|
||||
"her head turns back, not at once: {} from {}",
|
||||
early.tilt,
|
||||
working.tilt
|
||||
);
|
||||
let calm = at(Mood::Calm, EASE as f64);
|
||||
let eased = blend(&working, calm.clone(), EASE);
|
||||
assert!(
|
||||
(eased.tilt - calm.tilt).abs() < 1e-5 && (eased.look.1 - calm.look.1).abs() < 1e-5,
|
||||
"eased in {EASE} s"
|
||||
);
|
||||
let waiting = at(Mood::Waiting, 1.0);
|
||||
let leaving = blend(&waiting, at(Mood::Calm, 0.1), 0.1);
|
||||
let bang = (leaving.marks.iter())
|
||||
.find(|(mark, _)| *mark == Mark::Bang)
|
||||
.expect("the mark she leaves fades, not vanishes")
|
||||
.1;
|
||||
assert!(bang.alpha > 0.0 && bang.alpha < 1.0);
|
||||
assert_eq!(leaving.eyes, Eyes::Open, "the shapes are the new act's");
|
||||
let fretting = at(Mood::Error, 1.0);
|
||||
let calming = blend(&fretting, at(Mood::Calm, 0.1), 0.1);
|
||||
assert!(
|
||||
calming.brows > 0.0 && calming.brows < 1.0,
|
||||
"her brows ease down"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Ely's window on macOS: a child of the main window at its level, so it moves with it and keeps its layer and Space.
|
||||
|
||||
use anyhow::bail;
|
||||
use gpui::Window;
|
||||
use objc::{
|
||||
msg_send,
|
||||
runtime::{NO, Object, YES},
|
||||
sel, sel_impl,
|
||||
};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
use super::left;
|
||||
|
||||
/// AppKit's `NSWindowAbove`.
|
||||
const ABOVE: isize = 1;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Point {
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
struct Rect {
|
||||
origin: Point,
|
||||
width: f64,
|
||||
height: f64,
|
||||
}
|
||||
|
||||
/// Her NSWindow and the main one's.
|
||||
pub struct Glue {
|
||||
pet: *mut Object,
|
||||
main: *mut Object,
|
||||
}
|
||||
|
||||
impl Glue {
|
||||
/// Makes her window the main window's child, at its level and in its Spaces, with no shadow and never key, so a press leaves the keys with the terminal.
|
||||
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
|
||||
let (pet, main) = (native(pet)?, native(main)?);
|
||||
unsafe {
|
||||
let level: isize = msg_send![main, level];
|
||||
let _: () = msg_send![pet, setLevel: level];
|
||||
let spaces: usize = msg_send![main, collectionBehavior];
|
||||
let _: () = msg_send![pet, setCollectionBehavior: spaces];
|
||||
let _: () = msg_send![pet, setHasShadow: NO];
|
||||
let _: () = msg_send![pet, setBecomesKeyOnlyIfNeeded: YES];
|
||||
let _: () = msg_send![main, addChildWindow: pet ordered: ABOVE];
|
||||
}
|
||||
Ok(Self { pet, main })
|
||||
}
|
||||
|
||||
/// Stands her on the main window's top edge, as far in as its width allows.
|
||||
pub fn place(&self, _: &Window) -> anyhow::Result<()> {
|
||||
unsafe {
|
||||
let frame: Rect = msg_send![self.main, frame];
|
||||
let at = Point {
|
||||
x: frame.origin.x + f64::from(left(gpui::px(frame.width as f32))),
|
||||
y: frame.origin.y + frame.height,
|
||||
};
|
||||
let _: () = msg_send![self.pet, setFrameOrigin: at];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The NSWindow gpui made for `window`.
|
||||
fn native(window: &Window) -> anyhow::Result<*mut Object> {
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::AppKit(appkit) = handle.as_raw() else {
|
||||
bail!("pet: not an AppKit window");
|
||||
};
|
||||
let view = appkit.ns_view.as_ptr() as *mut Object;
|
||||
let native: *mut Object = unsafe { msg_send![view, window] };
|
||||
anyhow::ensure!(!native.is_null(), "pet: the view is in no window");
|
||||
Ok(native)
|
||||
}
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
//! Ely rigged as the films rig her: the icon's paths in layers of one color, each a mask gpui draws with a matrix of its own.
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, Hsla, Pixels, Point, ScaledPixels, SharedString, TransformationMatrix, Window,
|
||||
point, px, radians, rgb, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
WIDTH,
|
||||
look::{Eyes, Mark, Mouth, Pose},
|
||||
};
|
||||
|
||||
/// A point or a shift in the window's device pixels.
|
||||
type Device = (f32, f32);
|
||||
|
||||
/// The head's box in the icon's units; the edge she holds runs along its bottom.
|
||||
const VIEW: [f32; 4] = [110.0, 6.0, 292.0, 280.0];
|
||||
/// Points a unit draws at.
|
||||
const UNIT: f32 = WIDTH / VIEW[2];
|
||||
|
||||
/// How a layer moves.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Bone {
|
||||
/// Her neck and collar, which stay on the edge as her head bobs.
|
||||
Body,
|
||||
/// Bobs, and turns with her head.
|
||||
Head,
|
||||
/// An open eye's lid and lash, which a blink squashes; left is 0.
|
||||
Lid(usize),
|
||||
/// An open eye, which looks about as well.
|
||||
Iris(usize),
|
||||
Arc(usize),
|
||||
Lines,
|
||||
Brows,
|
||||
Blush,
|
||||
Mouth(Mouth),
|
||||
Lock(usize),
|
||||
Ahoge,
|
||||
/// Holds the edge; taps.
|
||||
Hand(usize),
|
||||
Mark(Mark),
|
||||
}
|
||||
|
||||
/// A layer: its name, color and opacity, its box in the icon's units, the point it turns and grows about, how it moves, and its shapes.
|
||||
struct Part {
|
||||
name: &'static str,
|
||||
color: u32,
|
||||
alpha: f32,
|
||||
frame: [f32; 4],
|
||||
pivot: (f32, f32),
|
||||
bone: Bone,
|
||||
shapes: &'static str,
|
||||
}
|
||||
|
||||
/// A part with no pivot of its own.
|
||||
#[rustfmt::skip]
|
||||
const fn part(name: &'static str, color: u32, frame: [f32; 4], bone: Bone, shapes: &'static str) -> Part {
|
||||
Part { name, color, alpha: 1.0, frame, pivot: (256.0, 236.0), bone, shapes }
|
||||
}
|
||||
|
||||
/// Every layer, back to front.
|
||||
#[rustfmt::skip]
|
||||
const PARTS: &[Part] = &[
|
||||
part("hair-back", 0xEC86B5, [117.0, 45.0, 278.0, 262.0], Bone::Head, r#"<path d="M256 46C192 46 142 90 136 158C131 214 126 262 118 306L394 306C386 262 381 214 376 158C370 90 320 46 256 46Z"/>"#),
|
||||
part("shade-back", 0xD9659A, [193.0, 199.0, 126.0, 78.0], Bone::Head, r#"<path d="M194 200C198 238 214 262 230 276L282 276C298 262 314 238 318 200Z"/>"#),
|
||||
part("neck", 0xF7CDBE, [235.0, 221.0, 42.0, 50.0], Bone::Body, r#"<path d="M236 222L276 222L276 266C268 270 244 270 236 266Z"/>"#),
|
||||
part("collar", 0xFFFFFF, [197.0, 255.0, 118.0, 30.0], Bone::Body, r#"<path d="M198 284C204 268 222 258 238 256L256 272L274 256C290 258 308 268 314 284Z"/>"#),
|
||||
part("bow", 0xF05A97, [221.0, 247.0, 70.0, 32.0], Bone::Body, r#"<path d="M256 264C244 250 226 248 224 258C222 270 236 278 256 264ZM256 264C268 250 286 248 288 258C290 270 276 278 256 264Z"/>"#),
|
||||
part("knot", 0xD94683, [247.0, 255.0, 18.0, 18.0], Bone::Body, r#"<circle cx="256" cy="264" r="8"/>"#),
|
||||
part("skin", 0xFFE7DC, [177.0, 93.0, 158.0, 152.0], Bone::Head, concat!(r#"<path d=""#, "M178 164C178 116 212 94 256 94C300 94 334 116 334 164C334 202 312 228 284 238C274 242 264 244 256 244C248 244 238 242 228 238C200 228 178 202 178 164Z", r#""/>"#)),
|
||||
part("face-shade", 0xF9D8CB, [161.0, 73.0, 190.0, 118.0], Bone::Head, concat!(r#"<defs><clipPath id="face"><path d=""#, "M178 164C178 116 212 94 256 94C300 94 334 116 334 164C334 202 312 228 284 238C274 242 264 244 256 244C248 244 238 242 228 238C200 228 178 202 178 164Z", r#""/></clipPath></defs><g clip-path="url(#face)"><path transform="translate(0 12)" d=""#, "M170 176C162 110 204 62 256 62C308 62 350 110 342 176Q328 152 318 134Q316 156 304 172Q298 150 286 130Q286 156 274 178Q268 150 256 128Q244 150 238 178Q226 156 226 130Q214 150 208 172Q196 156 194 134Q184 152 170 176Z", r#""/></g>"#)),
|
||||
eye("iris-l", 0xA24FB9, 222.0, 0, r#"<ellipse cx="222" cy="188" rx="18" ry="23"/>"#),
|
||||
eye("ring-l", 0xEE84C4, 222.0, 0, r#"<ellipse cx="222" cy="198" rx="13.5" ry="11.5"/>"#),
|
||||
eye("pupil-l", 0x5A2368, 222.0, 0, r#"<ellipse cx="222" cy="190" rx="7.5" ry="10.5"/>"#),
|
||||
eye("shine-l", 0xFFFFFF, 222.0, 0, r#"<circle cx="215" cy="178" r="7"/><circle cx="229" cy="199" r="3.2"/>"#),
|
||||
Part { name: "lid-l", color: 0x45233F, alpha: 1.0, frame: [193.0, 158.0, 52.0, 26.0], pivot: (222.0, 192.0), bone: Bone::Lid(0), shapes: r#"<path d="M194 182C195 168 208 159 224 159C234 159 241 164 244 172L240 173C236 168 231 165 224 165C214 165 207 172 204 183Z"/>"# },
|
||||
Part { name: "lash-l", color: 0xC2506F, alpha: 0.35, frame: [213.0, 210.0, 18.0, 6.0], pivot: (222.0, 192.0), bone: Bone::Lid(0), shapes: r#"<path fill="none" stroke="black" stroke-width="2" stroke-linecap="round" d="M215 212Q222 214 229 212"/>"# },
|
||||
eye("iris-r", 0xA24FB9, 290.0, 1, r#"<ellipse cx="290" cy="188" rx="18" ry="23"/>"#),
|
||||
eye("ring-r", 0xEE84C4, 290.0, 1, r#"<ellipse cx="290" cy="198" rx="13.5" ry="11.5"/>"#),
|
||||
eye("pupil-r", 0x5A2368, 290.0, 1, r#"<ellipse cx="290" cy="190" rx="7.5" ry="10.5"/>"#),
|
||||
eye("shine-r", 0xFFFFFF, 290.0, 1, r#"<circle cx="283" cy="178" r="7"/><circle cx="297" cy="199" r="3.2"/>"#),
|
||||
Part { name: "lid-r", color: 0x45233F, alpha: 1.0, frame: [267.0, 158.0, 52.0, 26.0], pivot: (290.0, 192.0), bone: Bone::Lid(1), shapes: r#"<path d="M318 182C317 168 304 159 288 159C278 159 271 164 268 172L272 173C276 168 281 165 288 165C298 165 305 172 308 183Z"/>"# },
|
||||
Part { name: "lash-r", color: 0xC2506F, alpha: 0.35, frame: [281.0, 210.0, 18.0, 6.0], pivot: (290.0, 192.0), bone: Bone::Lid(1), shapes: r#"<path fill="none" stroke="black" stroke-width="2" stroke-linecap="round" d="M283 212Q290 214 297 212"/>"# },
|
||||
Part { name: "arc-l", color: 0x45233F, alpha: 1.0, frame: [200.0, 171.0, 44.0, 31.0], pivot: (222.0, 192.0), bone: Bone::Arc(0), shapes: r#"<path fill="none" stroke="black" stroke-width="7" stroke-linecap="round" d="M205 197Q222 176 239 197"/>"# },
|
||||
Part { name: "arc-r", color: 0x45233F, alpha: 1.0, frame: [268.0, 171.0, 44.0, 31.0], pivot: (290.0, 192.0), bone: Bone::Arc(1), shapes: r#"<path fill="none" stroke="black" stroke-width="7" stroke-linecap="round" d="M273 197Q290 176 307 197"/>"# },
|
||||
part("lines", 0x45233F, [201.0, 190.0, 110.0, 8.0], Bone::Lines, r#"<path fill="none" stroke="black" stroke-width="6" stroke-linecap="round" d="M205 194H239M273 194H307"/>"#),
|
||||
Part { name: "blush", color: 0xFF8DAA, alpha: 0.55, frame: [180.0, 206.0, 152.0, 20.0], pivot: (256.0, 216.0), bone: Bone::Blush, shapes: r#"<ellipse cx="198" cy="216" rx="15" ry="8"/><ellipse cx="314" cy="216" rx="15" ry="8"/>"# },
|
||||
mouth("smile", 0xC2506F, [241.0, 219.0, 30.0, 13.0], Mouth::Smile, r#"<path fill="none" stroke="black" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" d="M244 222Q250 229 256 223Q262 229 268 222"/>"#),
|
||||
mouth("grin", 0xC2506F, [242.0, 218.0, 28.0, 24.0], Mouth::Grin, r#"<path d="M243 219Q256 241 269 219Z"/>"#),
|
||||
mouth("tongue", 0xFF8DAA, [248.0, 224.0, 16.0, 13.0], Mouth::Grin, r#"<path d="M249 229Q256 236 263 229Q256 225 249 229Z"/>"#),
|
||||
mouth("gasp", 0xC2506F, [249.0, 218.0, 14.0, 16.0], Mouth::Gasp, r#"<ellipse cx="256" cy="226" rx="5.5" ry="7"/>"#),
|
||||
mouth("flat", 0xC2506F, [246.0, 222.0, 20.0, 6.0], Mouth::Flat, r#"<path fill="none" stroke="black" stroke-width="3.5" stroke-linecap="round" d="M249 225H263"/>"#),
|
||||
mouth("frown", 0xC2506F, [243.0, 217.0, 26.0, 14.0], Mouth::Frown, r#"<path fill="none" stroke="black" stroke-width="3.5" stroke-linecap="round" d="M246 228Q256 220 266 228"/>"#),
|
||||
Part { name: "lock-l", color: 0xF7A6C9, alpha: 1.0, frame: [147.0, 117.0, 54.0, 200.0], pivot: (184.0, 118.0), bone: Bone::Lock(0), shapes: r#"<path d="M184 118C164 150 156 204 160 258C162 282 156 300 148 316C164 312 178 300 186 286C190 256 192 214 198 176C200 152 196 132 184 118Z"/>"# },
|
||||
Part { name: "lock-r", color: 0xF7A6C9, alpha: 1.0, frame: [311.0, 117.0, 54.0, 200.0], pivot: (328.0, 118.0), bone: Bone::Lock(1), shapes: r#"<path d="M328 118C348 150 356 204 352 258C350 282 356 300 364 316C348 312 334 300 326 286C322 256 320 214 314 176C312 152 316 132 328 118Z"/>"# },
|
||||
part("bangs", 0xF7A6C9, [161.0, 61.0, 190.0, 118.0], Bone::Head, concat!(r#"<path d=""#, "M170 176C162 110 204 62 256 62C308 62 350 110 342 176Q328 152 318 134Q316 156 304 172Q298 150 286 130Q286 156 274 178Q268 150 256 128Q244 150 238 178Q226 156 226 130Q214 150 208 172Q196 156 194 134Q184 152 170 176Z", r#""/>"#)),
|
||||
part("gloss", 0xFFD1E4, [195.0, 83.0, 122.0, 26.0], Bone::Head, r#"<path d="M196 108C220 84 292 84 316 108C292 92 220 92 196 108Z"/>"#),
|
||||
Part { name: "ahoge", color: 0xF7A6C9, alpha: 1.0, frame: [237.0, 17.0, 44.0, 50.0], pivot: (258.0, 64.0), bone: Bone::Ahoge, shapes: r#"<path d="M250 66C238 40 252 18 280 20C264 28 258 44 262 64Z"/>"# },
|
||||
part("pin", 0xFFE48A, [303.0, 85.0, 30.0, 30.0], Bone::Head, r#"<path d="M318 86Q320 98 332 100Q320 102 318 114Q316 102 304 100Q316 98 318 86Z"/>"#),
|
||||
part("brows", 0x45233F, [202.0, 148.0, 108.0, 16.0], Bone::Brows, r#"<g fill="none" stroke="black" stroke-width="5" stroke-linecap="round"><path d="M206 160L234 152"/><path d="M306 160L278 152"/></g>"#),
|
||||
part("hand-l", 0xFFE7DC, [149.0, 261.0, 46.0, 26.0], Bone::Hand(0), r#"<path d="M150 286L150 272C150 264 161 264 161 272C161 262 172 262 172 271C172 262 183 262 183 272C183 264 194 264 194 273L194 286Z"/>"#),
|
||||
part("knuckles-l", 0xF2BBA8, [159.0, 271.0, 26.0, 17.0], Bone::Hand(0), r#"<path fill="none" stroke="black" stroke-width="2" stroke-linecap="round" d="M161 274V286M172 273V286M183 274V286"/>"#),
|
||||
part("hand-r", 0xFFE7DC, [317.0, 261.0, 46.0, 26.0], Bone::Hand(1), r#"<path d="M318 286L318 273C318 264 329 264 329 272C329 262 340 262 340 271C340 262 351 262 351 272C351 264 362 264 362 272L362 286Z"/>"#),
|
||||
part("knuckles-r", 0xF2BBA8, [327.0, 271.0, 26.0, 17.0], Bone::Hand(1), r#"<path fill="none" stroke="black" stroke-width="2" stroke-linecap="round" d="M329 274V286M340 273V286M351 274V286"/>"#),
|
||||
mark("bang", 0xFFB020, [368.0, 35.0, 20.0, 75.0], (378.0, 107.0), Mark::Bang, r#"<path d="M371 38L385 38L380 86L376 86Z"/><circle cx="378" cy="100" r="7"/>"#),
|
||||
mark("bang-edge", 0x45233F, [368.0, 35.0, 20.0, 75.0], (378.0, 107.0), Mark::Bang, r#"<g fill="none" stroke="black" stroke-width="3"><path d="M371 38L385 38L380 86L376 86Z"/><circle cx="378" cy="100" r="7"/></g>"#),
|
||||
mark("z1", 0x9FA3D9, [348.0, 66.0, 28.0, 30.0], (362.0, 81.0), Mark::Z1, r#"<path fill="none" stroke="black" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" d="M352 70H372L352 92H372"/>"#),
|
||||
mark("z2", 0x9FA3D9, [376.0, 34.0, 22.0, 24.0], (387.0, 46.0), Mark::Z2, r#"<path fill="none" stroke="black" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" d="M380 38H394L380 54H394"/>"#),
|
||||
mark("heart", 0xFF5C93, [349.0, 55.0, 58.0, 46.0], (378.0, 82.0), Mark::Heart, r#"<path d="M378 70C366 56 350 66 356 80C360 90 378 100 378 100C378 100 396 90 400 80C406 66 390 56 378 70Z"/>"#),
|
||||
mark("sparkle-l", 0xFFE48A, [123.0, 69.0, 34.0, 34.0], (140.0, 86.0), Mark::SparkleLeft, r#"<path d="M140 70Q143 84 156 86Q143 88 140 102Q137 88 124 86Q137 84 140 70Z"/>"#),
|
||||
mark("sparkle-r", 0xFFE48A, [367.0, 51.0, 26.0, 26.0], (380.0, 64.0), Mark::SparkleRight, r#"<path d="M380 52Q382 62 392 64Q382 66 380 76Q378 66 368 64Q378 62 380 52Z"/>"#),
|
||||
mark("drop", 0x9CD5FF, [336.0, 126.0, 22.0, 32.0], (346.0, 142.0), Mark::Drop, r#"<path d="M346 128Q356 144 350 152Q340 156 338 146Q338 138 346 128Z"/>"#),
|
||||
mark("drop-edge", 0x5AA0D8, [336.0, 126.0, 22.0, 32.0], (346.0, 142.0), Mark::Drop, r#"<path fill="none" stroke="black" stroke-width="2" d="M346 128Q356 144 350 152Q340 156 338 146Q338 138 346 128Z"/>"#),
|
||||
];
|
||||
|
||||
/// A layer of an open eye centred at `x`.
|
||||
#[rustfmt::skip]
|
||||
const fn eye(name: &'static str, color: u32, x: f32, side: usize, shapes: &'static str) -> Part {
|
||||
let frame = [x - 19.0, 164.0, 38.0, 48.0];
|
||||
Part { name, color, alpha: 1.0, frame, pivot: (x, 192.0), bone: Bone::Iris(side), shapes }
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const fn mouth(name: &'static str, color: u32, frame: [f32; 4], mouth: Mouth, shapes: &'static str) -> Part {
|
||||
Part { name, color, alpha: 1.0, frame, pivot: (256.0, 224.0), bone: Bone::Mouth(mouth), shapes }
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
const fn mark(name: &'static str, color: u32, frame: [f32; 4], pivot: (f32, f32), mark: Mark, shapes: &'static str) -> Part {
|
||||
Part { name, color, alpha: 1.0, frame, pivot, bone: Bone::Mark(mark), shapes }
|
||||
}
|
||||
|
||||
/// Every layer's key and SVG, made once.
|
||||
pub struct Rig {
|
||||
layers: Vec<(&'static Part, SharedString, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl Rig {
|
||||
pub fn new() -> Self {
|
||||
let layers = (PARTS.iter())
|
||||
.map(|part| {
|
||||
let key = format!("ely/{}.svg", part.name).into();
|
||||
(part, key, svg(part).into_bytes())
|
||||
})
|
||||
.collect();
|
||||
Self { layers }
|
||||
}
|
||||
|
||||
/// Draws her in `pose`, the head's box from `origin`.
|
||||
pub fn paint(&self, origin: Point<Pixels>, pose: &Pose, window: &mut Window, cx: &App) {
|
||||
let scale = window.scale_factor();
|
||||
let (left, top) = (f32::from(origin.x), f32::from(origin.y));
|
||||
let at = |(x, y): (f32, f32)| {
|
||||
let x = (left + (x - VIEW[0]) * UNIT) * scale;
|
||||
(x, (top + (y - VIEW[1]) * UNIT) * scale)
|
||||
};
|
||||
let by = |(x, y): (f32, f32)| (x * UNIT * scale, y * UNIT * scale);
|
||||
let whole = shift(TransformationMatrix::unit(), by((pose.shake, -pose.lift)));
|
||||
let breath = shift(whole, by((0.0, -pose.bob)));
|
||||
let head = turn(breath, at((256.0, 236.0)), pose.tilt);
|
||||
for (part, key, svg) in &self.layers {
|
||||
let Some((matrix, alpha)) = moved(part, pose, [whole, head], &at, &by) else {
|
||||
continue;
|
||||
};
|
||||
let [x, y, w, h] = part.frame;
|
||||
let bounds = Bounds::new(
|
||||
origin + point(px((x - VIEW[0]) * UNIT), px((y - VIEW[1]) * UNIT)),
|
||||
size(px(w * UNIT), px(h * UNIT)),
|
||||
);
|
||||
let color = Hsla::from(rgb(part.color)).opacity(part.alpha * alpha);
|
||||
let drawn =
|
||||
window.paint_svg(bounds, key.clone(), Some(svg.as_slice()), matrix, color, cx);
|
||||
if let Err(error) = drawn {
|
||||
log::error!("pet: Ely's {} does not draw: {error:#}", part.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `part` stands in `pose`, and its opacity; none while the pose hides it.
|
||||
fn moved(
|
||||
part: &Part,
|
||||
pose: &Pose,
|
||||
[whole, head]: [TransformationMatrix; 2],
|
||||
at: &impl Fn((f32, f32)) -> Device,
|
||||
by: &impl Fn((f32, f32)) -> Device,
|
||||
) -> Option<(TransformationMatrix, f32)> {
|
||||
let pivot = at(part.pivot);
|
||||
let open = |side: usize| pose.eyes == Eyes::Open || (pose.eyes == Eyes::Wink && side == 1);
|
||||
let shut = |side: usize| pose.eyes == Eyes::Arcs || (pose.eyes == Eyes::Wink && side == 0);
|
||||
let squash = |side: usize, wide: f32| grow(head, pivot, (wide, wide * pose.open[side]));
|
||||
Some(match part.bone {
|
||||
Bone::Body => (whole, 1.0),
|
||||
Bone::Head => (head, 1.0),
|
||||
Bone::Lid(side) if open(side) => (squash(side, pose.wide), 1.0),
|
||||
Bone::Iris(side) if open(side) => (shift(squash(side, pose.wide), by(pose.look)), 1.0),
|
||||
Bone::Arc(side) if shut(side) => (squash(side, 1.0), 1.0),
|
||||
Bone::Lines if pose.eyes == Eyes::Lines => (head, 1.0),
|
||||
Bone::Brows if pose.brows > 0.0 => (head, pose.brows),
|
||||
Bone::Blush => (head, 1.0 + 0.6 * pose.flush),
|
||||
Bone::Mouth(mouth) if mouth == pose.mouth => (grow(head, pivot, (pose.pop, pose.pop)), 1.0),
|
||||
Bone::Lock(side) => {
|
||||
let sway = if side == 0 { pose.sway } else { -pose.sway };
|
||||
(turn(head, pivot, sway), 1.0)
|
||||
}
|
||||
Bone::Ahoge => (turn(head, pivot, pose.ahoge), 1.0),
|
||||
Bone::Hand(side) => (shift(whole, by((0.0, pose.hands[side]))), 1.0),
|
||||
Bone::Mark(mark) => {
|
||||
let &(_, motion) = pose.marks.iter().find(|(shown, _)| *shown == mark)?;
|
||||
let moved = shift(head, by(motion.at));
|
||||
let turned = turn(moved, pivot, motion.turn);
|
||||
(
|
||||
grow(turned, pivot, (motion.scale, motion.scale)),
|
||||
motion.alpha,
|
||||
)
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn shift(matrix: TransformationMatrix, (x, y): Device) -> TransformationMatrix {
|
||||
matrix.translate(point(ScaledPixels(x), ScaledPixels(y)))
|
||||
}
|
||||
|
||||
/// `matrix`, after a turn of `degrees` clockwise about `pivot`.
|
||||
fn turn(matrix: TransformationMatrix, pivot: Device, degrees: f32) -> TransformationMatrix {
|
||||
let turned = shift(matrix, pivot).rotate(radians(degrees.to_radians()));
|
||||
shift(turned, (-pivot.0, -pivot.1))
|
||||
}
|
||||
|
||||
/// `matrix`, after a growth by `(x, y)` about `pivot`.
|
||||
fn grow(matrix: TransformationMatrix, pivot: Device, (x, y): (f32, f32)) -> TransformationMatrix {
|
||||
shift(shift(matrix, pivot).scale(size(x, y)), (-pivot.0, -pivot.1))
|
||||
}
|
||||
|
||||
/// The SVG of `part`: its shapes in its box.
|
||||
fn svg(part: &Part) -> String {
|
||||
let [x, y, w, h] = part.frame;
|
||||
format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{x} {y} {w} {h}" width="{w}" height="{h}">{}</svg>"#,
|
||||
part.shapes
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use resvg::usvg;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_layer_parses_and_stays_in_its_box() {
|
||||
let mut names = HashSet::new();
|
||||
for part in PARTS {
|
||||
assert!(names.insert(part.name), "{} twice", part.name);
|
||||
let tree = usvg::Tree::from_str(&svg(part), &usvg::Options::default())
|
||||
.unwrap_or_else(|error| panic!("{}: {error}", part.name));
|
||||
let drawn = tree.root().abs_stroke_bounding_box();
|
||||
let [_, _, width, height] = part.frame;
|
||||
assert!(
|
||||
drawn.left() >= 0.0
|
||||
&& drawn.top() >= 0.0
|
||||
&& drawn.right() <= width
|
||||
&& drawn.bottom() <= height,
|
||||
"{}: {drawn:?} spills out of {width} by {height}",
|
||||
part.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_is_drawn_from_every_part_the_icon_gives_her() {
|
||||
let named = |name: &str| PARTS.iter().any(|part| part.name == name);
|
||||
for name in [
|
||||
"hair-back",
|
||||
"skin",
|
||||
"bangs",
|
||||
"ahoge",
|
||||
"lock-l",
|
||||
"lock-r",
|
||||
"hand-l",
|
||||
"hand-r",
|
||||
] {
|
||||
assert!(named(name), "{name}");
|
||||
}
|
||||
let marks = [
|
||||
Mark::Bang,
|
||||
Mark::Z1,
|
||||
Mark::Z2,
|
||||
Mark::Heart,
|
||||
Mark::SparkleLeft,
|
||||
Mark::SparkleRight,
|
||||
Mark::Drop,
|
||||
];
|
||||
for mark in marks {
|
||||
assert!(
|
||||
PARTS.iter().any(|part| part.bone == Bone::Mark(mark)),
|
||||
"{mark:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Ely's window on Windows: a bare popup owned by the main window, so it stays above it and hides with it, sized and moved as it moves.
|
||||
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::atomic::{AtomicIsize, Ordering},
|
||||
};
|
||||
|
||||
use anyhow::bail;
|
||||
use gpui::{Bounds, Pixels, Window};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
||||
Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect},
|
||||
UI::{
|
||||
HiDpi::{GetDpiForMonitor, GetDpiForWindow, MDT_EFFECTIVE_DPI},
|
||||
WindowsAndMessaging::{
|
||||
CallWindowProcW, GWL_STYLE, GWLP_HWNDPARENT, GWLP_WNDPROC, GetWindowInfo,
|
||||
GetWindowLongPtrW, HWND_NOTOPMOST, MA_NOACTIVATE, PostMessageW, SWP_FRAMECHANGED,
|
||||
SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetWindowLongPtrW, SetWindowPos,
|
||||
USER_DEFAULT_SCREEN_DPI, WINDOWINFO, WM_APP, WM_DPICHANGED, WM_MOUSEACTIVATE, WNDPROC,
|
||||
WS_CAPTION, WS_POPUP,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
use super::{HEAD, ROOM, WIDTH, left, pane};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// gpui's window procedure, which hers passes every message on to but a press's activation and her places.
|
||||
static GPUI: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
thread_local! {
|
||||
/// The bottom left corner her last place gave her, on the thread her window lives on.
|
||||
static CORNER: Cell<Option<(i32, i32)>> = const { Cell::new(None) };
|
||||
}
|
||||
|
||||
/// Her place, posted to her window: Windows sends a new size at once, which gpui hears only outside its own updates.
|
||||
const PLACE: u32 = WM_APP;
|
||||
/// Her corner at a new DPI, posted so it follows the whole move that brought the change.
|
||||
const KEEP: u32 = WM_APP + 1;
|
||||
const _: () = assert!(
|
||||
size_of::<WPARAM>() == 8,
|
||||
"a place packs two i32 into a word"
|
||||
);
|
||||
|
||||
/// Her window and the main one.
|
||||
pub struct Glue {
|
||||
pet: HWND,
|
||||
main: HWND,
|
||||
}
|
||||
|
||||
impl Glue {
|
||||
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
|
||||
Self::hold(native(pet)?, native(main)?)
|
||||
}
|
||||
|
||||
/// Gives her window the main window as owner, a bare popup's frame, none of the topmost place gpui gives a popup, and keeps a press from activating it, so the keys stay with the terminal.
|
||||
fn hold(pet: HWND, main: HWND) -> anyhow::Result<Self> {
|
||||
unsafe {
|
||||
SetWindowLongPtrW(pet, GWLP_HWNDPARENT, main as isize);
|
||||
// gpui's popup is overlapped, which Windows gives a caption and a frame.
|
||||
let style = (GetWindowLongPtrW(pet, GWL_STYLE) as u32 & !WS_CAPTION) | WS_POPUP;
|
||||
SetWindowLongPtrW(pet, GWL_STYLE, style as isize);
|
||||
let gpui = SetWindowLongPtrW(pet, GWLP_WNDPROC, inactive as *const () as isize);
|
||||
anyhow::ensure!(gpui != 0, "pet: her window's procedure does not read");
|
||||
GPUI.store(gpui, Ordering::Relaxed);
|
||||
let flags = SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_FRAMECHANGED;
|
||||
anyhow::ensure!(
|
||||
SetWindowPos(pet, HWND_NOTOPMOST, 0, 0, 0, 0, flags) != 0,
|
||||
"pet: her window keeps the topmost place or its frame"
|
||||
);
|
||||
}
|
||||
Ok(Self { pet, main })
|
||||
}
|
||||
|
||||
/// Stands her on the main window's top edge, as far in as its width allows.
|
||||
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
|
||||
self.put(main.scale_factor(), pane(main))
|
||||
}
|
||||
|
||||
/// Posts her bottom left corner on `pane` at `scale`, in the main window's points; her window sizes her at the DPI it has when the place lands, as gpui did not when it opened her off the display.
|
||||
fn put(&self, scale: f32, pane: Bounds<Pixels>) -> anyhow::Result<()> {
|
||||
let mut main = WINDOWINFO {
|
||||
cbSize: size_of::<WINDOWINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let read = unsafe { GetWindowInfo(self.main, &mut main) };
|
||||
anyhow::ensure!(read != 0, "pet: the main window's place does not read");
|
||||
let corner = stand(main.rcClient, scale, pane);
|
||||
let posted = unsafe { PostMessageW(self.pet, PLACE, pack(corner), 0) };
|
||||
anyhow::ensure!(posted != 0, "pet: her place does not post");
|
||||
log::debug!("pet: Ely is to stand on {corner:?}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Her bottom left corner on `pane` at `scale` over the main window's `client` rect, in pixels.
|
||||
fn stand(client: RECT, scale: f32, pane: Bounds<Pixels>) -> (i32, i32) {
|
||||
let (x, y) = (f32::from(pane.origin.x), f32::from(pane.origin.y));
|
||||
(
|
||||
client.left + ((x + left(pane.size.width)) * scale).round() as i32,
|
||||
client.top + (y * scale).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Her box at `dpi` on her bottom left `corner`.
|
||||
fn boxed((left, bottom): (i32, i32), dpi: u32) -> RECT {
|
||||
let (width, height) = sized(dpi as f32 / USER_DEFAULT_SCREEN_DPI as f32);
|
||||
RECT {
|
||||
left,
|
||||
top: bottom - height,
|
||||
right: left + width,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
|
||||
/// Her box's size in pixels at `own` scale.
|
||||
fn sized(own: f32) -> (i32, i32) {
|
||||
(
|
||||
(WIDTH * own).round() as i32,
|
||||
((HEAD + ROOM) * own).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Her window's procedure: a press does not activate her window, she takes her places, and all else is gpui's.
|
||||
unsafe extern "system" fn inactive(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
let gpui: WNDPROC = unsafe { std::mem::transmute(GPUI.load(Ordering::Relaxed)) };
|
||||
match message {
|
||||
WM_MOUSEACTIVATE => MA_NOACTIVATE as LRESULT,
|
||||
PLACE => {
|
||||
let corner = unpack(wparam);
|
||||
CORNER.set(Some(corner));
|
||||
match unsafe { GetDpiForWindow(window) } {
|
||||
0 => log::error!("pet: her window's DPI does not read at her place"),
|
||||
dpi => move_to(window, boxed(corner, dpi)),
|
||||
}
|
||||
0
|
||||
}
|
||||
WM_DPICHANGED => {
|
||||
// gpui takes the rect Windows suggests; her corner follows once the move is done.
|
||||
let answer = unsafe { CallWindowProcW(gpui, window, message, wparam, lparam) };
|
||||
if unsafe { PostMessageW(window, KEEP, wparam & 0xFFFF, 0) } == 0 {
|
||||
log::error!("pet: her corner does not post at a new DPI");
|
||||
}
|
||||
answer
|
||||
}
|
||||
KEEP => {
|
||||
let (dpi, current) = (wparam as u32, unsafe { GetDpiForWindow(window) });
|
||||
if current == 0 {
|
||||
log::error!("pet: her window's DPI does not read at a new DPI");
|
||||
return 0;
|
||||
}
|
||||
let Some(corner) = CORNER.get() else {
|
||||
log::debug!("pet: at {dpi} DPI Ely has no place to keep yet");
|
||||
return 0;
|
||||
};
|
||||
match keeping(corner, dpi, current, display_dpi) {
|
||||
Ok(Some(kept)) => {
|
||||
move_to(window, kept);
|
||||
log::debug!("pet: at {dpi} DPI Ely keeps her corner at {corner:?}");
|
||||
}
|
||||
Ok(None) => log::debug!(
|
||||
"pet: a keep at {dpi} DPI, hers {current}, leaves Ely where Windows put her"
|
||||
),
|
||||
Err(error) => log::error!("{error:#}"),
|
||||
}
|
||||
0
|
||||
}
|
||||
_ => unsafe { CallWindowProcW(gpui, window, message, wparam, lparam) },
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves her window to `rect`.
|
||||
fn move_to(window: HWND, rect: RECT) {
|
||||
let (width, height) = (rect.right - rect.left, rect.bottom - rect.top);
|
||||
let flags = SWP_NOZORDER | SWP_NOACTIVATE;
|
||||
let null = std::ptr::null_mut();
|
||||
if unsafe { SetWindowPos(window, null, rect.left, rect.top, width, height, flags) } == 0 {
|
||||
log::error!(
|
||||
"pet: her window does not move to {},{}",
|
||||
rect.left,
|
||||
rect.top
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Her box at `dpi` on the bottom left `corner` her place gave her, while `dpi` is still hers, `current`, and the box lies mostly on a display of that DPI, as `dpi_of` reads it, so the move changes no DPI and posts no other keep; else none, and Windows' rect stands.
|
||||
fn keeping(
|
||||
corner: (i32, i32),
|
||||
dpi: u32,
|
||||
current: u32,
|
||||
dpi_of: impl Fn(&RECT) -> anyhow::Result<u32>,
|
||||
) -> anyhow::Result<Option<RECT>> {
|
||||
if dpi != current {
|
||||
return Ok(None);
|
||||
}
|
||||
let kept = boxed(corner, dpi);
|
||||
Ok((dpi_of(&kept)? == dpi).then_some(kept))
|
||||
}
|
||||
|
||||
/// The DPI of the display under most of `rect`, which Windows gives a window there.
|
||||
fn display_dpi(rect: &RECT) -> anyhow::Result<u32> {
|
||||
let monitor = unsafe { MonitorFromRect(rect, MONITOR_DEFAULTTONEAREST) };
|
||||
let (mut x, mut y) = (0, 0);
|
||||
let read = unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut x, &mut y) };
|
||||
anyhow::ensure!(
|
||||
read == 0,
|
||||
"pet: the display under her box gives no DPI: {read:#x}"
|
||||
);
|
||||
Ok(x)
|
||||
}
|
||||
|
||||
fn pack((low, high): (i32, i32)) -> WPARAM {
|
||||
low as u32 as WPARAM | (high as u32 as WPARAM) << 32
|
||||
}
|
||||
|
||||
fn unpack(word: WPARAM) -> (i32, i32) {
|
||||
(word as u32 as i32, (word >> 32) as u32 as i32)
|
||||
}
|
||||
|
||||
/// The HWND gpui made for `window`.
|
||||
fn native(window: &Window) -> anyhow::Result<HWND> {
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::Win32(win32) = handle.as_raw() else {
|
||||
bail!("pet: not a Win32 window");
|
||||
};
|
||||
Ok(win32.hwnd.get() as HWND)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use gpui::{point, px, size};
|
||||
use windows_sys::{
|
||||
Win32::UI::WindowsAndMessaging::{
|
||||
CW_USEDEFAULT, CreateWindowExW, DefWindowProcW, DestroyWindow, DispatchMessageW, MSG,
|
||||
PM_REMOVE, PeekMessageW, SendMessageW, WS_EX_TOOLWINDOW, WS_EX_TOPMOST,
|
||||
WS_OVERLAPPEDWINDOW,
|
||||
},
|
||||
w,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A hidden window of `style`, at the size Windows picks, as gpui asks for its windows.
|
||||
fn window(style: u32, extended: u32) -> HWND {
|
||||
let window = unsafe {
|
||||
CreateWindowExW(
|
||||
extended,
|
||||
w!("STATIC"),
|
||||
w!(""),
|
||||
style,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
CW_USEDEFAULT,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
assert!(!window.is_null(), "a test window");
|
||||
window
|
||||
}
|
||||
|
||||
/// Hands her window the messages posted to it, as the message loop does.
|
||||
fn deliver(window: HWND) {
|
||||
let mut message = MSG::default();
|
||||
while unsafe { PeekMessageW(&mut message, window, 0, 0, PM_REMOVE) } != 0 {
|
||||
unsafe { DispatchMessageW(&message) };
|
||||
}
|
||||
}
|
||||
|
||||
fn info(window: HWND) -> WINDOWINFO {
|
||||
let mut info = WINDOWINFO {
|
||||
cbSize: size_of::<WINDOWINFO>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
assert_ne!(unsafe { GetWindowInfo(window, &mut info) }, 0, "its info");
|
||||
info
|
||||
}
|
||||
|
||||
/// gpui's procedure as far as these tests need it: at a new DPI it takes the rect Windows suggests.
|
||||
unsafe extern "system" fn standin(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
if message == WM_DPICHANGED {
|
||||
let rect = unsafe { *(lparam as *const RECT) };
|
||||
let (width, height) = (rect.right - rect.left, rect.bottom - rect.top);
|
||||
let flags = SWP_NOZORDER | SWP_NOACTIVATE;
|
||||
let null = std::ptr::null_mut();
|
||||
unsafe { SetWindowPos(window, null, rect.left, rect.top, width, height, flags) };
|
||||
return 0;
|
||||
}
|
||||
unsafe { DefWindowProcW(window, message, wparam, lparam) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn her_bottom_stands_on_the_panes_top_edge_whatever_the_two_windows_dpi() {
|
||||
let client = RECT {
|
||||
left: 100,
|
||||
top: 20,
|
||||
right: 1300,
|
||||
bottom: 900,
|
||||
};
|
||||
let pane = Bounds::new(point(px(10.), px(8.)), size(px(800.), px(600.)));
|
||||
for (scale, dpi) in [(1.0, 96), (1.0, 144), (1.5, 96), (2.0, 120)] {
|
||||
let her = boxed(stand(client, scale, pane), dpi);
|
||||
let own = dpi as f32 / 96.0;
|
||||
let own_box = (
|
||||
(WIDTH * own).round() as i32,
|
||||
((HEAD + ROOM) * own).round() as i32,
|
||||
);
|
||||
let corner = (
|
||||
100 + ((10.0 + left(px(800.))) * scale).round() as i32,
|
||||
20 + (8.0 * scale).round() as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
(her.right - her.left, her.bottom - her.top),
|
||||
(her.left, her.bottom)
|
||||
),
|
||||
(own_box, corner),
|
||||
"the main window at {scale}, hers at {dpi} DPI"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two displays split at 0, `first` DPI before and `second` after, across or down, giving a box the DPI of the one under most of it, as Windows does.
|
||||
fn displays(down: bool, first: u32, second: u32) -> impl Fn(&RECT) -> anyhow::Result<u32> {
|
||||
move |rect: &RECT| {
|
||||
let (from, to) = if down {
|
||||
(rect.top, rect.bottom)
|
||||
} else {
|
||||
(rect.left, rect.right)
|
||||
};
|
||||
Ok(if to.min(0) - from > to - from.max(0) {
|
||||
first
|
||||
} else {
|
||||
second
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What reaches her window: a place, her bottom left corner, or a keep at a DPI.
|
||||
enum Sent {
|
||||
Place((i32, i32)),
|
||||
Keep(u32),
|
||||
}
|
||||
|
||||
/// Her window, its rect and DPI, once the queue `places` start drains on `dpi_of`'s displays: a move to a display of another DPI changes hers, gpui takes Windows' rect, scaled about its middle, and a keep joins the queue, as her procedure posts one; and how many changes it took.
|
||||
fn drain(
|
||||
mut window: (RECT, u32),
|
||||
places: &[(i32, i32)],
|
||||
dpi_of: &impl Fn(&RECT) -> anyhow::Result<u32>,
|
||||
) -> ((RECT, u32), usize) {
|
||||
let mut queue: VecDeque<Sent> = places.iter().map(|&at| Sent::Place(at)).collect();
|
||||
let (mut corner, mut changes) = (None, 0);
|
||||
while let Some(sent) = queue.pop_front() {
|
||||
let target = match sent {
|
||||
Sent::Place(at) => {
|
||||
corner = Some(at);
|
||||
boxed(at, window.1)
|
||||
}
|
||||
Sent::Keep(dpi) => {
|
||||
let corner = corner.expect("a place first");
|
||||
match keeping(corner, dpi, window.1, dpi_of).expect("a display") {
|
||||
Some(kept) => kept,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
};
|
||||
let now = dpi_of(&target).expect("a display");
|
||||
if now == window.1 {
|
||||
window = (target, now);
|
||||
continue;
|
||||
}
|
||||
changes += 1;
|
||||
assert!(changes < 8, "her DPI goes back and forth");
|
||||
let grow = |from: i32, to: i32| {
|
||||
let length = ((to - from) as f64 * now as f64 / window.1 as f64).round() as i32;
|
||||
let start = (from + to - length).div_euclid(2);
|
||||
(start, start + length)
|
||||
};
|
||||
let ((left, right), (top, bottom)) = (
|
||||
grow(target.left, target.right),
|
||||
grow(target.top, target.bottom),
|
||||
);
|
||||
window = (
|
||||
RECT {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
},
|
||||
now,
|
||||
);
|
||||
queue.push_back(Sent::Keep(now));
|
||||
}
|
||||
(window, changes)
|
||||
}
|
||||
|
||||
fn rect(left: i32, top: i32, right: i32, bottom: i32) -> RECT {
|
||||
RECT {
|
||||
left,
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placing_her_across_two_displays_of_two_dpis_settles() {
|
||||
// Side by side, 144 DPI then 96: her 144 DPI box, 70 pixels on the first and 98 on the second.
|
||||
let across = displays(false, 144, 96);
|
||||
let start = (rect(-400, 315, -232, 500), 144);
|
||||
let ((at, dpi), changes) = drain(start, &[(-70, 500)], &across);
|
||||
assert_eq!(
|
||||
(changes, dpi, across(&at).expect("a display")),
|
||||
(1, 96, 96),
|
||||
"Windows' rect stands where her corner would set her DPI back"
|
||||
);
|
||||
// One over the other, 144 DPI above 96: her 96 DPI box, her bottom 50 pixels down the second.
|
||||
let down = displays(true, 144, 96);
|
||||
let start = (rect(200, 300, 312, 423), 96);
|
||||
let ((at, dpi), changes) = drain(start, &[(200, 50)], &down);
|
||||
assert_eq!(
|
||||
(changes, dpi, at.left, at.bottom, at.bottom - at.top),
|
||||
(1, 144, 200, 50, 185),
|
||||
"her corner kept on the edge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn places_that_pile_up_across_two_displays_drain_in_her_dpis_size() {
|
||||
let sizes = |(at, dpi): (RECT, u32)| {
|
||||
let drawn = (at.right - at.left, at.bottom - at.top);
|
||||
(drawn, sized(dpi as f32 / 96.0))
|
||||
};
|
||||
// Side by side, 96 DPI then 144; two places wait before either runs, as round 3 set them.
|
||||
let across = displays(false, 96, 144);
|
||||
let start = (rect(-400, 377, -288, 500), 96);
|
||||
let places = [(0, 500), (-70, 500)];
|
||||
let (window, _) = drain(start, &places, &across);
|
||||
let (drawn, own) = sizes(window);
|
||||
assert_eq!(
|
||||
(drawn, window.0.left, window.0.bottom),
|
||||
(own, -70, 500),
|
||||
"a keep whose DPI has passed moves her nowhere; she stands on the last corner"
|
||||
);
|
||||
// Side by side, 144 DPI then 96: the first takes her to 144, and the second lands there.
|
||||
let across = displays(false, 144, 96);
|
||||
let start = (rect(300, 377, 412, 500), 96);
|
||||
let places = [(-100, 500), (-60, 500)];
|
||||
let (drawn, own) = sizes(drain(start, &places, &across).0);
|
||||
assert_eq!(
|
||||
drawn, own,
|
||||
"her size is her DPI's, whatever DPI a place was posted at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edge() {
|
||||
let main = window(WS_OVERLAPPEDWINDOW, 0);
|
||||
// gpui's popup: style 0, an overlapped window, which Windows gives a caption.
|
||||
let pet = window(0, WS_EX_TOOLWINDOW | WS_EX_TOPMOST);
|
||||
assert_eq!(
|
||||
info(pet).dwStyle & WS_CAPTION,
|
||||
WS_CAPTION,
|
||||
"framed as gpui makes it"
|
||||
);
|
||||
unsafe { SetWindowLongPtrW(pet, GWLP_WNDPROC, standin as *const () as isize) };
|
||||
let glue = Glue::hold(pet, main).expect("held");
|
||||
let pane = Bounds::new(point(px(0.), px(0.)), size(px(380.), px(280.)));
|
||||
glue.put(1.0, pane).expect("placed");
|
||||
let made = info(pet).rcWindow;
|
||||
deliver(pet);
|
||||
let (her, under) = (info(pet), info(main));
|
||||
let own = unsafe { GetDpiForWindow(pet) } as f32 / 96.0;
|
||||
// New DPIs, each with a rect Windows suggests 30 and 40 pixels off, 100 by 60.
|
||||
let display = display_dpi(&her.rcWindow).expect("the display under her");
|
||||
let change = |dpi: u32| {
|
||||
let at = info(pet).rcWindow;
|
||||
let suggested = RECT {
|
||||
left: at.left + 30,
|
||||
top: at.top + 40,
|
||||
right: at.left + 130,
|
||||
bottom: at.top + 100,
|
||||
};
|
||||
let pointer = &suggested as *const RECT as LPARAM;
|
||||
unsafe { SendMessageW(pet, WM_DPICHANGED, (dpi | dpi << 16) as WPARAM, pointer) };
|
||||
let taken = info(pet).rcWindow;
|
||||
deliver(pet);
|
||||
(suggested, taken, info(pet).rcWindow)
|
||||
};
|
||||
let (_, _, kept) = change(display);
|
||||
let (suggested, taken, stood) = change(display + 48);
|
||||
unsafe {
|
||||
DestroyWindow(pet);
|
||||
DestroyWindow(main);
|
||||
}
|
||||
let (outer, inner) = (her.rcWindow, her.rcClient);
|
||||
let frame = (
|
||||
inner.left - outer.left,
|
||||
inner.top - outer.top,
|
||||
outer.right - inner.right,
|
||||
outer.bottom - inner.bottom,
|
||||
);
|
||||
let drawn = (outer.right - outer.left, outer.bottom - outer.top);
|
||||
let own_box = (
|
||||
(WIDTH * own).round() as i32,
|
||||
((HEAD + ROOM) * own).round() as i32,
|
||||
);
|
||||
let edge = (
|
||||
under.rcClient.left + left(px(380.)).round() as i32,
|
||||
under.rcClient.top,
|
||||
);
|
||||
let rect = |rect: RECT| (rect.left, rect.top, rect.right, rect.bottom);
|
||||
assert_eq!(
|
||||
(
|
||||
her.dwStyle & WS_CAPTION,
|
||||
frame,
|
||||
drawn,
|
||||
(outer.left, outer.bottom),
|
||||
rect(kept)
|
||||
),
|
||||
(0, (0, 0, 0, 0), own_box, edge, rect(outer)),
|
||||
"bare, in her own box, her bottom on the pane's top edge, kept there at her display's DPI"
|
||||
);
|
||||
assert_eq!(
|
||||
(rect(taken), rect(stood)),
|
||||
(rect(suggested), rect(suggested)),
|
||||
"a keep at a DPI not her window's moves her nowhere: the rect gpui took from Windows stands"
|
||||
);
|
||||
assert_ne!(
|
||||
(made.right - made.left, made.bottom - made.top),
|
||||
own_box,
|
||||
"no size until her window takes the posted place"
|
||||
);
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
//! Ely's window on X11: gpui's popup, which no window manager places, put on the main window's top edge and just above it.
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use gpui::{Bounds, Pixels, Window};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use x11rb::{
|
||||
protocol::xproto::{ConfigureWindowAux, ConnectionExt as _, StackMode},
|
||||
rust_connection::RustConnection,
|
||||
};
|
||||
|
||||
use super::{HEAD, ROOM, left, pane};
|
||||
|
||||
/// Her X window, the main one, and a connection of her own to their server.
|
||||
pub struct Glue {
|
||||
connection: RustConnection,
|
||||
pet: u32,
|
||||
main: u32,
|
||||
}
|
||||
|
||||
impl Glue {
|
||||
pub fn new(pet: &Window, main: &Window) -> anyhow::Result<Self> {
|
||||
let (pet, main) = (native(pet)?, native(main)?);
|
||||
// gpui opens $DISPLAY too; a window's place belongs to whoever asks.
|
||||
let (connection, _) = x11rb::connect(None).context("pet: no X server")?;
|
||||
Ok(Self {
|
||||
connection,
|
||||
pet,
|
||||
main,
|
||||
})
|
||||
}
|
||||
|
||||
/// Stands her on the main window's pane, as far in as its width allows, stacked just above the main window's frame.
|
||||
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
|
||||
self.put(main.scale_factor(), pane(main))
|
||||
}
|
||||
|
||||
/// Places her at `scale` over `pane`, in the main window's points.
|
||||
fn put(&self, scale: f32, pane: Bounds<Pixels>) -> anyhow::Result<()> {
|
||||
let (x, y) = (f32::from(pane.origin.x), f32::from(pane.origin.y));
|
||||
let (root, frame) = self.frame()?;
|
||||
let at = (self.connection)
|
||||
.translate_coordinates(self.main, root, 0, 0)?
|
||||
.reply()
|
||||
.context("pet: the main window's place")?;
|
||||
let aux = ConfigureWindowAux::new()
|
||||
.x(i32::from(at.dst_x) + ((x + left(pane.size.width)) * scale).round() as i32)
|
||||
.y(i32::from(at.dst_y) + ((y - HEAD - ROOM) * scale).round() as i32)
|
||||
.sibling(frame)
|
||||
.stack_mode(StackMode::ABOVE);
|
||||
(self.connection)
|
||||
.configure_window(self.pet, &aux)?
|
||||
.check()
|
||||
.context("pet: the X server did not move her window")
|
||||
}
|
||||
|
||||
/// Maps her window while the main one shows, and unmaps it while that hides.
|
||||
pub fn shown(&self, visible: bool) -> anyhow::Result<()> {
|
||||
let cookie = if visible {
|
||||
self.connection.map_window(self.pet)?
|
||||
} else {
|
||||
self.connection.unmap_window(self.pet)?
|
||||
};
|
||||
cookie
|
||||
.check()
|
||||
.context("pet: her window did not map or unmap")
|
||||
}
|
||||
|
||||
/// The root, and the main window's top window under it: the frame a window manager put round it, or itself.
|
||||
fn frame(&self) -> anyhow::Result<(u32, u32)> {
|
||||
let mut window = self.main;
|
||||
loop {
|
||||
let tree = self.connection.query_tree(window)?.reply()?;
|
||||
if tree.parent == tree.root {
|
||||
return Ok((tree.root, window));
|
||||
}
|
||||
window = tree.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The X window gpui made for `window`.
|
||||
fn native(window: &Window) -> anyhow::Result<u32> {
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("pet: no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::Xcb(xcb) = handle.as_raw() else {
|
||||
bail!("pet: not an X11 window");
|
||||
};
|
||||
Ok(xcb.window.get())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection as _,
|
||||
protocol::xproto::{CreateWindowAux, MapState, WindowClass},
|
||||
};
|
||||
|
||||
use gpui::{point, px, size};
|
||||
|
||||
use super::*;
|
||||
use crate::testx11::Xvfb;
|
||||
|
||||
#[test]
|
||||
fn she_stands_on_the_pane_just_above_the_frame_and_hides_with_it() {
|
||||
let xvfb = Xvfb::start();
|
||||
let (connection, screen) = x11rb::connect(Some(&xvfb.display)).expect("the test connects");
|
||||
let root = connection.setup().roots[screen].root;
|
||||
let window =
|
||||
|parent, (x, y, width, height): (i16, i16, u16, u16), aux: &CreateWindowAux| {
|
||||
let id = connection.generate_id().expect("a window id");
|
||||
let class = WindowClass::INPUT_OUTPUT;
|
||||
(connection.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
id,
|
||||
parent,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
class,
|
||||
0,
|
||||
aux,
|
||||
))
|
||||
.expect("the request")
|
||||
.check()
|
||||
.expect("a window");
|
||||
connection
|
||||
.map_window(id)
|
||||
.expect("the request")
|
||||
.check()
|
||||
.expect("mapped");
|
||||
id
|
||||
};
|
||||
let plain = CreateWindowAux::new();
|
||||
let pet = window(
|
||||
root,
|
||||
(0, 0, 112, 124),
|
||||
&CreateWindowAux::new().override_redirect(1),
|
||||
);
|
||||
// A window manager's frame, its title bar 20 pixels tall, round the main window.
|
||||
let frame = window(root, (100, 80, 800, 620), &plain);
|
||||
let main = window(frame, (0, 20, 800, 600), &plain);
|
||||
window(root, (0, 0, 50, 50), &plain);
|
||||
let hers = x11rb::connect(Some(&xvfb.display)).expect("hers").0;
|
||||
let glue = Glue {
|
||||
connection: hers,
|
||||
pet,
|
||||
main,
|
||||
};
|
||||
let pane = Bounds::new(point(px(10.), px(10.)), size(px(380.), px(280.)));
|
||||
glue.put(2.0, pane).expect("she is placed");
|
||||
let check = x11rb::connect(Some(&xvfb.display))
|
||||
.expect("the check connects")
|
||||
.0;
|
||||
let at = check
|
||||
.get_geometry(pet)
|
||||
.expect("the request")
|
||||
.reply()
|
||||
.expect("her place");
|
||||
let stands = (
|
||||
100 + ((10.0 + left(px(380.))) * 2.0).round() as i32,
|
||||
100 + ((10.0 - HEAD - ROOM) * 2.0).round() as i32,
|
||||
);
|
||||
assert_eq!((i32::from(at.x), i32::from(at.y)), stands);
|
||||
let order = check
|
||||
.query_tree(root)
|
||||
.expect("the request")
|
||||
.reply()
|
||||
.expect("the stack")
|
||||
.children;
|
||||
let place = |id| {
|
||||
order
|
||||
.iter()
|
||||
.position(|&child| child == id)
|
||||
.expect("on the root")
|
||||
};
|
||||
assert_eq!(
|
||||
place(pet),
|
||||
place(frame) + 1,
|
||||
"just above the frame: {order:?}"
|
||||
);
|
||||
let mapped = |check: &x11rb::rust_connection::RustConnection| {
|
||||
check
|
||||
.get_window_attributes(pet)
|
||||
.expect("the request")
|
||||
.reply()
|
||||
.expect("her state")
|
||||
.map_state
|
||||
};
|
||||
glue.shown(false).expect("hidden");
|
||||
assert_eq!(mapped(&check), MapState::UNMAPPED);
|
||||
glue.shown(true).expect("shown");
|
||||
assert_ne!(mapped(&check), MapState::UNMAPPED);
|
||||
}
|
||||
}
|
||||
+308
-12
@@ -1,18 +1,25 @@
|
||||
use std::path::{MAIN_SEPARATOR, Path, PathBuf};
|
||||
use std::{
|
||||
path::{MAIN_SEPARATOR, Path, PathBuf},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
lists::ListItem,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
};
|
||||
use gpui::{App, Entity, Subscription};
|
||||
use gpui::{AnyElement, App, Entity, SharedString, Subscription, div, prelude::*, rems};
|
||||
|
||||
use crate::{
|
||||
term::TerminalView,
|
||||
term::{Kind, Record, Records, State, TerminalView},
|
||||
workspace::{TITLE, Workspace},
|
||||
};
|
||||
|
||||
pub mod orb;
|
||||
|
||||
use orb::Look;
|
||||
|
||||
/// A program on its own terminal, as the sessions column lists it.
|
||||
pub struct Session {
|
||||
pub terminal: Entity<TerminalView>,
|
||||
@@ -24,9 +31,36 @@ pub struct Session {
|
||||
pub reports: bool,
|
||||
/// A bell rang while it was hidden.
|
||||
pub bell: bool,
|
||||
/// It started waiting on the user while hidden.
|
||||
pub waiting: bool,
|
||||
/// The look its mark shows, and since when.
|
||||
pub look: Option<(Look, Instant)>,
|
||||
pub _events: Subscription,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The look its mark takes from its terminal now.
|
||||
pub fn looks(&self, cx: &App) -> Option<Look> {
|
||||
let view = self.terminal.read(cx);
|
||||
let failed = view.ended().is_some_and(|code| code != 0);
|
||||
look(view.records().shown().map(|(_, record)| record), failed)
|
||||
}
|
||||
|
||||
/// Notes the look its mark takes `now`; true while the mark moves.
|
||||
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
|
||||
let shows = self.looks(cx);
|
||||
if self.look.map(|(shown, _)| shown) != shows {
|
||||
self.look = shows.map(|shows| (shows, now));
|
||||
}
|
||||
self.look
|
||||
.is_some_and(|(look, since)| orb::moves(look, now.saturating_duration_since(since)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The most records hover lists, and the most characters it shows of each part.
|
||||
const LISTED: usize = 10;
|
||||
const SHORT: usize = 120;
|
||||
|
||||
/// `folder` as a row shows it: the home folder as `~`, on one line.
|
||||
pub fn shown(folder: &Path, home: Option<&Path>) -> String {
|
||||
let shown = match home.and_then(|home| folder.strip_prefix(home).ok()) {
|
||||
@@ -57,16 +91,18 @@ pub fn after_close(active: usize, closed: usize, left: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session `index`'s row: its title and folder, a bell while one rang unseen, a button that closes it.
|
||||
/// Session `index`'s row: its title and folder, the mark of its status, a bell while one rang unseen, a button that closes it.
|
||||
pub fn row(
|
||||
workspace: &Entity<Workspace>,
|
||||
index: usize,
|
||||
session: &Session,
|
||||
active: bool,
|
||||
home: Option<&Path>,
|
||||
now: Instant,
|
||||
cx: &App,
|
||||
) -> ListItem {
|
||||
let pid = session.terminal.read(cx).pid() as usize;
|
||||
let terminal = session.terminal.read(cx);
|
||||
let pid = terminal.pid() as usize;
|
||||
let title = if session.title.is_empty() {
|
||||
TITLE
|
||||
} else {
|
||||
@@ -76,7 +112,7 @@ pub fn row(
|
||||
let item = ListItem::new(("session", pid), title.to_string())
|
||||
.description(shown(&session.folder, home))
|
||||
.selected(active)
|
||||
.strong(session.bell)
|
||||
.strong(session.bell || session.waiting)
|
||||
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
|
||||
.trailing(
|
||||
IconButton::new(("close-session", pid), IconName::X)
|
||||
@@ -88,11 +124,158 @@ pub fn row(
|
||||
close.update(cx, |this, cx| this.close(index, window, cx))
|
||||
}),
|
||||
);
|
||||
if session.bell {
|
||||
item.leading(Icon::new(IconName::Bell).color(cx.theme().colors.warning))
|
||||
} else {
|
||||
item
|
||||
let mark = mark(session, now, cx);
|
||||
let bell = session
|
||||
.bell
|
||||
.then(|| Icon::new(IconName::Bell).color(cx.theme().colors.warning));
|
||||
if mark.is_none() && bell.is_none() {
|
||||
return item;
|
||||
}
|
||||
item.leading(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(mark)
|
||||
.children(bell),
|
||||
)
|
||||
}
|
||||
|
||||
/// What a mark shows: the most pressing record's state, else a failed last command.
|
||||
pub fn look(record: Option<&Record>, failed: bool) -> Option<Look> {
|
||||
Some(
|
||||
match (record.map(|record| (record.state, record.progress)), failed) {
|
||||
(Some((State::Blocked, _)), _) => Look::Waiting,
|
||||
(Some((State::Error, _)), _) => Look::Error,
|
||||
(Some((State::Working, progress)), _) => Look::Working(progress),
|
||||
(Some((State::Done, _)), _) => Look::Done,
|
||||
(_, true) => Look::Error,
|
||||
(Some((State::Idle, _)), false) => Look::Idle,
|
||||
(None, false) => return None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The session's mark: its look's dots in its state's color; hover lists the records.
|
||||
fn mark(session: &Session, now: Instant, cx: &App) -> Option<AnyElement> {
|
||||
let (look, since) = session.look?;
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let color = match look {
|
||||
Look::Working(_) => colors.info,
|
||||
Look::Waiting => colors.warning,
|
||||
Look::Error => colors.danger,
|
||||
Look::Done => colors.success,
|
||||
Look::Idle => colors.fg_muted,
|
||||
};
|
||||
let pid = session.terminal.read(cx).pid() as usize;
|
||||
// Hover reads the records as they stand, so an open one follows them, and scrolls within the window.
|
||||
let watched = session.terminal.downgrade();
|
||||
let tip = Tooltip::rich(move |window, cx| {
|
||||
let lines = watched.upgrade().map_or_else(Vec::new, |terminal| {
|
||||
let view = terminal.read(cx);
|
||||
hover(view.records(), view.ended().filter(|&code| code != 0))
|
||||
});
|
||||
let room = window.viewport_size().height - rems(3.).to_pixels(window.rem_size());
|
||||
div()
|
||||
.id("session-hover")
|
||||
.max_h(room)
|
||||
.overflow_y_scroll()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(lines)
|
||||
.into_any_element()
|
||||
});
|
||||
// Every mark takes a 24-pixel box, so the title stays put as the state changes.
|
||||
Some(
|
||||
div()
|
||||
.id(("session-status", pid))
|
||||
.size(theme.icon_size(IconSize::Xl))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.hoverable_tooltip(tip)
|
||||
.child(orb::lattice(look, color, now, since))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What hover shows: a line for each record, ten at most, then a failed command's status.
|
||||
pub fn hover(records: &Records, failed: Option<i32>) -> Vec<SharedString> {
|
||||
let mut lines: Vec<SharedString> = records
|
||||
.iter()
|
||||
.take(LISTED)
|
||||
.map(|(id, record)| line(records, id, record).into())
|
||||
.collect();
|
||||
let more = records.iter().count().saturating_sub(LISTED);
|
||||
if more > 0 {
|
||||
lines.push(format!("{more} more").into());
|
||||
}
|
||||
if let Some(code) = failed {
|
||||
lines.push(format!("Last command exited with {code}").into());
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// A record as hover and notifications say it: its state, then its app, title and message.
|
||||
pub fn line(records: &Records, id: &str, record: &Record) -> String {
|
||||
let parts = [
|
||||
records.app(id),
|
||||
record.title.as_deref(),
|
||||
record.msg.as_deref(),
|
||||
];
|
||||
let said: Vec<String> = parts.into_iter().flatten().map(plain).collect();
|
||||
let state = label(record);
|
||||
if said.is_empty() {
|
||||
state
|
||||
} else {
|
||||
format!("{state}: {}", said.join(" · "))
|
||||
}
|
||||
}
|
||||
|
||||
/// A record's state, as hover names it.
|
||||
fn label(record: &Record) -> String {
|
||||
match (record.state, record.kind, record.progress) {
|
||||
(State::Idle, ..) => "Idle".into(),
|
||||
(State::Working, _, Some(value)) => format!("Working, {value}%"),
|
||||
(State::Working, ..) => "Working".into(),
|
||||
(State::Blocked, Some(Kind::Permission), _) => "Waiting for permission".into(),
|
||||
(State::Blocked, Some(Kind::Question), _) => "Waiting for an answer".into(),
|
||||
(State::Blocked, Some(Kind::Auth), _) => "Waiting for a login".into(),
|
||||
(State::Blocked, None, _) => "Waiting".into(),
|
||||
(State::Done, ..) => "Done".into(),
|
||||
(State::Error, ..) => "Error".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A program's text as hover shows it: on one line, 120 characters at most, without what could disguise it.
|
||||
pub fn plain(text: &str) -> String {
|
||||
let kept: String = text.chars().filter(|&c| !invisible(c)).collect();
|
||||
let line = one_line(&kept);
|
||||
match line.char_indices().nth(SHORT) {
|
||||
Some((at, _)) => format!("{}…", &line[..at]),
|
||||
None => line,
|
||||
}
|
||||
}
|
||||
|
||||
/// C1 controls, direction controls and invisible formatting; joiners stay, as scripts and emoji need them.
|
||||
fn invisible(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{80}'..='\u{9f}'
|
||||
| '\u{ad}'
|
||||
| '\u{61c}'
|
||||
| '\u{180e}'
|
||||
| '\u{200b}'
|
||||
| '\u{200e}'
|
||||
| '\u{200f}'
|
||||
| '\u{202a}'..='\u{202e}'
|
||||
| '\u{2060}'..='\u{2064}'
|
||||
| '\u{2066}'..='\u{2069}'
|
||||
| '\u{feff}'
|
||||
| '\u{fff9}'..='\u{fffb}'
|
||||
| '\u{e0000}'..='\u{e007f}'
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -133,4 +316,117 @@ mod tests {
|
||||
assert_eq!(after_close(3, 3, 3), 2, "the last went: the one before");
|
||||
assert_eq!(after_close(1, 2, 3), 1, "one after it went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mark_shows_the_most_pressing_look() {
|
||||
let record = |state, progress| Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress,
|
||||
app: None,
|
||||
title: None,
|
||||
msg: None,
|
||||
};
|
||||
let waiting = look(Some(&record(State::Blocked, None)), true);
|
||||
assert_eq!(
|
||||
waiting,
|
||||
Some(Look::Waiting),
|
||||
"waiting outranks a failed command"
|
||||
);
|
||||
let working = look(Some(&record(State::Working, Some(40))), false);
|
||||
assert_eq!(working, Some(Look::Working(Some(40))));
|
||||
let idle = record(State::Idle, None);
|
||||
assert_eq!(
|
||||
look(Some(&idle), true),
|
||||
Some(Look::Error),
|
||||
"a failed command outranks idle"
|
||||
);
|
||||
assert_eq!(look(Some(&idle), false), Some(Look::Idle));
|
||||
assert_eq!(
|
||||
look(Some(&record(State::Done, None)), true),
|
||||
Some(Look::Done)
|
||||
);
|
||||
assert_eq!(
|
||||
look(Some(&record(State::Error, None)), false),
|
||||
Some(Look::Error)
|
||||
);
|
||||
assert_eq!(look(None, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_shows_a_program_s_text_as_text() {
|
||||
assert_eq!(plain("evil\u{202e}txt.exe"), "eviltxt.exe");
|
||||
assert_eq!(
|
||||
plain("a\u{200b}b\u{feff}c\u{e0041}d\u{85}e\u{2066}f"),
|
||||
"abcdef"
|
||||
);
|
||||
assert_eq!(
|
||||
plain("👨\u{200d}💻 two\nlines"),
|
||||
"👨\u{200d}💻 two␊lines",
|
||||
"joiners stay"
|
||||
);
|
||||
assert_eq!(plain(&"x".repeat(SHORT)), "x".repeat(SHORT));
|
||||
assert_eq!(
|
||||
plain(&"é".repeat(SHORT + 1)),
|
||||
format!("{}…", "é".repeat(SHORT))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_lists_each_record_with_its_app_then_a_failed_command() {
|
||||
let record =
|
||||
|state: State, kind: Option<Kind>, progress: Option<u8>, texts: [Option<&str>; 3]| {
|
||||
let [app, title, msg] = texts.map(|text| text.map(String::from));
|
||||
Record {
|
||||
state,
|
||||
kind,
|
||||
progress,
|
||||
app,
|
||||
title,
|
||||
msg,
|
||||
}
|
||||
};
|
||||
let mut records = Records::default();
|
||||
records.report(
|
||||
String::new(),
|
||||
record(
|
||||
State::Working,
|
||||
None,
|
||||
Some(40),
|
||||
[Some("deploy"), None, Some("Deploying")],
|
||||
),
|
||||
);
|
||||
records.report(
|
||||
"eu".into(),
|
||||
record(
|
||||
State::Blocked,
|
||||
Some(Kind::Permission),
|
||||
None,
|
||||
[None, Some("EU West"), Some("Approve?")],
|
||||
),
|
||||
);
|
||||
records.report(
|
||||
"eu/db".into(),
|
||||
record(State::Done, None, None, [Some("db"), None, None]),
|
||||
);
|
||||
let lines: Vec<String> = hover(&records, Some(2))
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
"Working, 40%: deploy · Deploying",
|
||||
"Waiting for permission: deploy · EU West · Approve?",
|
||||
"Done: db",
|
||||
"Last command exited with 2"
|
||||
]
|
||||
);
|
||||
for at in 0..LISTED {
|
||||
records.report(format!("r{at}"), record(State::Idle, None, None, [None; 3]));
|
||||
}
|
||||
let lines = hover(&records, None);
|
||||
assert_eq!(lines.len(), LISTED + 1);
|
||||
assert_eq!(lines[LISTED].to_string(), "3 more");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! A session's status mark: 3 by 3 dots in its state's color, every mark stepping on one clock.
|
||||
|
||||
use std::{
|
||||
cmp::Ordering,
|
||||
f64::consts::TAU,
|
||||
sync::OnceLock,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gpui::{Div, Hsla, ParentElement, Styled, div};
|
||||
|
||||
/// The dots' beat: while a mark moves, the window draws at each step, not at every frame.
|
||||
pub const STEP: Duration = Duration::from_millis(80);
|
||||
|
||||
/// What a mark shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Look {
|
||||
/// A ripple from the centre; with progress, the outer ring fills clockwise from the top.
|
||||
Working(Option<u8>),
|
||||
/// Lit, with two quick dips.
|
||||
Waiting,
|
||||
/// A program's error, or a failed last command.
|
||||
Error,
|
||||
Done,
|
||||
Idle,
|
||||
}
|
||||
|
||||
/// One ripple, in seconds, and how far a dot one dot out from the centre lags it.
|
||||
const RIPPLE: f64 = 1.2;
|
||||
const LAG: f64 = 0.3;
|
||||
/// Steps in one round of a waiting mark's dips.
|
||||
const BLINK: u64 = 20;
|
||||
/// A working dot at its lowest, a waiting mark in a dip, and a dot outside a settled shape.
|
||||
const DIM: f32 = 0.35;
|
||||
const DIP: f32 = 0.25;
|
||||
const FAINT: f32 = 0.12;
|
||||
/// Steps a settled shape's dot takes to light, and between one dot and the next.
|
||||
const FADE: u64 = 2;
|
||||
const STAGGER: u64 = 1;
|
||||
/// The outer ring clockwise from the top, then the centre: the order progress fills.
|
||||
const FILL: [usize; 9] = [1, 2, 5, 8, 7, 6, 3, 0, 4];
|
||||
|
||||
/// A settled look's dots in the order they light, and how bright they end; none for a look that keeps moving.
|
||||
fn shape(look: Look) -> Option<(&'static [usize], f32)> {
|
||||
match look {
|
||||
Look::Working(_) | Look::Waiting => None,
|
||||
Look::Error => Some((&[0, 4, 8, 2, 6], 1.0)),
|
||||
Look::Done => Some((&[3, 7, 5, 2], 1.0)),
|
||||
Look::Idle => Some((&FILL[..8], 0.6)),
|
||||
}
|
||||
}
|
||||
|
||||
fn steps(time: Duration) -> u64 {
|
||||
(time.as_millis() / STEP.as_millis()) as u64
|
||||
}
|
||||
|
||||
/// A dot's brightness `t` seconds into its ripple.
|
||||
fn crest(t: f64) -> f32 {
|
||||
let wave = 0.5 + 0.5 * (TAU * t / RIPPLE).cos();
|
||||
DIM + (1.0 - DIM) * wave as f32
|
||||
}
|
||||
|
||||
/// Each dot's opacity, row by row, `clock` on the marks' clock and `held` after the look began.
|
||||
pub fn dots(look: Look, clock: Duration, held: Duration) -> [f32; 9] {
|
||||
let t = steps(clock) as f64 * STEP.as_secs_f64();
|
||||
let mut levels = [DIM; 9];
|
||||
match look {
|
||||
Look::Working(None) => {
|
||||
for (dot, level) in levels.iter_mut().enumerate() {
|
||||
let (x, y) = ((dot % 3) as f64 - 1.0, (dot / 3) as f64 - 1.0);
|
||||
*level = crest(t - x.hypot(y) * LAG);
|
||||
}
|
||||
}
|
||||
Look::Working(Some(progress)) => {
|
||||
let filled = (usize::from(progress) * FILL.len() / 100).min(FILL.len() - 1);
|
||||
for (rank, &dot) in FILL.iter().enumerate() {
|
||||
levels[dot] = match rank.cmp(&filled) {
|
||||
Ordering::Less => 1.0,
|
||||
Ordering::Equal => crest(t),
|
||||
Ordering::Greater => DIM,
|
||||
};
|
||||
}
|
||||
}
|
||||
Look::Waiting => {
|
||||
let dip = matches!(steps(clock) % BLINK, 0 | 1 | 4 | 5);
|
||||
levels = [if dip { DIP } else { 1.0 }; 9];
|
||||
}
|
||||
settled => {
|
||||
let (shape, bright) = shape(settled).expect("a settled look has a shape");
|
||||
levels = [FAINT; 9];
|
||||
let held = steps(held);
|
||||
for (rank, &dot) in shape.iter().enumerate() {
|
||||
let lit = held.saturating_sub(rank as u64 * STAGGER).min(FADE) as f32 / FADE as f32;
|
||||
levels[dot] = bright * lit + FAINT * (1.0 - lit);
|
||||
}
|
||||
}
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
/// Whether a mark still changes `held` after its look began: work and waiting always, a settled shape until it is drawn.
|
||||
pub fn moves(look: Look, held: Duration) -> bool {
|
||||
shape(look).is_none_or(|(shape, _)| steps(held) < (shape.len() as u64 - 1) * STAGGER + FADE)
|
||||
}
|
||||
|
||||
/// Time on the clock every mark steps to, so marks move together.
|
||||
fn clock(now: Instant) -> Duration {
|
||||
static START: OnceLock<Instant> = OnceLock::new();
|
||||
now.saturating_duration_since(*START.get_or_init(|| now))
|
||||
}
|
||||
|
||||
/// How long until the clock's next step, a little past it.
|
||||
pub fn wait(now: Instant) -> Duration {
|
||||
let into = clock(now).as_nanos() % STEP.as_nanos();
|
||||
STEP - Duration::from_nanos(into as u64) + Duration::from_millis(2)
|
||||
}
|
||||
|
||||
/// The mark's dots in `color`, `now`, its look shown since `since`.
|
||||
pub fn lattice(look: Look, color: Hsla, now: Instant, since: Instant) -> Div {
|
||||
let levels = dots(look, clock(now), now.saturating_duration_since(since));
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(3)
|
||||
.gap_0p5()
|
||||
.children(levels.map(|level| div().size_1().rounded_full().bg(color.opacity(level))))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(steps: u32) -> Duration {
|
||||
STEP * steps
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_ripples_from_the_centre_to_the_edges_then_the_corners() {
|
||||
let levels = |steps| dots(Look::Working(None), at(steps), Duration::ZERO);
|
||||
let peak = |dot: usize| {
|
||||
(0..15)
|
||||
.max_by(|&a, &b| levels(a)[dot].total_cmp(&levels(b)[dot]))
|
||||
.expect("steps")
|
||||
};
|
||||
let (centre, edge, corner) = (peak(4), peak(1), peak(0));
|
||||
assert_eq!(centre, 0);
|
||||
assert!(centre < edge && edge < corner, "{centre} {edge} {corner}");
|
||||
assert_eq!(peak(3), edge, "every edge together");
|
||||
assert_eq!(peak(8), corner, "every corner together");
|
||||
let again = levels(15);
|
||||
assert!(
|
||||
levels(0)
|
||||
.iter()
|
||||
.zip(again)
|
||||
.all(|(a, b)| (a - b).abs() < 1e-4),
|
||||
"one ripple every 15 steps"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_fills_the_ring_clockwise_from_the_top_then_the_centre() {
|
||||
let lit = |progress, steps| dots(Look::Working(Some(progress)), at(steps), Duration::ZERO);
|
||||
let half = lit(50, 9);
|
||||
assert!([1, 2, 5, 8].iter().all(|&dot| half[dot] == 1.0), "{half:?}");
|
||||
assert!([6, 3, 0, 4].iter().all(|&dot| half[dot] == DIM), "{half:?}");
|
||||
assert!(lit(50, 0)[7] > half[7], "the next dot pulses");
|
||||
let full = lit(100, 9);
|
||||
assert!(FILL[..8].iter().all(|&dot| full[dot] == 1.0), "{full:?}");
|
||||
assert!(lit(100, 0)[4] > full[4], "then the centre pulses");
|
||||
assert!(lit(0, 0)[1] > lit(0, 9)[1], "at 0% the top pulses");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_stays_lit_and_dips_twice() {
|
||||
let blink = |steps| dots(Look::Waiting, at(steps), Duration::ZERO);
|
||||
let dips: Vec<u32> = (0..20).filter(|&steps| blink(steps)[0] == DIP).collect();
|
||||
assert_eq!(dips, [0, 1, 4, 5]);
|
||||
assert!((0..20).all(|steps| blink(steps).iter().all(|&level| level == blink(steps)[0])));
|
||||
assert_eq!(blink(2), [1.0; 9]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settled_look_draws_its_shape_dot_by_dot_then_holds() {
|
||||
let done = |steps| dots(Look::Done, at(1000), at(steps));
|
||||
assert_eq!(done(0), [FAINT; 9], "nothing lit at first");
|
||||
assert!(
|
||||
done(1)[3] > FAINT && done(1)[2] == FAINT,
|
||||
"the check draws from its left end"
|
||||
);
|
||||
let check = [3, 7, 5, 2];
|
||||
let drawn = done(5);
|
||||
assert!((0..9).all(|dot| drawn[dot] == if check.contains(&dot) { 1.0 } else { FAINT }));
|
||||
assert_eq!(done(500), drawn, "then it holds still");
|
||||
assert!(moves(Look::Done, at(4)) && !moves(Look::Done, at(5)));
|
||||
let idle = dots(Look::Idle, Duration::ZERO, at(9));
|
||||
assert_eq!(idle[4], FAINT, "idle is a ring");
|
||||
assert!(FILL[..8].iter().all(|&dot| idle[dot] == 0.6));
|
||||
assert!(!moves(Look::Idle, at(9)) && moves(Look::Idle, at(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn work_and_waiting_never_stop() {
|
||||
for look in [Look::Working(None), Look::Working(Some(40)), Look::Waiting] {
|
||||
assert!(moves(look, at(1_000_000)), "{look:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
-5
@@ -72,9 +72,17 @@ pub struct Settings {
|
||||
pub scrollback: u32,
|
||||
pub option_as_alt: OptionAsAlt,
|
||||
pub copy_on_select: bool,
|
||||
/// Ely peeks over the main window's top edge.
|
||||
pub ely: bool,
|
||||
pub show_sessions: bool,
|
||||
pub show_panel: bool,
|
||||
pub panel: PanelTab,
|
||||
/// The sessions and right columns' widths, in pixels, as their borders were dragged.
|
||||
pub sessions_width: f32,
|
||||
pub panel_width: f32,
|
||||
/// The main window's size, unmaximized, in pixels.
|
||||
pub window_width: f32,
|
||||
pub window_height: f32,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@@ -82,7 +90,7 @@ impl Default for Settings {
|
||||
Self {
|
||||
shell: String::new(),
|
||||
start_in: StartIn::Home,
|
||||
theme: "catppuccin-mocha".into(),
|
||||
theme: themes::default_id().into(),
|
||||
dark_theme: None,
|
||||
font_family: "JetBrains Mono".into(),
|
||||
font_size: 13.0,
|
||||
@@ -97,9 +105,14 @@ impl Default for Settings {
|
||||
scrollback: 10_000,
|
||||
option_as_alt: OptionAsAlt::Off,
|
||||
copy_on_select: false,
|
||||
ely: true,
|
||||
show_sessions: true,
|
||||
show_panel: true,
|
||||
panel: PanelTab::Git,
|
||||
sessions_width: 240.0,
|
||||
panel_width: 240.0,
|
||||
window_width: 1200.0,
|
||||
window_height: 760.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +122,8 @@ pub const LINE_HEIGHT: RangeInclusive<f32> = 1.0..=2.0;
|
||||
pub const OPACITY: RangeInclusive<f32> = 0.3..=1.0;
|
||||
pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
||||
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||
pub const COLUMN: RangeInclusive<f32> = 160.0..=960.0;
|
||||
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
||||
|
||||
impl Settings {
|
||||
/// Every value inside its range, every theme built in; the error names the field.
|
||||
@@ -119,15 +134,16 @@ impl Settings {
|
||||
within("padding_x", self.padding_x, &PADDING)?;
|
||||
within("padding_y", self.padding_y, &PADDING)?;
|
||||
within("scrollback", self.scrollback, &SCROLLBACK)?;
|
||||
within("sessions_width", self.sessions_width, &COLUMN)?;
|
||||
within("panel_width", self.panel_width, &COLUMN)?;
|
||||
within("window_width", self.window_width, &WINDOW)?;
|
||||
within("window_height", self.window_height, &WINDOW)?;
|
||||
for (field, id) in [
|
||||
("theme", Some(&self.theme)),
|
||||
("dark_theme", self.dark_theme.as_ref()),
|
||||
] {
|
||||
if let Some(id) = id {
|
||||
anyhow::ensure!(
|
||||
themes::find(id).is_some(),
|
||||
"{field} is {id:?}, no built-in theme"
|
||||
);
|
||||
anyhow::ensure!(themes::known(id), "{field} is {id:?}, no built-in theme");
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||
@@ -228,6 +244,21 @@ mod tests {
|
||||
error.to_string(),
|
||||
"scrollback is 100001, outside 0 to 100000"
|
||||
);
|
||||
let settings = Settings {
|
||||
window_width: 50.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let error = settings.validate().expect_err("too narrow");
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"window_width is 50, outside 100 to 16384"
|
||||
);
|
||||
let settings = Settings {
|
||||
panel_width: 1000.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let error = settings.validate().expect_err("too wide");
|
||||
assert_eq!(error.to_string(), "panel_width is 1000, outside 160 to 960");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -103,7 +103,7 @@ impl SettingsWindow {
|
||||
settings.cursor_style,
|
||||
|settings, style| settings.cursor_style = style,
|
||||
);
|
||||
let active = themes::active(settings, window.appearance());
|
||||
let active = themes::active(settings, window.appearance(), cx);
|
||||
let lit = self.lit || !settings.cursor_blink;
|
||||
let cursor = SettingsSection::new("Cursor")
|
||||
.row(SettingsRow::new("Style").control(style))
|
||||
@@ -113,7 +113,7 @@ impl SettingsWindow {
|
||||
settings.cursor_blink,
|
||||
|settings, on| settings.cursor_blink = on,
|
||||
)))
|
||||
.row(SettingsRow::new("Preview").control(prompt(settings, active, lit, cx)));
|
||||
.row(SettingsRow::new("Preview").control(prompt(settings, &active, lit, cx)));
|
||||
let opacity = percent(settings.background_opacity);
|
||||
let slider = Slider::new("background_opacity", opacity)
|
||||
.range(percent(*OPACITY.start()), percent(*OPACITY.end()))
|
||||
@@ -207,7 +207,7 @@ fn fraction(percent: f64) -> f32 {
|
||||
(percent / 100.) as f32
|
||||
}
|
||||
|
||||
/// Every built-in theme as a card over its preview; `dark` picks the one a dark system wears.
|
||||
/// Omarchy's theme where followed, then every built-in, as cards; `dark` picks the dark system's.
|
||||
fn grid(
|
||||
this: &Entity<SettingsWindow>,
|
||||
title: Option<&'static str>,
|
||||
@@ -222,7 +222,8 @@ fn grid(
|
||||
} else {
|
||||
("theme", Some(settings.theme.as_str()))
|
||||
};
|
||||
let cards = themes::all().iter().map(|each| {
|
||||
let omarchy = cx.try_global::<themes::Omarchy>().map(|omarchy| &omarchy.0);
|
||||
let cards = omarchy.into_iter().chain(themes::all()).map(|each| {
|
||||
let (id, this) = (each.id, this.clone());
|
||||
let key = SharedString::from(format!("{field}-{id}"));
|
||||
RadioCard::new(key, chosen == Some(id), each.name)
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::{OpenSettings, SettingsWindow, choice, set, switch};
|
||||
use crate::{
|
||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||
term::input::{self, Find},
|
||||
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
workspace::{
|
||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||
TogglePanel, ToggleSessions,
|
||||
@@ -99,7 +99,14 @@ impl SettingsWindow {
|
||||
settings.copy_on_select,
|
||||
|settings, on| settings.copy_on_select = on,
|
||||
);
|
||||
let section = section.row(SettingsRow::new("Copy on select").control(copy));
|
||||
let ely = switch(&this, "ely", settings.ely, |settings, on| settings.ely = on);
|
||||
let section = section
|
||||
.row(SettingsRow::new("Copy on select").control(copy))
|
||||
.row(
|
||||
SettingsRow::new("Ely")
|
||||
.description("Peeks over the window, her face as your sessions are")
|
||||
.control(ely),
|
||||
);
|
||||
let keys = SettingsSection::new("Shortcuts")
|
||||
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
||||
div().flex().flex_col().gap_6().child(section).child(keys)
|
||||
@@ -141,6 +148,8 @@ fn listed() -> Vec<(&'static str, &'static str, Box<dyn Action>)> {
|
||||
("Edit", "Paste", Paste.boxed_clone()),
|
||||
("Edit", "Select all", SelectAll.boxed_clone()),
|
||||
("Edit", "Find", Find.boxed_clone()),
|
||||
("View", "Previous prompt", PreviousPrompt.boxed_clone()),
|
||||
("View", "Next prompt", NextPrompt.boxed_clone()),
|
||||
("View", "Toggle sessions", ToggleSessions.boxed_clone()),
|
||||
("View", "Toggle right panel", TogglePanel.boxed_clone()),
|
||||
("View", "Bigger", Bigger.boxed_clone()),
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
io::{self, BufRead, BufReader, Write},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::{Context, Task};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{
|
||||
status::{HERDR, Record, State},
|
||||
view::{TerminalEvent, TerminalView},
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
#[cfg(unix)]
|
||||
use unix::{Program, client, connect, home, leader, running};
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
#[cfg(windows)]
|
||||
use windows::{Program, client, connect, home, leader, running};
|
||||
|
||||
/// How often the session's foreground program is checked.
|
||||
const CHECK: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How long herdr may take to answer.
|
||||
const WAIT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The socket request for herdr's session: its focus, its panes' folders and its agents.
|
||||
const SNAPSHOT: &[u8] = b"{\"id\":\"elyterminal\",\"method\":\"session.snapshot\",\"params\":{}}\n";
|
||||
|
||||
/// A program's arguments, and its variables as the system holds them.
|
||||
type Invocation = (Vec<String>, HashMap<String, OsString>);
|
||||
|
||||
/// Follows the pane herdr's server has focused while herdr's client runs in the session.
|
||||
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
let mut seen: Option<Program> = None;
|
||||
let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new());
|
||||
let mut states: Vec<(String, State)> = Vec::new();
|
||||
loop {
|
||||
cx.background_executor().timer(CHECK).await;
|
||||
let Ok((pid, lead, reports)) = view.update(cx, |view, _| {
|
||||
(view.pty.pid, leader(&view.pty), view.reports)
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
let program = match lead {
|
||||
Ok(lead) => {
|
||||
cx.background_executor()
|
||||
.spawn(async move { running(lead) })
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let program = match program {
|
||||
Ok(program) => Some(program),
|
||||
Err(error) => {
|
||||
if seen.is_some() {
|
||||
log::debug!("terminal {pid}: its foreground does not read: {error}");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
if program != seen {
|
||||
seen = program.clone();
|
||||
shown = None;
|
||||
retry = true;
|
||||
failed.clear();
|
||||
if socket.take().is_some() {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
||||
);
|
||||
states.clear();
|
||||
let left = view.update(cx, |view, cx| {
|
||||
view.herdr = false;
|
||||
view.agents(&[], cx);
|
||||
});
|
||||
if left.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if retry {
|
||||
let found = match program {
|
||||
Some(program) => {
|
||||
cx.background_executor()
|
||||
.spawn(async move { client(program) })
|
||||
.await
|
||||
}
|
||||
None => Ok(None),
|
||||
};
|
||||
retry = found.is_err();
|
||||
socket = found.unwrap_or_else(|error| {
|
||||
failure(&mut failed, pid, &error);
|
||||
None
|
||||
});
|
||||
if let Some(socket) = &socket {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr runs here, its server at {}",
|
||||
socket.display()
|
||||
);
|
||||
}
|
||||
let runs = socket.is_some();
|
||||
if view.update(cx, |view, _| view.herdr = runs).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(path) = socket.clone() else {
|
||||
continue;
|
||||
};
|
||||
let (folder, agents) = match cx
|
||||
.background_executor()
|
||||
.spawn(async move { snapshot(&path) })
|
||||
.await
|
||||
{
|
||||
Ok(shows) => shows,
|
||||
Err(error) => {
|
||||
failure(&mut failed, pid, &error);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
failed.clear();
|
||||
let report = folder.filter(|folder| shown.as_ref() != Some(folder));
|
||||
// The shell reports only once herdr has left; its folder then stands.
|
||||
let Ok(still) = view.update(cx, |view, cx| {
|
||||
let still = view.reports == reports;
|
||||
if still {
|
||||
view.agents(&agents, cx);
|
||||
if let Some(folder) = report.clone() {
|
||||
cx.emit(TerminalEvent::Folder(folder));
|
||||
}
|
||||
}
|
||||
still
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if !still {
|
||||
continue;
|
||||
}
|
||||
let now: Vec<(String, State)> = agents
|
||||
.into_iter()
|
||||
.map(|(id, record)| (id, record.state))
|
||||
.collect();
|
||||
if now != states {
|
||||
log::info!("terminal {pid}: herdr's agents are {now:?}");
|
||||
states = now;
|
||||
}
|
||||
if let Some(folder) = report {
|
||||
log::info!(
|
||||
"terminal {pid}: herdr's focused pane is in {}",
|
||||
folder.display()
|
||||
);
|
||||
shown = Some(folder);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Logs `error` once while it lasts; a server not up yet is no error.
|
||||
fn failure(failed: &mut String, pid: u32, error: &anyhow::Error) {
|
||||
let text = format!("{error:#}");
|
||||
if text != *failed {
|
||||
let level = if starting(error) {
|
||||
log::Level::Info
|
||||
} else {
|
||||
log::Level::Error
|
||||
};
|
||||
log::log!(level, "terminal {pid}: {text}; the panel keeps its folder");
|
||||
*failed = text;
|
||||
}
|
||||
}
|
||||
|
||||
/// herdr's client runs before its server listens: no socket yet, or none answering.
|
||||
fn starting(error: &anyhow::Error) -> bool {
|
||||
error.downcast_ref::<io::Error>().is_some_and(|error| {
|
||||
matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The socket a herdr process with `args` and `env` shows, if it is herdr's terminal client.
|
||||
pub(super) fn follows(
|
||||
args: &[String],
|
||||
env: &HashMap<String, OsString>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
if !attaches(args) {
|
||||
return Ok(None);
|
||||
}
|
||||
let remote = |arg: &String| {
|
||||
["--remote", "--machine"]
|
||||
.iter()
|
||||
.any(|flag| arg == flag || arg.starts_with(&format!("{flag}=")))
|
||||
};
|
||||
anyhow::ensure!(
|
||||
!args.iter().any(remote),
|
||||
"herdr: its client shows another machine's server, whose folders cannot show here"
|
||||
);
|
||||
socket(args, env).map(Some)
|
||||
}
|
||||
|
||||
/// Whether `args` start herdr's terminal client, not one of its commands.
|
||||
fn attaches(args: &[String]) -> bool {
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
match arg.as_str() {
|
||||
"--session" | "--remote" | "--machine" | "--remote-keybindings" => {
|
||||
rest.next();
|
||||
}
|
||||
"--version" | "-V" | "--help" | "-h" | "--default-config" | "--skill" => return false,
|
||||
"client" => return true,
|
||||
"session" => return rest.next().is_some_and(|next| next == "attach"),
|
||||
flag if flag.starts_with('-') => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The socket herdr 0.9's client finds: a named session, `HERDR_SOCKET_PATH`, `HERDR_SESSION`, the default.
|
||||
fn socket(args: &[String], env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let dir = match env.get("XDG_CONFIG_HOME") {
|
||||
Some(config) => PathBuf::from(config).join("herdr"),
|
||||
None => home(env)?,
|
||||
};
|
||||
let named = |name: &OsStr| {
|
||||
if name == "default" {
|
||||
dir.join("herdr.sock")
|
||||
} else {
|
||||
dir.join("sessions").join(name).join("herdr.sock")
|
||||
}
|
||||
};
|
||||
if let Some(name) = session(args) {
|
||||
return Ok(named(OsStr::new(&name)));
|
||||
}
|
||||
if let Some(path) = env.get("HERDR_SOCKET_PATH") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
Ok(env
|
||||
.get("HERDR_SESSION")
|
||||
.map_or_else(|| dir.join("herdr.sock"), |name| named(name)))
|
||||
}
|
||||
|
||||
/// The session `session attach NAME`, `--session NAME` or `--session=NAME` names, as herdr reads it.
|
||||
fn session(args: &[String]) -> Option<String> {
|
||||
if let [_, noun, verb, name] = args
|
||||
&& noun == "session"
|
||||
&& verb == "attach"
|
||||
{
|
||||
return Some(name.clone());
|
||||
}
|
||||
let mut named = None;
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(arg) = rest.next() {
|
||||
if arg == "--" {
|
||||
break;
|
||||
}
|
||||
if arg == "--session" {
|
||||
named = rest.next().cloned();
|
||||
} else if let Some(name) = arg.strip_prefix("--session=") {
|
||||
named = Some(name.to_owned());
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// What herdr's server shows: the folder of the pane it has focused, none while it has none or knows none, and its agents.
|
||||
fn snapshot(socket: &Path) -> anyhow::Result<Shows> {
|
||||
let ask = || -> io::Result<String> {
|
||||
let mut stream = connect(socket)?;
|
||||
stream.write_all(SNAPSHOT)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(stream).read_line(&mut reply)?;
|
||||
Ok(reply)
|
||||
};
|
||||
let reply = ask().with_context(|| format!("herdr: asking {}", socket.display()))?;
|
||||
shows(&reply).with_context(|| format!("herdr: {}", socket.display()))
|
||||
}
|
||||
|
||||
/// The focused pane's folder, and herdr's agents as records.
|
||||
type Shows = (Option<PathBuf>, Vec<(String, Record)>);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Reply {
|
||||
result: Option<Snapshot>,
|
||||
error: Option<Failure>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Snapshot {
|
||||
snapshot: Session,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Session {
|
||||
focused_pane_id: Option<String>,
|
||||
panes: Vec<Pane>,
|
||||
/// A herdr before agents lists none.
|
||||
#[serde(default)]
|
||||
agents: Vec<Agent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Pane {
|
||||
pane_id: String,
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Agent {
|
||||
pane_id: String,
|
||||
agent_status: AgentStatus,
|
||||
agent: Option<String>,
|
||||
display_agent: Option<String>,
|
||||
name: Option<String>,
|
||||
title: Option<String>,
|
||||
terminal_title_stripped: Option<String>,
|
||||
}
|
||||
|
||||
/// An agent's state, as herdr names it.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum AgentStatus {
|
||||
Idle,
|
||||
Working,
|
||||
Blocked,
|
||||
Done,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Failure {
|
||||
code: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// What herdr's `session.snapshot` reply shows.
|
||||
pub(super) fn shows(reply: &str) -> anyhow::Result<Shows> {
|
||||
let reply: Reply = serde_json::from_str(reply).context("its reply does not parse")?;
|
||||
if let Some(Failure { code, message }) = reply.error {
|
||||
anyhow::bail!("{code}: {message}");
|
||||
}
|
||||
let session = reply.result.context("its reply holds no result")?.snapshot;
|
||||
let agents = records(session.agents);
|
||||
let Some(id) = session.focused_pane_id else {
|
||||
return Ok((None, agents));
|
||||
};
|
||||
let pane = session
|
||||
.panes
|
||||
.into_iter()
|
||||
.find(|pane| pane.pane_id == id)
|
||||
.with_context(|| format!("its focused pane {id} is not among its panes"))?;
|
||||
if let Some(cwd) = &pane.cwd {
|
||||
anyhow::ensure!(
|
||||
cwd.is_absolute(),
|
||||
"pane {id}'s folder {} is relative",
|
||||
cwd.display()
|
||||
);
|
||||
}
|
||||
Ok((pane.cwd, agents))
|
||||
}
|
||||
|
||||
/// Each agent whose state herdr knows, as the record of its pane: herdr names no kind and no progress.
|
||||
fn records(agents: Vec<Agent>) -> Vec<(String, Record)> {
|
||||
let record = |agent: Agent| {
|
||||
let state = match agent.agent_status {
|
||||
AgentStatus::Idle => State::Idle,
|
||||
AgentStatus::Working => State::Working,
|
||||
AgentStatus::Blocked => State::Blocked,
|
||||
AgentStatus::Done => State::Done,
|
||||
AgentStatus::Unknown => return None,
|
||||
};
|
||||
let record = Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress: None,
|
||||
app: agent.agent,
|
||||
title: agent.name.or(agent.display_agent),
|
||||
msg: agent.terminal_title_stripped.or(agent.title),
|
||||
};
|
||||
Some((format!("{HERDR}{}", agent.pane_id), record))
|
||||
};
|
||||
agents.into_iter().filter_map(record).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,408 @@
|
||||
use super::*;
|
||||
|
||||
/// A root that makes a path absolute here.
|
||||
const ROOT: &str = if cfg!(windows) { "C:/" } else { "/" };
|
||||
|
||||
fn args(words: &str) -> Vec<String> {
|
||||
words.split(' ').map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn env(pairs: &[(&str, &str)]) -> HashMap<String, OsString> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(name, value)| ((*name).to_owned(), OsString::from(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn found(words: &str, env: &HashMap<String, OsString>) -> PathBuf {
|
||||
follows(&args(words), env)
|
||||
.expect("a local client")
|
||||
.expect("herdr's client")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_finds_its_server_as_herdr_does() {
|
||||
let home = env(&[("HOME", "/h")]);
|
||||
let default = PathBuf::from("/h/.config/herdr/herdr.sock");
|
||||
let work = PathBuf::from("/h/.config/herdr/sessions/work/herdr.sock");
|
||||
assert_eq!(found("herdr", &home), default);
|
||||
assert_eq!(found("herdr client", &home), default);
|
||||
assert_eq!(found("herdr --session work", &home), work);
|
||||
assert_eq!(found("herdr --session=work", &home), work);
|
||||
assert_eq!(found("herdr session attach work", &home), work);
|
||||
assert_eq!(found("herdr --session default", &home), default);
|
||||
let config = env(&[("HOME", "/h"), ("XDG_CONFIG_HOME", "/x")]);
|
||||
assert_eq!(
|
||||
found("herdr", &config),
|
||||
PathBuf::from("/x/herdr/herdr.sock")
|
||||
);
|
||||
let socket = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "/s.sock")]);
|
||||
assert_eq!(found("herdr", &socket), PathBuf::from("/s.sock"));
|
||||
assert_eq!(found("herdr --session work", &socket), work);
|
||||
let relative = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "herdr.sock")]);
|
||||
assert_eq!(found("herdr", &relative), PathBuf::from("herdr.sock"));
|
||||
let named = env(&[("HOME", "/h"), ("HERDR_SESSION", "work")]);
|
||||
assert_eq!(found("herdr", &named), work);
|
||||
assert!(follows(&args("herdr"), &env(&[])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_herdr_s_terminal_client_is_followed() {
|
||||
let home = env(&[("HOME", "/h")]);
|
||||
for words in [
|
||||
"herdr pane list",
|
||||
"herdr api snapshot",
|
||||
"herdr server",
|
||||
"herdr session list",
|
||||
"herdr --version",
|
||||
"herdr --session work status",
|
||||
] {
|
||||
assert_eq!(follows(&args(words), &home).expect(words), None, "{words}");
|
||||
}
|
||||
for words in ["herdr --remote box", "herdr --remote=box --session work"] {
|
||||
let error = follows(&args(words), &home).expect_err(words);
|
||||
assert!(error.to_string().contains("another machine"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
fn reply(focused_pane: &str, cwd: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":"elyterminal","result":{{"type":"session_snapshot","snapshot":{{"version":"0.9.5","protocol":22,"focused_pane_id":{focused_pane},"workspaces":[],"tabs":[],"panes":[{{"pane_id":"w1:p1","cwd":"/a","focused":false}},{{"pane_id":"w1:p2","cwd":{cwd},"focused":true}}],"layouts":[],"agents":[]}}}}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focused_pane_s_folder_comes_from_herdr_s_snapshot() {
|
||||
let read =
|
||||
|focused_pane: &str, cwd: &str| shows(&reply(focused_pane, cwd)).map(|(folder, _)| folder);
|
||||
let folder = format!("{ROOT}srv/b");
|
||||
assert_eq!(
|
||||
read(r#""w1:p2""#, &format!("\"{folder}\"")).expect("a folder"),
|
||||
Some(folder.clone().into())
|
||||
);
|
||||
assert_eq!(
|
||||
read("null", &format!("\"{folder}\"")).expect("no focus"),
|
||||
None
|
||||
);
|
||||
assert_eq!(read(r#""w1:p2""#, "null").expect("no folder"), None);
|
||||
let error = read(r#""w1:p9""#, r#""/b""#).expect_err("a lost pane");
|
||||
assert!(error.to_string().contains("w1:p9"), "{error}");
|
||||
let error = read(r#""w1:p2""#, r#""b""#).expect_err("a relative folder");
|
||||
assert!(error.to_string().contains("relative"), "{error}");
|
||||
let failed =
|
||||
r#"{"id":"elyterminal","error":{"code":"invalid_request","message":"no such method"}}"#;
|
||||
let error = shows(failed).expect_err("an error");
|
||||
assert_eq!(error.to_string(), "invalid_request: no such method");
|
||||
assert!(shows("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn herdr_s_agents_whose_state_it_knows_are_records_by_pane() {
|
||||
let agent = |pane: &str, status: &str, more: &str| {
|
||||
format!(
|
||||
r#"{{"terminal_id":"t","agent_status":"{status}","workspace_id":"w1","tab_id":"w1:t1","pane_id":"{pane}","focused":false,"revision":1{more}}}"#
|
||||
)
|
||||
};
|
||||
let agents = [
|
||||
agent(
|
||||
"w1:p1",
|
||||
"working",
|
||||
r#","agent":"codex","display_agent":"Codex","name":"review","title":"codex","terminal_title_stripped":"Reading files""#,
|
||||
),
|
||||
agent(
|
||||
"w1:p2",
|
||||
"blocked",
|
||||
r#","agent":"claude","display_agent":"Claude Code","title":"claude""#,
|
||||
),
|
||||
agent("w1:p3", "unknown", ""),
|
||||
agent("w1:p4", "napping", ""),
|
||||
agent("w1:p5", "done", ""),
|
||||
agent("w1:p6", "idle", ""),
|
||||
];
|
||||
let reply = reply("null", "null").replace(
|
||||
r#""agents":[]"#,
|
||||
&format!(r#""agents":[{}]"#, agents.join(",")),
|
||||
);
|
||||
let (folder, records) = shows(&reply).expect("a snapshot");
|
||||
assert_eq!(folder, None);
|
||||
let said: Vec<_> = records
|
||||
.iter()
|
||||
.map(|(id, record)| {
|
||||
(
|
||||
id.as_str(),
|
||||
record.state,
|
||||
record.app.as_deref(),
|
||||
record.title.as_deref(),
|
||||
record.msg.as_deref(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
said,
|
||||
[
|
||||
(
|
||||
"herdr:w1:p1",
|
||||
State::Working,
|
||||
Some("codex"),
|
||||
Some("review"),
|
||||
Some("Reading files")
|
||||
),
|
||||
(
|
||||
"herdr:w1:p2",
|
||||
State::Blocked,
|
||||
Some("claude"),
|
||||
Some("Claude Code"),
|
||||
Some("claude")
|
||||
),
|
||||
("herdr:w1:p5", State::Done, None, None, None),
|
||||
("herdr:w1:p6", State::Idle, None, None, None),
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
records
|
||||
.iter()
|
||||
.all(|(_, record)| record.kind.is_none() && record.progress.is_none())
|
||||
);
|
||||
let older = reply.replace(&format!(r#","agents":[{}]"#, agents.join(",")), "");
|
||||
assert_eq!(shows(&older).expect("a herdr before agents").1, []);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn herdr_answers_over_its_socket() {
|
||||
use std::{fs, os::unix::net::UnixListener};
|
||||
let dir = std::env::temp_dir().join(format!("elyterminal-herdr-{}", std::process::id()));
|
||||
if dir.exists() {
|
||||
fs::remove_dir_all(&dir).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&dir).expect("a test folder");
|
||||
let socket = dir.join("herdr.sock");
|
||||
let listener = UnixListener::bind(&socket).expect("a socket");
|
||||
let answer = reply(r#""w1:p2""#, r#""/srv""#);
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().expect("a client");
|
||||
let mut request = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.expect("a request");
|
||||
(&stream)
|
||||
.write_all(format!("{answer}\n").as_bytes())
|
||||
.expect("a reply");
|
||||
request
|
||||
});
|
||||
assert_eq!(snapshot(&socket).expect("an answer").0, Some("/srv".into()));
|
||||
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||
fs::remove_dir_all(&dir).expect("the test folder goes");
|
||||
let error = snapshot(&socket).expect_err("no server");
|
||||
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||
assert!(starting(&error), "{error:#}");
|
||||
assert!(!starting(&shows("").expect_err("no reply")));
|
||||
}
|
||||
|
||||
/// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_process_reads_with_its_path_arguments_variables_and_folder() {
|
||||
use super::unix::{cwd, launch, path};
|
||||
use std::fs;
|
||||
let pid = std::process::id() as i32;
|
||||
let read = path(pid).expect("its path");
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
let exe = std::env::current_exe().expect("this test's path");
|
||||
assert_eq!(fs::canonicalize(read).ok(), fs::canonicalize(exe).ok());
|
||||
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||
assert_eq!(env.get("HOME"), std::env::var_os("HOME").as_ref());
|
||||
let folder = std::env::current_dir().expect("this test's folder");
|
||||
assert_eq!(
|
||||
fs::canonicalize(cwd(pid).expect("its folder")).ok(),
|
||||
fs::canonicalize(folder).ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn only_herdr_s_program_is_read() {
|
||||
let pid = std::process::id() as i32;
|
||||
assert_eq!(
|
||||
client((pid, None)).expect("a program that does not read"),
|
||||
None
|
||||
);
|
||||
let shell = client((pid, Some("/bin/sh".into())));
|
||||
assert_eq!(shell.expect("another program"), None);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_updated_herdr_still_names_itself() {
|
||||
use super::unix::installed;
|
||||
let deleted = PathBuf::from("/home/u/.local/bin/herdr (deleted)");
|
||||
assert_eq!(
|
||||
installed(deleted),
|
||||
PathBuf::from("/home/u/.local/bin/herdr")
|
||||
);
|
||||
assert_eq!(
|
||||
installed("/usr/bin/herdr".into()),
|
||||
PathBuf::from("/usr/bin/herdr")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_lays_out_the_arguments_after_the_program_s_path() {
|
||||
use super::unix::procargs;
|
||||
let mut buffer = 2i32.to_ne_bytes().to_vec();
|
||||
buffer.extend_from_slice(b"/usr/local/bin/herdr\0\0\0\0herdr\0\0HOME=/h\0X=a=b\0\0\0");
|
||||
let (args, env) = procargs(&buffer).expect("it parses");
|
||||
assert_eq!(args, ["herdr", ""]);
|
||||
assert_eq!(env.get("HOME"), Some(&OsString::from("/h")));
|
||||
assert_eq!(env.get("X"), Some(&OsString::from("a=b")));
|
||||
assert!(procargs(&[1, 0]).is_err());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_windows_client_finds_its_server_in_app_data() {
|
||||
let roaming = PathBuf::from(r"C:\Users\u\AppData\Roaming\herdr");
|
||||
let data = env(&[
|
||||
("APPDATA", r"C:\Users\u\AppData\Roaming"),
|
||||
("USERPROFILE", r"C:\D"),
|
||||
]);
|
||||
assert_eq!(found("herdr", &data), roaming.join("herdr.sock"));
|
||||
let profile = env(&[("USERPROFILE", r"C:\Users\u")]);
|
||||
assert_eq!(
|
||||
found("herdr --session work", &profile),
|
||||
roaming.join(r"sessions\work\herdr.sock")
|
||||
);
|
||||
let config = env(&[("XDG_CONFIG_HOME", r"C:\x"), ("APPDATA", r"C:\D")]);
|
||||
assert_eq!(
|
||||
found("herdr", &config),
|
||||
PathBuf::from(r"C:\x\herdr\herdr.sock")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn herdr_is_sought_at_and_under_the_shell_not_under_herdr() {
|
||||
use super::windows::herdrs;
|
||||
let processes = [
|
||||
(100, 50, "pwsh.exe"),
|
||||
(104, 100, "cmd.exe"),
|
||||
(108, 104, "HERDR.EXE"),
|
||||
(112, 100, "herdr.exe"),
|
||||
(116, 112, "herdr.exe"),
|
||||
(120, 100, "herdr.exe"),
|
||||
(200, 300, "a.exe"),
|
||||
(300, 200, "b.exe"),
|
||||
]
|
||||
.map(|(pid, parent, name)| (pid, parent, name.to_owned()));
|
||||
// 116 is the server 112 started; 120 one started beside it.
|
||||
assert_eq!(herdrs(&processes, 100), [112, 120, 108]);
|
||||
assert_eq!(herdrs(&processes, 112), [112]);
|
||||
assert!(herdrs(&processes, 200).is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_server_or_a_command_beside_herdr_s_client_is_passed_by() {
|
||||
use super::windows::pick;
|
||||
let data = env(&[("APPDATA", r"C:\D")]);
|
||||
let read = |words: &str| -> io::Result<Invocation> { Ok((args(words), data.clone())) };
|
||||
let gone = || -> io::Result<Invocation> { Err(io::ErrorKind::NotFound.into()) };
|
||||
let socket = Some(PathBuf::from(r"C:\D\herdr\herdr.sock"));
|
||||
let server = "herdr.exe server";
|
||||
let client = [(200, read(server)), (100, read("herdr.exe"))];
|
||||
assert_eq!(pick(client).expect("a client"), socket);
|
||||
let command = [(200, read(server)), (300, read("herdr.exe api snapshot"))];
|
||||
assert_eq!(pick(command).expect("no client"), None);
|
||||
let unread = [(300, gone()), (100, read("herdr.exe"))];
|
||||
assert_eq!(pick(unread).expect("a client"), socket);
|
||||
let error = pick([(300, gone()), (200, read(server))]).expect_err("an unread process");
|
||||
assert!(format!("{error:#}").contains("process 300"), "{error:#}");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_windows_process_reads_with_its_name_arguments_and_variables() {
|
||||
use super::windows::{launch, processes};
|
||||
let pid = std::process::id();
|
||||
let exe = std::env::current_exe().expect("this test's path");
|
||||
let name = exe.file_name().expect("this test's name");
|
||||
let listed = processes().expect("the process list");
|
||||
assert!(
|
||||
listed
|
||||
.iter()
|
||||
.any(|(id, _, read)| *id == pid && name.eq_ignore_ascii_case(read)),
|
||||
"process {pid} is not listed under its name"
|
||||
);
|
||||
assert!(running(pid).expect("the process list").is_empty());
|
||||
let (args, env) = launch(pid).expect("its arguments");
|
||||
assert_eq!(args, std::env::args().collect::<Vec<_>>());
|
||||
// Windows names it Path; the reader folds names to upper case.
|
||||
let path = std::env::var_os("PATH");
|
||||
assert!(env.get("PATH") == path.as_ref(), "PATH reads otherwise");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn herdr_answers_over_its_pipe() {
|
||||
use std::os::windows::{
|
||||
ffi::OsStrExt,
|
||||
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_PIPE_CONNECTED, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::PIPE_ACCESS_DUPLEX,
|
||||
System::Pipes::{ConnectNamedPipe, CreateNamedPipeW, PIPE_TYPE_BYTE},
|
||||
};
|
||||
let socket = PathBuf::from(format!(
|
||||
r"C:\elyterminal-herdr-{}\herdr.sock",
|
||||
std::process::id()
|
||||
));
|
||||
let name = format!(r"\\.\pipe\{}", socket.display());
|
||||
let name: Vec<u16> = OsStr::new(&name).encode_wide().chain([0]).collect();
|
||||
// SAFETY: `name` ends with a NUL; the handle is owned below.
|
||||
let pipe = unsafe {
|
||||
CreateNamedPipeW(
|
||||
name.as_ptr(),
|
||||
PIPE_ACCESS_DUPLEX,
|
||||
PIPE_TYPE_BYTE,
|
||||
1,
|
||||
4096,
|
||||
4096,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
assert_ne!(pipe, INVALID_HANDLE_VALUE, "{}", io::Error::last_os_error());
|
||||
// SAFETY: the handle is new and ours.
|
||||
let pipe = unsafe { OwnedHandle::from_raw_handle(pipe) };
|
||||
let answer = reply(r#""w1:p2""#, r#""C:/srv""#);
|
||||
let server = std::thread::spawn(move || {
|
||||
// SAFETY: the pipe is open.
|
||||
let joined = unsafe { ConnectNamedPipe(pipe.as_raw_handle(), std::ptr::null_mut()) };
|
||||
let error = io::Error::last_os_error();
|
||||
assert!(
|
||||
joined != 0 || error.raw_os_error() == Some(ERROR_PIPE_CONNECTED as i32),
|
||||
"{error}"
|
||||
);
|
||||
let stream = std::fs::File::from(pipe);
|
||||
let mut request = String::new();
|
||||
BufReader::new(&stream)
|
||||
.read_line(&mut request)
|
||||
.expect("a request");
|
||||
(&stream)
|
||||
.write_all(format!("{answer}\n").as_bytes())
|
||||
.expect("a reply");
|
||||
// The pipe goes only once the client has read the reply.
|
||||
stream.sync_all().expect("the client reads the reply");
|
||||
request
|
||||
});
|
||||
assert_eq!(
|
||||
snapshot(&socket).expect("an answer").0,
|
||||
Some("C:/srv".into())
|
||||
);
|
||||
assert_eq!(server.join().expect("the server").as_bytes(), SNAPSHOT);
|
||||
let error = snapshot(&socket).expect_err("no server");
|
||||
assert!(format!("{error:#}").contains("asking"), "{error:#}");
|
||||
assert!(starting(&error), "{error:#}");
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
io,
|
||||
os::unix::{ffi::OsStringExt, net::UnixStream},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
|
||||
use super::{Invocation, WAIT, follows};
|
||||
use crate::term::Pty;
|
||||
|
||||
pub(super) type Pid = i32;
|
||||
|
||||
/// The foreground's group and its leader's executable: an exec keeps the group and swaps the program.
|
||||
pub(super) type Program = (Pid, Option<PathBuf>);
|
||||
|
||||
/// herdr's executable.
|
||||
const HERDR: &str = "herdr";
|
||||
|
||||
/// The process group leading the session's foreground.
|
||||
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||
pty.foreground()
|
||||
}
|
||||
|
||||
pub(super) fn running(group: Pid) -> io::Result<Program> {
|
||||
Ok((group, path(group).ok()))
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
|
||||
pub(super) fn client((group, exe): Program) -> anyhow::Result<Option<PathBuf>> {
|
||||
let Some(exe) = exe else {
|
||||
log::debug!("herdr: process {group}, another user's or gone, does not read");
|
||||
return Ok(None);
|
||||
};
|
||||
if !exe
|
||||
.file_name()
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(HERDR))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let (args, env) =
|
||||
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
|
||||
let Some(socket) = follows(&args, &env)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if socket.is_absolute() {
|
||||
return Ok(Some(socket));
|
||||
}
|
||||
// herdr reads a relative socket from its client's folder.
|
||||
let folder =
|
||||
cwd(group).with_context(|| format!("herdr: process {group}'s folder does not read"))?;
|
||||
Ok(Some(folder.join(socket)))
|
||||
}
|
||||
|
||||
/// herdr's folder without `XDG_CONFIG_HOME`.
|
||||
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
let home = env.get("HOME").context("herdr: its client has no HOME")?;
|
||||
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||
}
|
||||
|
||||
/// herdr's socket, which must answer within `WAIT`.
|
||||
pub(super) fn connect(socket: &Path) -> io::Result<UnixStream> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(WAIT))?;
|
||||
stream.set_write_timeout(Some(WAIT))?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn path(pid: Pid) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/exe")).map(installed)
|
||||
}
|
||||
|
||||
/// The path of an executable the kernel marks ` (deleted)`, as an update that replaced it leaves it.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn installed(path: PathBuf) -> PathBuf {
|
||||
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
|
||||
match path.as_os_str().as_bytes().strip_suffix(b" (deleted)") {
|
||||
Some(kept) => PathBuf::from(OsStr::from_bytes(kept)),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let read = |name: &str| std::fs::read(format!("/proc/{pid}/{name}"));
|
||||
let (args, env) = (read("cmdline")?, read("environ")?);
|
||||
let args = strings(&args).map(lossy).collect();
|
||||
let env = strings(&env).filter_map(variable).collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn cwd(pid: Pid) -> io::Result<PathBuf> {
|
||||
std::fs::read_link(format!("/proc/{pid}/cwd"))
|
||||
}
|
||||
|
||||
/// Each NUL-ended string in `bytes`.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn strings(bytes: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
bytes
|
||||
.strip_suffix(b"\0")
|
||||
.unwrap_or(bytes)
|
||||
.split(|byte| *byte == 0)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn path(pid: Pid) -> io::Result<PathBuf> {
|
||||
let mut buffer = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
|
||||
// SAFETY: the buffer holds the size given.
|
||||
let length =
|
||||
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
|
||||
if length <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
buffer.truncate(length as usize);
|
||||
Ok(PathBuf::from(OsString::from_vec(buffer)))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let mut name = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
|
||||
let mut ask = |buffer: &mut Vec<u8>| {
|
||||
let mut size = buffer.len();
|
||||
let into = if buffer.is_empty() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
buffer.as_mut_ptr().cast()
|
||||
};
|
||||
// SAFETY: `into` holds `size` bytes, or is null to ask for the size.
|
||||
let done = unsafe {
|
||||
libc::sysctl(
|
||||
name.as_mut_ptr(),
|
||||
3,
|
||||
into,
|
||||
&mut size,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
)
|
||||
};
|
||||
if done != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(size)
|
||||
};
|
||||
let mut buffer = Vec::new();
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.resize(size, 0);
|
||||
let size = ask(&mut buffer)?;
|
||||
buffer.truncate(size);
|
||||
procargs(&buffer)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn cwd(pid: Pid) -> io::Result<PathBuf> {
|
||||
// SAFETY: the struct is plain C data, valid zeroed.
|
||||
let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
|
||||
let size = std::mem::size_of::<libc::proc_vnodepathinfo>() as libc::c_int;
|
||||
// SAFETY: `info` holds the size given.
|
||||
let read = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDVNODEPATHINFO,
|
||||
0,
|
||||
(&raw mut info).cast(),
|
||||
size,
|
||||
)
|
||||
};
|
||||
if read <= 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if read != size {
|
||||
return Err(io::Error::other(format!(
|
||||
"proc_pidinfo read {read} of {size} bytes"
|
||||
)));
|
||||
}
|
||||
let path = info
|
||||
.pvi_cdir
|
||||
.vip_path
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|byte| *byte as u8);
|
||||
Ok(PathBuf::from(OsString::from_vec(
|
||||
path.take_while(|byte| *byte != 0).collect(),
|
||||
)))
|
||||
}
|
||||
|
||||
/// `KERN_PROCARGS2`'s layout: argc, the program's path, NUL padding, the arguments, the variables.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn procargs(buffer: &[u8]) -> io::Result<Invocation> {
|
||||
let invalid =
|
||||
|what: &str| io::Error::new(io::ErrorKind::InvalidData, format!("KERN_PROCARGS2 {what}"));
|
||||
let (count, rest) = buffer
|
||||
.split_first_chunk::<4>()
|
||||
.ok_or_else(|| invalid("holds no argument count"))?;
|
||||
let count = usize::try_from(i32::from_ne_bytes(*count))
|
||||
.map_err(|_| invalid("holds a negative argument count"))?;
|
||||
let mut parts = rest
|
||||
.split(|byte| *byte == 0)
|
||||
.skip(1)
|
||||
.skip_while(|part| part.is_empty());
|
||||
let args = parts.by_ref().take(count).map(lossy).collect();
|
||||
let env = parts
|
||||
.take_while(|part| !part.is_empty())
|
||||
.filter_map(variable)
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
fn lossy(bytes: &[u8]) -> String {
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable; its value keeps its bytes.
|
||||
fn variable(pair: &[u8]) -> Option<(String, OsString)> {
|
||||
let at = pair.iter().position(|byte| *byte == b'=')?;
|
||||
Some((
|
||||
lossy(&pair[..at]),
|
||||
OsString::from_vec(pair[at + 1..].to_vec()),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
fs::{File, OpenOptions},
|
||||
io,
|
||||
os::windows::{
|
||||
ffi::{OsStrExt, OsStringExt},
|
||||
fs::OpenOptionsExt,
|
||||
io::{AsRawHandle, FromRawHandle, OwnedHandle},
|
||||
},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{ERROR_NO_MORE_FILES, ERROR_PIPE_BUSY, INVALID_HANDLE_VALUE},
|
||||
Storage::FileSystem::SECURITY_IDENTIFICATION,
|
||||
System::{
|
||||
Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
},
|
||||
Pipes::WaitNamedPipeW,
|
||||
},
|
||||
};
|
||||
|
||||
use super::{Invocation, WAIT, follows};
|
||||
use crate::term::Pty;
|
||||
|
||||
pub(super) type Pid = u32;
|
||||
|
||||
/// The herdr processes at or under the session's shell, nearest first.
|
||||
pub(super) type Program = Vec<Pid>;
|
||||
|
||||
/// herdr's executable.
|
||||
const HERDR: &str = "herdr.exe";
|
||||
|
||||
/// The session's shell: Windows has no foreground group, so herdr is sought under it.
|
||||
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
|
||||
Ok(pty.pid)
|
||||
}
|
||||
|
||||
pub(super) fn running(shell: Pid) -> io::Result<Program> {
|
||||
Ok(herdrs(&processes()?, shell))
|
||||
}
|
||||
|
||||
/// The herdr processes at or under `root`, breadth first; what a herdr starts, its server among them, is its own.
|
||||
pub(super) fn herdrs(processes: &[(Pid, Pid, String)], root: Pid) -> Vec<Pid> {
|
||||
let herdr = |pid: Pid| {
|
||||
processes
|
||||
.iter()
|
||||
.any(|(id, _, name)| *id == pid && name.eq_ignore_ascii_case(HERDR))
|
||||
};
|
||||
let (mut under, mut found, mut at) = (vec![root], Vec::new(), 0);
|
||||
while let Some(&pid) = under.get(at) {
|
||||
at += 1;
|
||||
if herdr(pid) {
|
||||
found.push(pid);
|
||||
continue;
|
||||
}
|
||||
for (child, ..) in processes.iter().filter(|(_, of, _)| *of == pid) {
|
||||
// A parent id outlives its process, so a stale one may lead back up.
|
||||
if !under.contains(child) {
|
||||
under.push(*child);
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Every process: its id, its parent's, and its executable's name.
|
||||
pub(super) fn processes() -> io::Result<Vec<(Pid, Pid, String)>> {
|
||||
// SAFETY: a plain call; its handle is owned below.
|
||||
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||
if snapshot == INVALID_HANDLE_VALUE {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: the handle is new and ours.
|
||||
let snapshot = unsafe { OwnedHandle::from_raw_handle(snapshot) };
|
||||
let mut entry = PROCESSENTRY32W {
|
||||
dwSize: size_of::<PROCESSENTRY32W>() as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut processes = Vec::new();
|
||||
// SAFETY: `entry` holds the size it names.
|
||||
let mut read = unsafe { Process32FirstW(snapshot.as_raw_handle(), &mut entry) };
|
||||
while read != 0 {
|
||||
let name = entry.szExeFile.split(|unit| *unit == 0).next();
|
||||
processes.push((
|
||||
entry.th32ProcessID,
|
||||
entry.th32ParentProcessID,
|
||||
String::from_utf16_lossy(name.unwrap_or_default()),
|
||||
));
|
||||
// SAFETY: as above.
|
||||
read = unsafe { Process32NextW(snapshot.as_raw_handle(), &mut entry) };
|
||||
}
|
||||
let error = io::Error::last_os_error();
|
||||
// The list ends with ERROR_NO_MORE_FILES.
|
||||
if error.raw_os_error() == Some(ERROR_NO_MORE_FILES as i32) {
|
||||
Ok(processes)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the herdr server whose terminal client is among `herdrs`; a server or a command is passed by.
|
||||
pub(super) fn client(herdrs: Program) -> anyhow::Result<Option<PathBuf>> {
|
||||
pick(herdrs.into_iter().map(|pid| (pid, launch(pid))))
|
||||
}
|
||||
|
||||
/// The first terminal client's socket in `reads`; with none, a failed read is the error, so it is tried again.
|
||||
pub(super) fn pick(
|
||||
reads: impl IntoIterator<Item = (Pid, io::Result<Invocation>)>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
let mut unread = None;
|
||||
for (pid, read) in reads {
|
||||
match read.with_context(|| format!("herdr: process {pid} does not read")) {
|
||||
// herdr names its pipe after the socket's path as written, relative or not.
|
||||
Ok((args, env)) => {
|
||||
if let Some(socket) = follows(&args, &env)? {
|
||||
return Ok(Some(socket));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
unread.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
unread.map_or(Ok(None), Err)
|
||||
}
|
||||
|
||||
/// The arguments and variables of process `pid`; names fold to upper case, as Windows matches them.
|
||||
pub(super) fn launch(pid: Pid) -> io::Result<Invocation> {
|
||||
let id = sysinfo::Pid::from_u32(pid);
|
||||
let mut system = System::new();
|
||||
let read = ProcessRefreshKind::nothing()
|
||||
.with_cmd(UpdateKind::Always)
|
||||
.with_environ(UpdateKind::Always);
|
||||
system.refresh_processes_specifics(ProcessesToUpdate::Some(&[id]), true, read);
|
||||
let process = system
|
||||
.process(id)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "the process is gone"))?;
|
||||
// sysinfo leaves a list empty when it does not read; every process has both.
|
||||
if process.cmd().is_empty() || process.environ().is_empty() {
|
||||
return Err(io::Error::other(
|
||||
"its command line or variables do not read",
|
||||
));
|
||||
}
|
||||
let args = process
|
||||
.cmd()
|
||||
.iter()
|
||||
.map(|arg| arg.to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let env = process
|
||||
.environ()
|
||||
.iter()
|
||||
.filter_map(|pair| variable(pair))
|
||||
.collect();
|
||||
Ok((args, env))
|
||||
}
|
||||
|
||||
/// A `NAME=value` variable, its name in upper case; its value keeps its units.
|
||||
fn variable(pair: &OsStr) -> Option<(String, OsString)> {
|
||||
let pair: Vec<u16> = pair.encode_wide().collect();
|
||||
let at = pair.iter().position(|unit| *unit == u16::from(b'='))?;
|
||||
Some((
|
||||
String::from_utf16_lossy(&pair[..at]).to_ascii_uppercase(),
|
||||
OsString::from_wide(&pair[at + 1..]),
|
||||
))
|
||||
}
|
||||
|
||||
/// herdr's folder without `XDG_CONFIG_HOME`, as herdr finds it on Windows.
|
||||
pub(super) fn home(env: &HashMap<String, OsString>) -> anyhow::Result<PathBuf> {
|
||||
if let Some(data) = env.get("APPDATA") {
|
||||
return Ok(PathBuf::from(data).join("herdr"));
|
||||
}
|
||||
if let Some(profile) = env.get("USERPROFILE") {
|
||||
return Ok(PathBuf::from(profile).join(r"AppData\Roaming\herdr"));
|
||||
}
|
||||
let home = env
|
||||
.get("HOME")
|
||||
.context("herdr: its client has no APPDATA, USERPROFILE or HOME")?;
|
||||
Ok(PathBuf::from(home).join(".config/herdr"))
|
||||
}
|
||||
|
||||
/// The pipe herdr names after its socket's path; a busy one is waited for, as herdr's client waits.
|
||||
pub(super) fn connect(socket: &Path) -> io::Result<File> {
|
||||
let pipe = format!(r"\\.\pipe\{}", socket.to_string_lossy());
|
||||
// Identification only: the pipe's server may not act as this user.
|
||||
let open = || {
|
||||
OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.security_qos_flags(SECURITY_IDENTIFICATION)
|
||||
.open(&pipe)
|
||||
};
|
||||
match open() {
|
||||
Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => {
|
||||
let name: Vec<u16> = OsStr::new(&pipe).encode_wide().chain([0]).collect();
|
||||
// SAFETY: `name` ends with a NUL.
|
||||
if unsafe { WaitNamedPipeW(name.as_ptr(), WAIT.as_millis() as u32) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
open()
|
||||
}
|
||||
opened => opened,
|
||||
}
|
||||
}
|
||||
+130
-19
@@ -12,7 +12,7 @@ use super::{
|
||||
view::TerminalView,
|
||||
};
|
||||
|
||||
actions!(elyterminal, [Find, FindNewer]);
|
||||
actions!(elyterminal, [Find, FindNewer, PreviousPrompt, NextPrompt]);
|
||||
|
||||
/// The key context a terminal's root sets.
|
||||
pub const CONTEXT: &str = "Terminal";
|
||||
@@ -32,6 +32,20 @@ pub fn bind_keys(cx: &mut App) {
|
||||
"ctrl-shift-f",
|
||||
)
|
||||
};
|
||||
// Ghostty's jumps: Cmd with or without Shift on macOS, Ctrl-Shift elsewhere.
|
||||
let (up, down): (&[&str], &[&str]) = if cfg!(target_os = "macos") {
|
||||
(&["cmd-up", "cmd-shift-up"], &["cmd-down", "cmd-shift-down"])
|
||||
} else {
|
||||
(&["ctrl-shift-up"], &["ctrl-shift-down"])
|
||||
};
|
||||
cx.bind_keys(
|
||||
up.iter()
|
||||
.map(|key| KeyBinding::new(key, PreviousPrompt, Some(CONTEXT))),
|
||||
);
|
||||
cx.bind_keys(
|
||||
down.iter()
|
||||
.map(|key| KeyBinding::new(key, NextPrompt, Some(CONTEXT))),
|
||||
);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new(copy, Copy, Some(CONTEXT)),
|
||||
KeyBinding::new(paste, Paste, Some(CONTEXT)),
|
||||
@@ -54,7 +68,7 @@ pub fn bind_keys(cx: &mut App) {
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Bytes from the keyboard: back to the newest line, no selection, then to the program.
|
||||
/// Bytes from the keyboard: back to the newest line, no selection, then to the program, which sees done and error records.
|
||||
pub(super) fn input(&mut self, bytes: Vec<u8>, cx: &mut Context<Self>) {
|
||||
let mut term = self.pty.term.lock();
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
@@ -63,6 +77,9 @@ impl TerminalView {
|
||||
}
|
||||
drop(term);
|
||||
self.pty.write(bytes);
|
||||
if self.records.seen() {
|
||||
self.restate(cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -75,37 +92,44 @@ impl TerminalView {
|
||||
if event.prefer_character_input {
|
||||
return;
|
||||
}
|
||||
self.held.insert(event.keystroke.key.clone());
|
||||
let facts = self.facts();
|
||||
let facts = self.facts(&event.keystroke);
|
||||
let action = if event.is_held || facts.repeat {
|
||||
Action::Repeat
|
||||
} else {
|
||||
Action::Press
|
||||
};
|
||||
if self.send(&event.keystroke, action, facts, cx) {
|
||||
let id = held(&event.keystroke, facts);
|
||||
let pressed = self.held.get(&id) == Some(&true);
|
||||
let went = self.send(&event.keystroke, action, facts, pressed, cx);
|
||||
self.held.insert(id, went == Some(true));
|
||||
if went.is_some() {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
|
||||
/// A release goes out only for a press this terminal saw: none for one a binding took.
|
||||
pub(super) fn key_up(&mut self, event: &KeyUpEvent, _: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.held.remove(&event.keystroke.key) {
|
||||
let facts = self.facts(&event.keystroke);
|
||||
let Some(record) = self.held.remove(&held(&event.keystroke, facts)) else {
|
||||
return;
|
||||
}
|
||||
let facts = self.facts();
|
||||
if self.send(&event.keystroke, Action::Release, facts, cx) {
|
||||
};
|
||||
if self
|
||||
.send(&event.keystroke, Action::Release, facts, record, cx)
|
||||
.is_some()
|
||||
{
|
||||
cx.stop_propagation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends what `keystroke` means to the program; false when it is text the input method types.
|
||||
/// Sends what `keystroke` means to the program: none when it is text the input method types, else whether it went as Windows' record; `pressed` says that of a held key's press.
|
||||
fn send(
|
||||
&mut self,
|
||||
keystroke: &Keystroke,
|
||||
action: Action,
|
||||
facts: Facts,
|
||||
pressed: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> bool {
|
||||
) -> Option<bool> {
|
||||
let modifiers = keystroke.modifiers;
|
||||
let alt = modifiers.alt && Self::alt_is_meta(facts, cx);
|
||||
let text = match &keystroke.key_char {
|
||||
@@ -121,6 +145,7 @@ impl TerminalView {
|
||||
alt,
|
||||
platform: modifiers.platform,
|
||||
action,
|
||||
unshifted: facts.unshifted,
|
||||
};
|
||||
let mode = *self.pty.term.lock().mode();
|
||||
let kitty = Kitty {
|
||||
@@ -130,29 +155,62 @@ impl TerminalView {
|
||||
all: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC),
|
||||
text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT),
|
||||
};
|
||||
let Some(bytes) = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty) else {
|
||||
return false;
|
||||
};
|
||||
#[cfg(windows)]
|
||||
if self.win32 && records(kitty, action, keys::types(&key), pressed) {
|
||||
match super::win32::record(keystroke, action) {
|
||||
Some(bytes) => {
|
||||
log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid);
|
||||
self.input(bytes, cx);
|
||||
return Some(true);
|
||||
}
|
||||
None => log::debug!(
|
||||
"terminal {}: no virtual key for {:?}, so it goes as bytes",
|
||||
self.pty.pid,
|
||||
keystroke.key
|
||||
),
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
debug_assert!(!pressed, "only Windows sends records");
|
||||
let bytes = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty)?;
|
||||
log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid);
|
||||
self.input(bytes, cx);
|
||||
true
|
||||
Some(false)
|
||||
}
|
||||
|
||||
/// AppKit's facts of the key; logged and none when unreadable.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn facts(&self) -> Facts {
|
||||
super::mac::key_facts().unwrap_or_else(|error| {
|
||||
fn facts(&self, keystroke: &Keystroke) -> Facts {
|
||||
super::mac::key_facts(&keystroke.key, keystroke.modifiers.shift).unwrap_or_else(|error| {
|
||||
log::error!("terminal {}: {error:#}", self.pty.pid);
|
||||
Facts {
|
||||
left_option: false,
|
||||
right_option: false,
|
||||
repeat: false,
|
||||
unshifted: None,
|
||||
code: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn facts(&self) -> Facts {
|
||||
/// Windows' layout names the key gpui named by what Shift made of it.
|
||||
#[cfg(windows)]
|
||||
fn facts(&self, keystroke: &Keystroke) -> Facts {
|
||||
let unshifted = if keystroke.modifiers.shift {
|
||||
None
|
||||
} else {
|
||||
super::win32::unshifted(&keystroke.key)
|
||||
};
|
||||
Facts {
|
||||
repeat: false,
|
||||
unshifted,
|
||||
code: super::win32::virtual_key(&keystroke.key).map(|(key, _)| key),
|
||||
}
|
||||
}
|
||||
|
||||
/// gpui keeps no key code on Linux, so a shifted symbol stays as gpui names it.
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
fn facts(&self, _: &Keystroke) -> Facts {
|
||||
Facts::default()
|
||||
}
|
||||
|
||||
@@ -182,6 +240,33 @@ type Facts = super::mac::KeyFacts;
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct Facts {
|
||||
repeat: bool,
|
||||
unshifted: Option<char>,
|
||||
/// The key's virtual key, which Shift does not change; none off Windows.
|
||||
code: Option<u16>,
|
||||
}
|
||||
|
||||
/// A key while it is down: its code where the platform gives one, else gpui's name, which Shift may change.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub(super) enum Held {
|
||||
Code(u16),
|
||||
Name(String),
|
||||
}
|
||||
|
||||
/// Whether a key goes as Windows' record, as in Windows Terminal: kitty's flags go first, text stays text, and a repeat or release goes as its press did.
|
||||
#[cfg(any(windows, test))]
|
||||
fn records(kitty: Kitty, action: Action, types: bool, pressed: bool) -> bool {
|
||||
kitty == Kitty::default()
|
||||
&& match action {
|
||||
Action::Press => !types,
|
||||
Action::Repeat => pressed || !types,
|
||||
Action::Release => pressed,
|
||||
}
|
||||
}
|
||||
|
||||
fn held(keystroke: &Keystroke, facts: Facts) -> Held {
|
||||
facts
|
||||
.code
|
||||
.map_or_else(|| Held::Name(keystroke.key.clone()), Held::Code)
|
||||
}
|
||||
|
||||
impl EntityInputHandler for TerminalView {
|
||||
@@ -270,3 +355,29 @@ impl EntityInputHandler for TerminalView {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_record_press_takes_its_repeats_and_release_with_it() {
|
||||
let plain = Kitty::default();
|
||||
// Ctrl-Shift-] goes as a record; Ctrl and Shift come up while ] repeats and types.
|
||||
assert!(records(plain, Action::Press, false, false));
|
||||
assert!(records(plain, Action::Repeat, true, true));
|
||||
assert!(records(plain, Action::Release, true, true));
|
||||
// A key that types goes as text, and so does its release.
|
||||
assert!(!records(plain, Action::Press, true, false));
|
||||
assert!(!records(plain, Action::Repeat, true, false));
|
||||
assert!(!records(plain, Action::Release, true, false));
|
||||
let kitty = Kitty {
|
||||
disambiguate: true,
|
||||
..plain
|
||||
};
|
||||
assert!(
|
||||
!records(kitty, Action::Press, false, false),
|
||||
"kitty's flags go first"
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -24,6 +24,8 @@ pub struct Key<'a> {
|
||||
/// Cmd, the Windows key or Super: kitty's super.
|
||||
pub platform: bool,
|
||||
pub action: Action,
|
||||
/// The key's own character, where gpui named the one Shift made and dropped Shift.
|
||||
pub unshifted: Option<char>,
|
||||
}
|
||||
|
||||
/// The kitty keyboard flags a program set.
|
||||
@@ -79,7 +81,7 @@ fn printable(name: &str) -> Option<char> {
|
||||
}
|
||||
|
||||
/// Whether the key types its text: some, none of it control, and no modifier that stops text.
|
||||
fn types(key: &Key) -> bool {
|
||||
pub(super) fn types(key: &Key) -> bool {
|
||||
let control = key.text.chars().any(char::is_control);
|
||||
!key.text.is_empty() && !control && !key.ctrl && !key.alt && !key.platform
|
||||
}
|
||||
@@ -171,16 +173,22 @@ fn typed(key: &Key, ch: char, kitty: Kitty) -> Option<Vec<u8>> {
|
||||
if typing && !kitty.all && key.action != Action::Release {
|
||||
return None;
|
||||
}
|
||||
// gpui names what Shift made of a key and drops Shift; CSI u names the key, with Shift.
|
||||
let (own, shift, made) = match key.unshifted {
|
||||
Some(own) => (own, true, Some(ch).filter(|&made| made != own)),
|
||||
None => (ch, key.shift, None),
|
||||
};
|
||||
let shifted = key
|
||||
.text
|
||||
.chars()
|
||||
.next()
|
||||
.filter(|&typed| typing && typed != ch && !typed.is_control())
|
||||
.or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase()))
|
||||
.filter(|_| key.shift && kitty.alternates);
|
||||
.filter(|&typed| typing && typed != own && !typed.is_control())
|
||||
.or(made)
|
||||
.or_else(|| (shift && own.is_ascii_lowercase()).then(|| own.to_ascii_uppercase()))
|
||||
.filter(|_| shift && kitty.alternates);
|
||||
let event = event(key, kitty);
|
||||
let text = typing && kitty.text && key.action != Action::Release;
|
||||
let mods = code(key);
|
||||
let mods = code(&Key { shift, ..*key });
|
||||
if shifted.is_none() && event.is_none() && !text {
|
||||
if mods == 1 && !kitty.all {
|
||||
return None;
|
||||
@@ -193,7 +201,7 @@ fn typed(key: &Key, ch: char, kitty: Kitty) -> Option<Vec<u8>> {
|
||||
return Some(bytes);
|
||||
}
|
||||
}
|
||||
let mut out = format!("\x1b[{}", ch.to_ascii_lowercase() as u32);
|
||||
let mut out = format!("\x1b[{}", own.to_ascii_lowercase() as u32);
|
||||
if let Some(shifted) = shifted {
|
||||
out.push_str(&format!(":{}", shifted as u32));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ fn key(spec: &'static str, text: &'static str) -> Key<'static> {
|
||||
alt: parts.contains(&"alt"),
|
||||
platform: parts.contains(&"cmd"),
|
||||
action: Action::Press,
|
||||
unshifted: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +297,75 @@ fn flags_that_only_add_fields_keep_legacy_keys() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_reports_the_key_shift_turned_into_a_symbol() {
|
||||
// gpui names Shift-[ by its `{` and drops Shift; the platform gives back `[`.
|
||||
let made = |spec, text, action| Key {
|
||||
unshifted: Some('['),
|
||||
action,
|
||||
..key(spec, text)
|
||||
};
|
||||
let herdr = Kitty {
|
||||
events: true,
|
||||
alternates: true,
|
||||
..DISAMBIGUATE
|
||||
};
|
||||
assert_eq!(
|
||||
kitty(made("cmd+ctrl+{", "", Action::Press), herdr).as_deref(),
|
||||
Some("\x1b[91:123;14u")
|
||||
);
|
||||
assert_eq!(
|
||||
kitty(made("cmd+ctrl+{", "", Action::Release), herdr).as_deref(),
|
||||
Some("\x1b[91:123;14:3u")
|
||||
);
|
||||
assert_eq!(
|
||||
kitty(made("ctrl+{", "", Action::Press), DISAMBIGUATE).as_deref(),
|
||||
Some("\x1b[91;6u")
|
||||
);
|
||||
assert_eq!(
|
||||
kitty(made("{", "{", Action::Press), herdr),
|
||||
None,
|
||||
"it types"
|
||||
);
|
||||
let all = Kitty {
|
||||
all: true,
|
||||
alternates: true,
|
||||
..DISAMBIGUATE
|
||||
};
|
||||
assert_eq!(
|
||||
kitty(made("{", "{", Action::Press), all).as_deref(),
|
||||
Some("\x1b[91:123;2u")
|
||||
);
|
||||
assert_eq!(
|
||||
legacy(made("ctrl+{", "", Action::Press), false).as_deref(),
|
||||
Some("\x1b[123;5u"),
|
||||
"legacy keeps fixterms' shifted character"
|
||||
);
|
||||
let events = Kitty {
|
||||
events: true,
|
||||
..Kitty::default()
|
||||
};
|
||||
assert_eq!(
|
||||
kitty(made("alt+{", "", Action::Press), events).as_deref(),
|
||||
Some("\x1b{"),
|
||||
"a flag that adds no field keeps the legacy bytes gpui's name gives"
|
||||
);
|
||||
let bang = Key {
|
||||
unshifted: Some('1'),
|
||||
..key("alt+!", "")
|
||||
};
|
||||
assert_eq!(kitty(bang, events).as_deref(), Some("\x1b!"));
|
||||
let same = Key {
|
||||
unshifted: Some('7'),
|
||||
..key("ctrl+7", "")
|
||||
};
|
||||
assert_eq!(
|
||||
kitty(same, herdr).as_deref(),
|
||||
Some("\x1b[55;6u"),
|
||||
"no alternate where Shift made the same character"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_is_kittys_eighth_bit() {
|
||||
let events = Kitty {
|
||||
|
||||
+14
-2
@@ -4,13 +4,18 @@ use alacritty_terminal::{
|
||||
};
|
||||
use ely_gpui_component::terminal::{Target, links};
|
||||
|
||||
/// The OSC 8 target at `point`, else a URL in its wrapped line.
|
||||
use super::prompts;
|
||||
|
||||
/// The OSC 8 target at `point`, else a URL in its wrapped line; a prompt's own link is no target.
|
||||
pub fn link_at<T>(term: &Term<T>, mut point: Point) -> Option<String> {
|
||||
let grid = term.grid();
|
||||
if grid[point].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
point.column -= 1;
|
||||
}
|
||||
if let Some(link) = grid[point].hyperlink() {
|
||||
if let Some(link) = grid[point]
|
||||
.hyperlink()
|
||||
.filter(|link| link.uri() != prompts::LINK)
|
||||
{
|
||||
return Some(link.uri().to_string());
|
||||
}
|
||||
let end = term.line_search_right(point);
|
||||
@@ -63,6 +68,13 @@ mod tests {
|
||||
assert_eq!(link(text, 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_s_own_link_is_no_target() {
|
||||
let text = "\x1b]8;;\x7f\x1b\\$ https://ely.dev\x1b]8;;\x1b\\";
|
||||
assert_eq!(link(text, 0, 0), None);
|
||||
assert_eq!(link(text, 0, 4).as_deref(), Some("https://ely.dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_url_ends_before_its_sentence_does() {
|
||||
assert_eq!(link("(a.io/x).", 0, 2), None, "no scheme, no link");
|
||||
|
||||
+110
-9
@@ -1,3 +1,6 @@
|
||||
use std::ffi::c_void;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use block::ConcreteBlock;
|
||||
use objc::{
|
||||
class, msg_send,
|
||||
@@ -5,13 +8,43 @@ use objc::{
|
||||
sel, sel_impl,
|
||||
};
|
||||
|
||||
#[link(name = "Carbon", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
static kTISPropertyUnicodeKeyLayoutData: *const c_void;
|
||||
fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut c_void;
|
||||
fn TISGetInputSourceProperty(source: *mut c_void, key: *const c_void) -> *const c_void;
|
||||
fn LMGetKbdType() -> u16;
|
||||
fn UCKeyTranslate(
|
||||
layout: *const c_void,
|
||||
code: u16,
|
||||
action: u16,
|
||||
modifiers: u32,
|
||||
keyboard: u32,
|
||||
options: u32,
|
||||
dead: *mut u32,
|
||||
most: usize,
|
||||
length: *mut usize,
|
||||
text: *mut u16,
|
||||
) -> u32;
|
||||
}
|
||||
|
||||
#[link(name = "CoreFoundation", kind = "framework")]
|
||||
unsafe extern "C" {
|
||||
fn CFDataGetBytePtr(data: *const c_void) -> *const u8;
|
||||
fn CFRelease(object: *const c_void);
|
||||
}
|
||||
|
||||
/// AppKit's event types for a key going down and coming up.
|
||||
const KEY_DOWN: u64 = 10;
|
||||
const KEY_UP: u64 = 11;
|
||||
|
||||
/// The Command key's bit in an event's modifier flags.
|
||||
/// The Shift and Command keys' bits in an event's modifier flags.
|
||||
const SHIFT: u64 = 1 << 17;
|
||||
const COMMAND: u64 = 1 << 20;
|
||||
|
||||
/// Command in a layout's modifier state, as `UCKeyTranslate` takes it.
|
||||
const LAYOUT_COMMAND: u32 = 1;
|
||||
|
||||
/// The device bits of the left and right Option keys in an event's modifier flags.
|
||||
const LEFT_OPTION: u64 = 0x20;
|
||||
const RIGHT_OPTION: u64 = 0x40;
|
||||
@@ -23,11 +56,15 @@ pub struct KeyFacts {
|
||||
pub right_option: bool,
|
||||
/// Held down; gpui loses this for keys AppKit routes through commands.
|
||||
pub repeat: bool,
|
||||
/// The key's own character, where gpui named the one Shift made and dropped Shift.
|
||||
pub unshifted: Option<char>,
|
||||
/// AppKit's key code, which Shift does not change.
|
||||
pub code: Option<u16>,
|
||||
}
|
||||
|
||||
/// The facts of the key event AppKit is handling now.
|
||||
pub fn key_facts() -> anyhow::Result<KeyFacts> {
|
||||
unsafe {
|
||||
/// The facts of the key event AppKit is handling now, which gpui named `name`, with `shift` or not.
|
||||
pub fn key_facts(name: &str, shift: bool) -> anyhow::Result<KeyFacts> {
|
||||
let (flags, repeat, code) = unsafe {
|
||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||
let event: *mut Object = msg_send![app, currentEvent];
|
||||
anyhow::ensure!(!event.is_null(), "macOS: no key event to read");
|
||||
@@ -38,12 +75,76 @@ pub fn key_facts() -> anyhow::Result<KeyFacts> {
|
||||
);
|
||||
let flags: u64 = msg_send![event, modifierFlags];
|
||||
let repeat: BOOL = msg_send![event, isARepeat];
|
||||
Ok(KeyFacts {
|
||||
left_option: flags & LEFT_OPTION != 0,
|
||||
right_option: flags & RIGHT_OPTION != 0,
|
||||
repeat: repeat != NO,
|
||||
})
|
||||
let code: u16 = msg_send![event, keyCode];
|
||||
(flags, repeat != NO, code)
|
||||
};
|
||||
let one = name.chars().count() == 1;
|
||||
let unshifted = if flags & SHIFT != 0 && !shift && one {
|
||||
let own = own(code, flags)?;
|
||||
log::debug!("macOS: Shift made {name:?} of key {code}, whose own is {own:?}");
|
||||
own
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(KeyFacts {
|
||||
left_option: flags & LEFT_OPTION != 0,
|
||||
right_option: flags & RIGHT_OPTION != 0,
|
||||
repeat,
|
||||
unshifted,
|
||||
code: Some(code),
|
||||
})
|
||||
}
|
||||
|
||||
/// Key `code`'s character without Shift, read as gpui reads key names: Command's layout while Command is down or the plain one types no ASCII.
|
||||
fn own(code: u16, flags: u64) -> anyhow::Result<Option<char>> {
|
||||
let command =
|
||||
flags & COMMAND != 0 || (!typed(0, 0)?.is_ascii() && typed(0, LAYOUT_COMMAND)?.is_ascii());
|
||||
let own = typed(code, if command { LAYOUT_COMMAND } else { 0 })?;
|
||||
let mut chars = own.chars();
|
||||
Ok(match (chars.next(), chars.next()) {
|
||||
(Some(ch), None) if !ch.is_control() => Some(ch),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// What key `code` types under `modifiers` in the current layout; a dead key types its own mark.
|
||||
fn typed(code: u16, modifiers: u32) -> anyhow::Result<String> {
|
||||
const SPACE: u16 = 49;
|
||||
let mut text = [0u16; 4];
|
||||
let mut length = 0;
|
||||
unsafe {
|
||||
let source = TISCopyCurrentKeyboardLayoutInputSource();
|
||||
anyhow::ensure!(!source.is_null(), "macOS: no keyboard layout");
|
||||
let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData);
|
||||
if !data.is_null() {
|
||||
let layout = CFDataGetBytePtr(data).cast::<c_void>();
|
||||
let keyboard = u32::from(LMGetKbdType());
|
||||
let mut dead = 0;
|
||||
for key in [code, SPACE] {
|
||||
UCKeyTranslate(
|
||||
layout,
|
||||
key,
|
||||
0,
|
||||
modifiers,
|
||||
keyboard,
|
||||
0,
|
||||
&mut dead,
|
||||
text.len(),
|
||||
&mut length,
|
||||
text.as_mut_ptr(),
|
||||
);
|
||||
if dead == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
CFRelease(source);
|
||||
anyhow::ensure!(
|
||||
!data.is_null(),
|
||||
"macOS: the keyboard layout has no key table"
|
||||
);
|
||||
}
|
||||
String::from_utf16(&text[..length]).context("macOS: the layout typed no UTF-16")
|
||||
}
|
||||
|
||||
/// Turns off the accent menu a held key opens, so held keys repeat as in other terminals.
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/// A semantic prompt mark a program sends, OSC 133.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
/// A prompt starts: A, or N.
|
||||
Prompt,
|
||||
/// The prompt ends and input starts: B, or I.
|
||||
Input,
|
||||
/// Input ends and the command's output starts: C.
|
||||
Output,
|
||||
/// The command ended, with the status it gave: D.
|
||||
Done(Option<i32>),
|
||||
}
|
||||
|
||||
/// The mark in an OSC 133 body past `133;`; none for one the terminal takes no action on.
|
||||
pub(crate) fn read(body: &[u8]) -> Option<Mark> {
|
||||
let shown = || String::from_utf8_lossy(body);
|
||||
let Some((&letter, rest)) = body.split_first() else {
|
||||
log::warn!("pty: OSC 133 without a mark");
|
||||
return None;
|
||||
};
|
||||
let options = match rest {
|
||||
[] => rest,
|
||||
[b';', options @ ..] => options,
|
||||
_ => {
|
||||
log::warn!("pty: OSC 133;{} is no mark", shown());
|
||||
return None;
|
||||
}
|
||||
};
|
||||
match letter {
|
||||
b'A' | b'N' => Some(Mark::Prompt),
|
||||
b'B' | b'I' => Some(Mark::Input),
|
||||
b'C' => Some(Mark::Output),
|
||||
b'D' => Some(Mark::Done(status(options))),
|
||||
// L's fresh line needs the cursor, unseen before the parser; P adds nothing to A.
|
||||
b'L' | b'P' => {
|
||||
log::debug!("pty: OSC 133;{} takes no action here", shown());
|
||||
None
|
||||
}
|
||||
_ => {
|
||||
log::warn!("pty: OSC 133;{} is no mark", shown());
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D's status, its first option; none when the program gave none or no number.
|
||||
fn status(options: &[u8]) -> Option<i32> {
|
||||
let first = options.split(|&byte| byte == b';').next()?;
|
||||
if first.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let status = std::str::from_utf8(first)
|
||||
.ok()
|
||||
.and_then(|text| text.parse().ok());
|
||||
if status.is_none() {
|
||||
log::warn!(
|
||||
"pty: OSC 133;D with a status that is no number ({})",
|
||||
String::from_utf8_lossy(first)
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn each_mark_reads_with_its_options_as_ghostty_reads_it() {
|
||||
assert_eq!(read(b"A"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"A;click_events=1"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"B"), Some(Mark::Input));
|
||||
assert_eq!(read(b"I"), Some(Mark::Input));
|
||||
assert_eq!(read(b"C;cmdline_url=false"), Some(Mark::Output));
|
||||
assert_eq!(read(b"D;1"), Some(Mark::Done(Some(1))));
|
||||
assert_eq!(read(b"D;127;aid=foo"), Some(Mark::Done(Some(127))));
|
||||
assert_eq!(read(b"D;-1"), Some(Mark::Done(Some(-1))));
|
||||
assert_eq!(read(b"D"), Some(Mark::Done(None)));
|
||||
assert_eq!(read(b"D;"), Some(Mark::Done(None)));
|
||||
assert_eq!(read(b"D;abc"), Some(Mark::Done(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_the_terminal_takes_no_action_on_reads_as_none() {
|
||||
for body in [&b""[..], b"L", b"P;k=s", b"AB", b"Q", b"a"] {
|
||||
assert_eq!(read(body), None, "{}", String::from_utf8_lossy(body));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,32 @@
|
||||
mod boxes;
|
||||
mod find;
|
||||
pub mod frame;
|
||||
mod herdr;
|
||||
pub mod ink;
|
||||
pub mod input;
|
||||
pub mod keys;
|
||||
mod links;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod mac;
|
||||
mod marks;
|
||||
pub mod mouse;
|
||||
mod notify;
|
||||
mod paint;
|
||||
mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
mod reports;
|
||||
pub mod shell;
|
||||
mod status;
|
||||
mod tap;
|
||||
mod titles;
|
||||
#[cfg(unix)]
|
||||
mod unix;
|
||||
mod view;
|
||||
#[cfg(windows)]
|
||||
mod win32;
|
||||
pub mod zoom;
|
||||
|
||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||
pub use status::{Kind, Record, Records, State};
|
||||
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
|
||||
@@ -0,0 +1,389 @@
|
||||
use base64::Engine;
|
||||
|
||||
use super::status::BASE64;
|
||||
|
||||
/// A notification a program asks for: OSC 9, 777 or 99.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Notice {
|
||||
/// OSC 99's identifier, which updates, closes and answers name; none for OSC 9, 777 and OSC 99 without one.
|
||||
pub id: Option<String>,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
/// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`).
|
||||
pub focus: bool,
|
||||
pub report: bool,
|
||||
/// The program hears of its close: `c=1`.
|
||||
pub close: bool,
|
||||
/// Milliseconds it lasts: `w` over 0.
|
||||
pub expire: Option<u32>,
|
||||
}
|
||||
|
||||
/// Where a program's work stands, OSC 9;4.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Progress {
|
||||
Remove,
|
||||
Set(Option<u8>),
|
||||
Error(Option<u8>),
|
||||
Indeterminate,
|
||||
Pause(Option<u8>),
|
||||
}
|
||||
|
||||
/// What a program asks of notifications and progress.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Ask {
|
||||
Show(Notice),
|
||||
/// Closes the notification this id names: OSC 99's `p=close`.
|
||||
Close(String),
|
||||
/// Which notifications are alive; the answer names this id: `p=alive`.
|
||||
Alive(String),
|
||||
/// What the terminal does with OSC 99; the answer names this id: `p=?`.
|
||||
Query(String),
|
||||
Progress(Progress),
|
||||
}
|
||||
|
||||
/// The ask in an OSC 9 body past `9;`: ConEmu's commands, 9;4 among them, else iTerm2's notification, as Ghostty reads them.
|
||||
pub fn nine(body: &[u8]) -> Option<Ask> {
|
||||
let semi = |at: usize| body.get(at) == Some(&b';');
|
||||
let conemu = match body {
|
||||
[] => false,
|
||||
[b'1', b';', ..] | [b'1', b'0'] => true,
|
||||
[b'1', b'0', b';', b'0'..=b'3', ..] | [b'1', b'1', b';', ..] | [b'1', b'2', ..] => true,
|
||||
[b'4', b';', state @ b'0'..=b'4', ..] => {
|
||||
return Some(Ask::Progress(progress(*state, &body[3..])));
|
||||
}
|
||||
[b'5', ..] => true,
|
||||
[b'2' | b'3' | b'6' | b'7' | b'8' | b'9', ..] => semi(1),
|
||||
_ => false,
|
||||
};
|
||||
if conemu {
|
||||
log::debug!(
|
||||
"pty: OSC 9;{} is ConEmu's, no notification",
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
return None;
|
||||
}
|
||||
let text = safe(body, "OSC 9")?;
|
||||
(!text.is_empty()).then(|| Ask::Show(plain(text, String::new())))
|
||||
}
|
||||
|
||||
/// OSC 9;4's state and the progress after it, clamped to 100.
|
||||
fn progress(state: u8, rest: &[u8]) -> Progress {
|
||||
let value = rest
|
||||
.strip_prefix(b";")
|
||||
.and_then(|digits| std::str::from_utf8(digits).ok())
|
||||
.filter(|digits| !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.and_then(|digits| digits.parse::<u64>().ok())
|
||||
.map(|value| value.min(100) as u8);
|
||||
match state {
|
||||
b'0' => Progress::Remove,
|
||||
// Set alone means 0, as in Ghostty; a value that does not read means none.
|
||||
b'1' if rest.is_empty() => Progress::Set(Some(0)),
|
||||
b'1' => Progress::Set(value),
|
||||
b'2' => Progress::Error(value),
|
||||
b'3' => Progress::Indeterminate,
|
||||
_ => Progress::Pause(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// The notification in an OSC 777 body past `777;`: rxvt's `notify;title;body`.
|
||||
pub fn rxvt(body: &[u8]) -> Option<Ask> {
|
||||
let Some(rest) = body.strip_prefix(b"notify;") else {
|
||||
log::warn!(
|
||||
"pty: OSC 777;{} is no rxvt notify",
|
||||
String::from_utf8_lossy(body)
|
||||
);
|
||||
return None;
|
||||
};
|
||||
let Some(at) = rest.iter().position(|&byte| byte == b';') else {
|
||||
log::warn!("pty: OSC 777 notify without a title");
|
||||
return None;
|
||||
};
|
||||
let (title, body) = (
|
||||
safe(&rest[..at], "OSC 777")?,
|
||||
safe(&rest[at + 1..], "OSC 777")?,
|
||||
);
|
||||
(!title.is_empty() || !body.is_empty()).then(|| Ask::Show(plain(title, body)))
|
||||
}
|
||||
|
||||
/// A notification with no options, the body standing in for a missing title.
|
||||
fn plain(title: String, body: String) -> Notice {
|
||||
Notice {
|
||||
id: None,
|
||||
title,
|
||||
body,
|
||||
focus: true,
|
||||
report: false,
|
||||
close: false,
|
||||
expire: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with no control character, else none, logged with `what` sent it.
|
||||
fn safe(bytes: &[u8], what: &str) -> Option<String> {
|
||||
let text = String::from_utf8(bytes.to_vec())
|
||||
.inspect_err(|_| log::warn!("pty: {what} text is not UTF-8"))
|
||||
.ok()?;
|
||||
if text.chars().any(char::is_control) {
|
||||
log::warn!("pty: {what} text holds a control character");
|
||||
return None;
|
||||
}
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// gpui closes no notification on Linux, nor replaces one, so close and expiry hold elsewhere alone.
|
||||
pub(super) const CLOSES: bool = !cfg!(target_os = "linux");
|
||||
|
||||
/// The answer to OSC 99's query, naming `id`: a press focuses and reports, a notification posts while unseen, its payloads, and close and expiry where gpui closes one.
|
||||
pub fn answer(id: &str) -> String {
|
||||
let (close, expire) = if CLOSES { ("close,", ":w=1") } else { ("", "") };
|
||||
format!(
|
||||
"\x1b]99;i={id}:p=?;a=focus,report:o=invisible:p=title,body,{close}?,alive{expire}\x1b\\"
|
||||
)
|
||||
}
|
||||
|
||||
/// The answer to OSC 99's `p=alive`, naming `id`: the ids of notifications still alive.
|
||||
pub fn alive(id: &str, alive: &[&str]) -> String {
|
||||
format!("\x1b]99;i={id}:p=alive;{}\x1b\\", alive.join(","))
|
||||
}
|
||||
|
||||
/// What `c=1` hears of notification `id`: its close goes untracked, as gpui hears of none.
|
||||
pub fn untracked(id: &str) -> String {
|
||||
format!("\x1b]99;i={id}:p=close;untracked\x1b\\")
|
||||
}
|
||||
|
||||
/// What `a=report` hears when notification `id` is pressed.
|
||||
pub fn activated(id: &str) -> String {
|
||||
format!("\x1b]99;i={id};\x1b\\")
|
||||
}
|
||||
|
||||
/// An OSC 99 payload's most bytes: plain, and base64; and its metadata's.
|
||||
const PLAIN: usize = 2048;
|
||||
const ENCODED: usize = 4096;
|
||||
const METADATA: usize = 1024;
|
||||
|
||||
/// The longest OSC 99 body the tap's scanner keeps: `99;`, metadata, `;` and a payload.
|
||||
pub(super) const BODY: usize = 3 + METADATA + 1 + ENCODED;
|
||||
|
||||
/// The most text one notification gathers from its chunks, and how many gather at once.
|
||||
const GATHERED: usize = 16384;
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// A payload gathering its chunks: its bytes so far, and base64 short of a whole group.
|
||||
#[derive(Default)]
|
||||
struct Text {
|
||||
bytes: Vec<u8>,
|
||||
rest: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
/// Adds a chunk: plain text as it is, base64 by whole groups, so a program may chunk before encoding or after.
|
||||
fn add(&mut self, payload: &[u8], encoded: bool) -> Result<(), String> {
|
||||
// Plain text is UTF-8 without a control character; base64 holds any UTF-8, checked once whole.
|
||||
if !encoded {
|
||||
let text = std::str::from_utf8(payload).map_err(|_| "payload is not UTF-8")?;
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err("payload holds a control character".into());
|
||||
}
|
||||
self.finish()?;
|
||||
self.bytes.extend_from_slice(payload);
|
||||
return Ok(());
|
||||
}
|
||||
self.rest.extend_from_slice(payload);
|
||||
let whole = self.rest.len() / 4 * 4;
|
||||
for group in self.rest[..whole].chunks(4) {
|
||||
self.bytes.extend(decode(group)?);
|
||||
}
|
||||
self.rest.drain(..whole);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes the base64 left, padded or not, as the last chunk may leave it.
|
||||
fn finish(&mut self) -> Result<(), String> {
|
||||
if !self.rest.is_empty() {
|
||||
self.bytes.extend(decode(&self.rest)?);
|
||||
self.rest.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.bytes.len() + self.rest.len()
|
||||
}
|
||||
}
|
||||
|
||||
fn decode(base64: &[u8]) -> Result<Vec<u8>, String> {
|
||||
BASE64
|
||||
.decode(base64)
|
||||
.map_err(|error| format!("payload is no base64: {error}"))
|
||||
}
|
||||
|
||||
/// A notification gathering its chunks.
|
||||
struct Gathering {
|
||||
notice: Notice,
|
||||
title: Text,
|
||||
body: Text,
|
||||
}
|
||||
|
||||
/// OSC 99 notifications gathered from their chunks, by id.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Kitty {
|
||||
pending: Vec<(String, Gathering)>,
|
||||
}
|
||||
|
||||
impl Kitty {
|
||||
/// The ask an OSC 99 body past `99;` completes, logged with why when there is none.
|
||||
pub fn read(&mut self, body: &[u8]) -> Option<Ask> {
|
||||
self.parse(body)
|
||||
.inspect_err(|why| log::warn!("pty: OSC 99 dropped: {why}"))
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
fn parse(&mut self, body: &[u8]) -> Result<Option<Ask>, String> {
|
||||
let at = body
|
||||
.iter()
|
||||
.position(|&byte| byte == b';')
|
||||
.ok_or("no payload")?;
|
||||
let (metadata, payload) = (&body[..at], &body[at + 1..]);
|
||||
if metadata.len() > METADATA {
|
||||
return Err(format!("metadata over {METADATA} bytes"));
|
||||
}
|
||||
let mut options: Vec<(u8, &[u8])> = Vec::new();
|
||||
for pair in metadata
|
||||
.split(|&byte| byte == b':')
|
||||
.filter(|pair| !pair.is_empty())
|
||||
{
|
||||
match pair {
|
||||
[key, b'=', value @ ..] if key.is_ascii_alphabetic() => options.push((*key, value)),
|
||||
_ => log::debug!("pty: OSC 99 passes {} by", String::from_utf8_lossy(pair)),
|
||||
}
|
||||
}
|
||||
let get = |key: u8| {
|
||||
options
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(name, _)| *name == key)
|
||||
.map(|(_, value)| *value)
|
||||
};
|
||||
let id = match get(b'i') {
|
||||
Some(id)
|
||||
if id
|
||||
.iter()
|
||||
.all(|&byte| byte.is_ascii_alphanumeric() || b"_-+.".contains(&byte)) =>
|
||||
{
|
||||
Some(String::from_utf8(id.to_vec()).expect("ASCII"))
|
||||
}
|
||||
Some(id) => {
|
||||
return Err(format!(
|
||||
"id {} holds a byte ids may not",
|
||||
String::from_utf8_lossy(id)
|
||||
));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let encoded = get(b'e') == Some(b"1");
|
||||
let limit = if encoded { ENCODED } else { PLAIN };
|
||||
if payload.len() > limit {
|
||||
return Err(format!("a payload over {limit} bytes"));
|
||||
}
|
||||
// An answer names the asker's id, or 0 without one, as kitty's spec says.
|
||||
let named = || id.clone().unwrap_or_else(|| "0".into());
|
||||
match get(b'p').unwrap_or(b"title") {
|
||||
b"?" => return Ok(Some(Ask::Query(named()))),
|
||||
b"alive" => return Ok(Some(Ask::Alive(named()))),
|
||||
b"close" => return Ok(id.map(Ask::Close)),
|
||||
b"title" | b"body" => {}
|
||||
other => {
|
||||
log::debug!(
|
||||
"pty: OSC 99 payload {} passed by",
|
||||
String::from_utf8_lossy(other)
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
let key = id.clone().unwrap_or_default();
|
||||
let at = match self.pending.iter().position(|(name, _)| *name == key) {
|
||||
Some(at) => at,
|
||||
None if self.pending.len() == PENDING => {
|
||||
return Err(format!("{PENDING} notifications gathering already"));
|
||||
}
|
||||
None => {
|
||||
let notice = Notice {
|
||||
id: id.clone(),
|
||||
..plain(String::new(), String::new())
|
||||
};
|
||||
let (title, body) = (Text::default(), Text::default());
|
||||
self.pending.push((
|
||||
key,
|
||||
Gathering {
|
||||
notice,
|
||||
title,
|
||||
body,
|
||||
},
|
||||
));
|
||||
self.pending.len() - 1
|
||||
}
|
||||
};
|
||||
let gathering = &mut self.pending[at].1;
|
||||
let text = if get(b'p') == Some(b"body") {
|
||||
&mut gathering.body
|
||||
} else {
|
||||
&mut gathering.title
|
||||
};
|
||||
// A chunk that breaks the rules drops what its notification gathered.
|
||||
if let Err(why) = text.add(payload, encoded) {
|
||||
self.pending.remove(at);
|
||||
return Err(why);
|
||||
}
|
||||
if gathering.title.len() + gathering.body.len() > GATHERED {
|
||||
self.pending.remove(at);
|
||||
return Err(format!("a notification over {GATHERED} bytes"));
|
||||
}
|
||||
let notice = &mut gathering.notice;
|
||||
if let Some(actions) = get(b'a') {
|
||||
for action in actions.split(|&byte| byte == b',') {
|
||||
let (on, name) = match action.strip_prefix(b"-") {
|
||||
Some(name) => (false, name),
|
||||
None => (true, action),
|
||||
};
|
||||
match name {
|
||||
b"focus" => notice.focus = on,
|
||||
b"report" => notice.report = on,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(close) = get(b'c') {
|
||||
notice.close = close == b"1";
|
||||
}
|
||||
if let Some(expire) = get(b'w') {
|
||||
notice.expire = std::str::from_utf8(expire)
|
||||
.ok()
|
||||
.and_then(|ms| ms.parse::<u32>().ok())
|
||||
.filter(|&ms| ms > 0);
|
||||
}
|
||||
if get(b'd') == Some(b"0") {
|
||||
return Ok(None);
|
||||
}
|
||||
let (_, gathering) = self.pending.remove(at);
|
||||
let Gathering {
|
||||
mut notice,
|
||||
mut title,
|
||||
mut body,
|
||||
} = gathering;
|
||||
title.finish()?;
|
||||
body.finish()?;
|
||||
notice.title = String::from_utf8(title.bytes).map_err(|_| "title is not UTF-8")?;
|
||||
notice.body = String::from_utf8(body.bytes).map_err(|_| "body is not UTF-8")?;
|
||||
if notice.title.is_empty() && notice.body.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if notice.title.is_empty() {
|
||||
notice.title = std::mem::take(&mut notice.body);
|
||||
}
|
||||
Ok(Some(Ask::Show(notice)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,235 @@
|
||||
use super::*;
|
||||
|
||||
fn shown(ask: Option<Ask>) -> Notice {
|
||||
match ask {
|
||||
Some(Ask::Show(notice)) => notice,
|
||||
other => panic!("no notification: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_is_iterm2_s_notification_unless_it_is_conemu_s_command() {
|
||||
assert_eq!(shown(nine(b"Hello world")).title, "Hello world");
|
||||
for command in [
|
||||
&b"1;100"[..],
|
||||
b"10",
|
||||
b"10;2",
|
||||
b"11;note",
|
||||
b"12",
|
||||
b"2;box",
|
||||
b"3;",
|
||||
b"3;title",
|
||||
b"5",
|
||||
b"5x",
|
||||
b"6;macro",
|
||||
b"7;cmd",
|
||||
b"8;VAR",
|
||||
b"9;/path",
|
||||
] {
|
||||
assert_eq!(nine(command), None, "{}", String::from_utf8_lossy(command));
|
||||
}
|
||||
for text in [
|
||||
"1", "1a", "10x", "10;5", "13", "2", "2a", "4", "4;", "4;9", "x;y",
|
||||
] {
|
||||
assert_eq!(shown(nine(text.as_bytes())).title, text);
|
||||
}
|
||||
assert_eq!(nine(b""), None);
|
||||
assert_eq!(nine(b"a\xffb"), None, "not UTF-8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_reads_progress_as_ghostty_does() {
|
||||
let progress = |body: &[u8]| match nine(body) {
|
||||
Some(Ask::Progress(progress)) => progress,
|
||||
other => panic!("no progress: {other:?}"),
|
||||
};
|
||||
assert_eq!(progress(b"4;0"), Progress::Remove);
|
||||
assert_eq!(progress(b"4;1"), Progress::Set(Some(0)));
|
||||
assert_eq!(progress(b"4;1;40"), Progress::Set(Some(40)));
|
||||
assert_eq!(progress(b"4;1;900"), Progress::Set(Some(100)));
|
||||
assert_eq!(progress(b"4;1;x"), Progress::Set(None));
|
||||
assert_eq!(
|
||||
progress(b"4;1;99999999999999999999999"),
|
||||
Progress::Set(None)
|
||||
);
|
||||
assert_eq!(progress(b"4;1;1_0"), Progress::Set(None));
|
||||
assert_eq!(progress(b"4;2"), Progress::Error(None));
|
||||
assert_eq!(progress(b"4;2;50"), Progress::Error(Some(50)));
|
||||
assert_eq!(progress(b"4;3;7"), Progress::Indeterminate);
|
||||
assert_eq!(progress(b"4;4;10"), Progress::Pause(Some(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_777_is_rxvt_s_notify() {
|
||||
let notice = shown(rxvt(b"notify;Title;Body;more"));
|
||||
assert_eq!(
|
||||
(notice.title.as_str(), notice.body.as_str()),
|
||||
("Title", "Body;more")
|
||||
);
|
||||
assert_eq!(rxvt(b"other;x;y"), None);
|
||||
assert_eq!(rxvt(b"notify;Title"), None, "no body");
|
||||
assert_eq!(rxvt(b"notify;;"), None, "nothing to say");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_gathers_chunks_by_id_and_reads_its_options() {
|
||||
let mut kitty = Kitty::default();
|
||||
assert_eq!(kitty.read(b"i=a:d=0;Hel"), None);
|
||||
assert_eq!(kitty.read(b"i=b:d=0:p=body;other"), None);
|
||||
assert_eq!(kitty.read(b"i=a:d=0:p=body;Wor"), None);
|
||||
let notice = shown(kitty.read(b"i=a:p=body:o=unfocused:a=report,-focus:c=1:w=5000;ld"));
|
||||
assert_eq!(
|
||||
(
|
||||
notice.id.as_deref(),
|
||||
notice.title.as_str(),
|
||||
notice.body.as_str()
|
||||
),
|
||||
(Some("a"), "Hel", "World")
|
||||
);
|
||||
assert_eq!(
|
||||
(notice.focus, notice.report, notice.close, notice.expire),
|
||||
(false, true, true, Some(5000)),
|
||||
"o passes by"
|
||||
);
|
||||
let other = shown(kitty.read(b"i=b:p=body;!"));
|
||||
assert_eq!(
|
||||
(other.title.as_str(), other.body.as_str()),
|
||||
("other!", ""),
|
||||
"a lone body is the title"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_decodes_base64_and_takes_plain_text() {
|
||||
let mut kitty = Kitty::default();
|
||||
let notice = shown(kitty.read(b"e=1:p=body;SGVsbG8gd29ybGQ="));
|
||||
assert_eq!((notice.title.as_str(), notice.id), ("Hello world", None));
|
||||
let plain = shown(kitty.read(b";Hello world"));
|
||||
assert_eq!(
|
||||
(plain.title.as_str(), plain.focus, plain.report),
|
||||
("Hello world", true, false)
|
||||
);
|
||||
assert_eq!(
|
||||
kitty.read(b"bad:i=c;x").map(|_| ()),
|
||||
Some(()),
|
||||
"a malformed pair passes by"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_gathers_base64_chunked_before_or_after_encoding() {
|
||||
let title = |chunks: &[&str]| {
|
||||
let mut kitty = Kitty::default();
|
||||
let (last, first) = chunks.split_last().expect("a chunk");
|
||||
for chunk in first {
|
||||
assert_eq!(kitty.read(format!("i=a:d=0:e=1;{chunk}").as_bytes()), None);
|
||||
}
|
||||
shown(kitty.read(format!("i=a:e=1;{last}").as_bytes())).title
|
||||
};
|
||||
assert_eq!(title(&["SGVsb", "G8="]), "Hello", "split inside a group");
|
||||
assert_eq!(title(&["8J+Y", "gA=="]), "😀", "split inside a character");
|
||||
assert_eq!(
|
||||
title(&["SGVsbG8=", "IHdvcmxk"]),
|
||||
"Hello world",
|
||||
"each chunk padded"
|
||||
);
|
||||
assert_eq!(title(&["SGVsbG", "8"]), "Hello", "the last chunk unpadded");
|
||||
let mut kitty = Kitty::default();
|
||||
assert_eq!(kitty.read(b"i=n:d=0;Title"), None);
|
||||
let notice = shown(kitty.read(b"i=n:p=body:e=1;TGluZSAxCkxpbmUgMg=="));
|
||||
assert_eq!(
|
||||
(notice.title.as_str(), notice.body.as_str()),
|
||||
("Title", "Line 1\nLine 2"),
|
||||
"base64 carries any UTF-8"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_asks_to_close_and_what_is_alive_and_supported() {
|
||||
let mut kitty = Kitty::default();
|
||||
assert_eq!(kitty.read(b"i=x:p=close;"), Some(Ask::Close("x".into())));
|
||||
assert_eq!(kitty.read(b"p=close;"), None, "no id, no close");
|
||||
assert_eq!(kitty.read(b"i=q:p=?;"), Some(Ask::Query("q".into())));
|
||||
assert_eq!(kitty.read(b"p=?;"), Some(Ask::Query("0".into())));
|
||||
assert_eq!(kitty.read(b"i=y:p=alive;"), Some(Ask::Alive("y".into())));
|
||||
assert_eq!(
|
||||
kitty.read(b"p=icon;x"),
|
||||
None,
|
||||
"an unknown payload passes by"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_drops_what_breaks_its_rules() {
|
||||
let mut kitty = Kitty::default();
|
||||
let long = |length: usize, metadata: &str| {
|
||||
let mut body = format!("{metadata};").into_bytes();
|
||||
body.extend(std::iter::repeat_n(b'a', length));
|
||||
body
|
||||
};
|
||||
for body in [
|
||||
b"no payload".to_vec(),
|
||||
b"i=a b;x".to_vec(),
|
||||
b"i=a\"b;x".to_vec(),
|
||||
b"e=1;!!!".to_vec(),
|
||||
b"e=1;/w==".to_vec(),
|
||||
"x\u{85}y"
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(b";".to_vec(), |mut body, byte| {
|
||||
body.push(byte);
|
||||
body
|
||||
}),
|
||||
b";a\tb".to_vec(),
|
||||
b"e=1;SGVsbG8gS".to_vec(),
|
||||
long(PLAIN + 1, ""),
|
||||
long(ENCODED + 4, "e=1"),
|
||||
long(1, &"x".repeat(METADATA + 1)),
|
||||
] {
|
||||
assert_eq!(
|
||||
kitty.read(&body),
|
||||
None,
|
||||
"{}",
|
||||
String::from_utf8_lossy(&body)
|
||||
);
|
||||
}
|
||||
assert_eq!(kitty.read(b"i=k:d=0;Hel"), None);
|
||||
assert_eq!(kitty.read(b"i=k:d=0:e=1;!!!!"), None);
|
||||
assert!(kitty.pending.is_empty(), "a bad chunk drops what gathered");
|
||||
for at in 0..PENDING {
|
||||
assert_eq!(kitty.read(format!("i=p{at}:d=0;x").as_bytes()), None);
|
||||
}
|
||||
assert_eq!(kitty.read(b"i=over:d=0;x"), None);
|
||||
assert!(
|
||||
kitty.pending.iter().all(|(id, _)| id != "over"),
|
||||
"past the cap nothing gathers"
|
||||
);
|
||||
let mut big = Kitty::default();
|
||||
for _ in 0..GATHERED / PLAIN {
|
||||
assert_eq!(big.read(&long(PLAIN, "i=g:d=0")), None);
|
||||
}
|
||||
assert_eq!(
|
||||
big.read(&long(PLAIN, "i=g")),
|
||||
None,
|
||||
"over the gathered bound"
|
||||
);
|
||||
assert!(big.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_answers_name_the_asker_s_id() {
|
||||
let offered = if CLOSES {
|
||||
"p=title,body,close,?,alive:w=1"
|
||||
} else {
|
||||
"p=title,body,?,alive"
|
||||
};
|
||||
assert_eq!(
|
||||
answer("q"),
|
||||
format!("\x1b]99;i=q:p=?;a=focus,report:o=invisible:{offered}\x1b\\")
|
||||
);
|
||||
assert_eq!(alive("0", &[]), "\x1b]99;i=0:p=alive;\x1b\\");
|
||||
assert_eq!(alive("z", &["a", "b"]), "\x1b]99;i=z:p=alive;a,b\x1b\\");
|
||||
assert_eq!(untracked("n1"), "\x1b]99;i=n1:p=close;untracked\x1b\\");
|
||||
assert_eq!(activated("0"), "\x1b]99;i=0;\x1b\\");
|
||||
}
|
||||
+13
-3
@@ -80,13 +80,16 @@ impl TerminalView {
|
||||
modifiers: &Modifiers,
|
||||
tracking: Tracking,
|
||||
) -> bool {
|
||||
let wheel = matches!(button, Some(Button::WheelUp | Button::WheelDown));
|
||||
// herdr's link key is Ctrl; on macOS Cmd stands for it, the wheel aside.
|
||||
let link = cfg!(target_os = "macos") && self.herdr && modifiers.platform && !wheel;
|
||||
let report = Report {
|
||||
button,
|
||||
gesture,
|
||||
cell,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
ctrl: modifiers.control,
|
||||
ctrl: modifiers.control || link,
|
||||
};
|
||||
let format = self.mouse.format;
|
||||
let Some(bytes) = mouse::encode(&report, MouseMode { tracking, format }) else {
|
||||
@@ -111,7 +114,15 @@ impl TerminalView {
|
||||
} else {
|
||||
event.modifiers.control
|
||||
};
|
||||
if event.button == MouseButton::Left && opens {
|
||||
let tracking = self.tracking(&event.modifiers);
|
||||
// herdr opens its panes' links itself, whole where its drawing wraps them.
|
||||
let herdr = self.herdr && tracking != Tracking::Off;
|
||||
if event.button == MouseButton::Left && opens && herdr {
|
||||
log::debug!(
|
||||
"terminal {}: herdr takes the link press at {cell:?}",
|
||||
self.pty.pid
|
||||
);
|
||||
} else if event.button == MouseButton::Left && opens {
|
||||
let link = link_at(&self.pty.term.lock(), point);
|
||||
if let Some(link) = link {
|
||||
log::info!("terminal {}: opens {link}", self.pty.pid);
|
||||
@@ -119,7 +130,6 @@ impl TerminalView {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let tracking = self.tracking(&event.modifiers);
|
||||
if tracking != Tracking::Off {
|
||||
let Some(pressed) = button(event.button) else {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use alacritty_terminal::{
|
||||
grid::{Dimensions, Row, Scroll},
|
||||
index::Line,
|
||||
term::{Term, cell::Cell},
|
||||
};
|
||||
use gpui::Context;
|
||||
|
||||
use super::{tap::MOST, view::TerminalView};
|
||||
|
||||
/// The target of the OSC 8 link a prompt becomes; no program names a link so.
|
||||
pub(crate) const LINK: &str = "\u{7f}";
|
||||
|
||||
/// What a link's id starts with when it carries a prompt's number: the prompt's own link, or a program's inside it.
|
||||
const NUMBERED: char = '\u{7f}';
|
||||
|
||||
/// What ends a prompt's link.
|
||||
const CLOSE: &[u8] = b"\x1b]8;;\x07";
|
||||
|
||||
/// What a held sequence's rewrite comes to at most: a link with the longest target the scanner keeps.
|
||||
pub(super) const SLACK: usize = MOST + 32;
|
||||
|
||||
/// How many times its size a rewrite makes a sequence at most: a mark of 8 bytes inside a program's link becomes that link again.
|
||||
pub(super) const GROWTH: usize = SLACK / 8 + 1;
|
||||
|
||||
const ESC: u8 = 0x1b;
|
||||
|
||||
/// Controls vte skips inside an OSC.
|
||||
fn ignored(byte: u8) -> bool {
|
||||
matches!(byte, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum State {
|
||||
#[default]
|
||||
Ground,
|
||||
/// After an ESC, which waits while the parser stands where it stood before it, else has gone on.
|
||||
Escape { sent: bool },
|
||||
/// An OSC held whole, since it may be a mark or a link to rewrite.
|
||||
Held,
|
||||
/// An OSC passed on as it comes.
|
||||
Passing,
|
||||
}
|
||||
|
||||
/// Rewrites what goes to the parser so it stamps each prompt's cells with a link of its own, numbered per prompt.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Prompts {
|
||||
state: State,
|
||||
/// The escape or OSC held, from its ESC.
|
||||
held: Vec<u8>,
|
||||
/// The OSC's body as vte reads it, the controls it skips left out.
|
||||
body: Vec<u8>,
|
||||
/// The body passed the scanner's bound, so it counts for nothing.
|
||||
overflow: bool,
|
||||
/// The held OSC's ESC has gone on: it ended the OSC before.
|
||||
sent: bool,
|
||||
/// The prompt open now, from its start mark to its end.
|
||||
prompt: Option<u32>,
|
||||
/// The target of a program's link open now, to open it again with a prompt's number and without.
|
||||
target: Option<Vec<u8>>,
|
||||
/// The last prompt's number.
|
||||
count: u32,
|
||||
/// What goes to the parser from the last read.
|
||||
out: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Prompts {
|
||||
/// The bytes held from earlier reads.
|
||||
pub fn held(&self) -> usize {
|
||||
self.held.len()
|
||||
}
|
||||
|
||||
/// What goes to the parser for `read`: marks become links, and a held start waits for its end.
|
||||
pub fn pass(&mut self, read: &[u8]) -> &[u8] {
|
||||
self.out.clear();
|
||||
for &byte in read {
|
||||
self.step(byte);
|
||||
}
|
||||
&self.out
|
||||
}
|
||||
|
||||
fn step(&mut self, byte: u8) {
|
||||
self.state = match (self.state, byte) {
|
||||
(State::Ground, ESC) => {
|
||||
self.held.push(ESC);
|
||||
State::Escape { sent: false }
|
||||
}
|
||||
(State::Ground, _) => {
|
||||
self.out.push(byte);
|
||||
State::Ground
|
||||
}
|
||||
(State::Escape { sent }, b']') => {
|
||||
self.held.push(byte);
|
||||
self.sent = sent;
|
||||
self.body.clear();
|
||||
self.overflow = false;
|
||||
State::Held
|
||||
}
|
||||
(State::Escape { .. }, ESC) => {
|
||||
self.release();
|
||||
self.held.push(ESC);
|
||||
State::Escape { sent: false }
|
||||
}
|
||||
// vte runs these and stays in the escape; one that waits lets them on first, where the parser stands the same.
|
||||
(State::Escape { sent }, _) if byte == 0x07 || ignored(byte) => {
|
||||
self.out.push(byte);
|
||||
State::Escape { sent }
|
||||
}
|
||||
// vte passes DEL and high bytes by in the escape: the ESC goes on, then they.
|
||||
(State::Escape { sent }, 0x7f..=0xff) => {
|
||||
if !sent {
|
||||
self.release();
|
||||
}
|
||||
self.out.push(byte);
|
||||
State::Escape { sent: true }
|
||||
}
|
||||
(State::Escape { .. }, _) => {
|
||||
// A full reset clears the link the parser writes with.
|
||||
if byte == b'c' {
|
||||
(self.prompt, self.target) = (None, None);
|
||||
}
|
||||
self.held.push(byte);
|
||||
self.release();
|
||||
State::Ground
|
||||
}
|
||||
(State::Held | State::Passing, 0x07 | 0x18 | 0x1a) => {
|
||||
self.end(Some(byte));
|
||||
State::Ground
|
||||
}
|
||||
// An ESC ends the OSC: after a rewrite the parser stands in ground, else inside the OSC, so the ESC goes on.
|
||||
(State::Held | State::Passing, ESC) => {
|
||||
if self.end(None) {
|
||||
self.held.push(ESC);
|
||||
State::Escape { sent: false }
|
||||
} else {
|
||||
self.out.push(ESC);
|
||||
State::Escape { sent: true }
|
||||
}
|
||||
}
|
||||
(State::Held, _) => {
|
||||
self.held.push(byte);
|
||||
self.read(byte);
|
||||
if self.wanted() {
|
||||
State::Held
|
||||
} else {
|
||||
self.release();
|
||||
State::Passing
|
||||
}
|
||||
}
|
||||
(State::Passing, _) => {
|
||||
self.out.push(byte);
|
||||
self.read(byte);
|
||||
State::Passing
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Adds `byte` to the body as the tap's scanner does; skipped controls bound the bytes held too.
|
||||
fn read(&mut self, byte: u8) {
|
||||
if self.held.len() > 2 * MOST {
|
||||
self.overflow = true;
|
||||
}
|
||||
if ignored(byte) {
|
||||
return;
|
||||
}
|
||||
if self.body.len() < MOST {
|
||||
self.body.push(byte);
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the OSC held may be a mark, or a link that ends inside a prompt, within the scanner's bounds.
|
||||
fn wanted(&self) -> bool {
|
||||
let fits = |prefix: &[u8]| {
|
||||
let length = self.body.len().min(prefix.len());
|
||||
self.body[..length] == prefix[..length]
|
||||
};
|
||||
!self.overflow && (fits(b"133;") || (self.prompt.is_some() && fits(b"8;")))
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.out.append(&mut self.held);
|
||||
}
|
||||
|
||||
/// The OSC ends at `end`, BEL, CAN or SUB, or at an ESC when none; true when it was rewritten.
|
||||
fn end(&mut self, end: Option<u8>) -> bool {
|
||||
let rewrite = if self.overflow {
|
||||
None
|
||||
} else if let Some(opens) = self.body.strip_prefix(b"133;").and_then(mark) {
|
||||
if opens {
|
||||
self.count = self.count.wrapping_add(1);
|
||||
self.prompt = Some(self.count);
|
||||
// A prompt inside a program's link carries its number on that link.
|
||||
Some(match &self.target {
|
||||
Some(target) => link(self.count, target),
|
||||
None => link(self.count, LINK.as_bytes()),
|
||||
})
|
||||
} else {
|
||||
match (self.prompt.take(), &self.target) {
|
||||
(Some(_), Some(target)) => Some(plain(target)),
|
||||
(Some(_), None) => Some(CLOSE.to_vec()),
|
||||
(None, _) => None,
|
||||
}
|
||||
}
|
||||
} else if let Some((opens, target)) = user(&self.body) {
|
||||
self.target = opens.then(|| target.to_vec());
|
||||
// Inside a prompt a program's link carries the prompt's number; its end takes the prompt's link up again.
|
||||
let target = if opens { target } else { LINK.as_bytes() };
|
||||
self.prompt.map(|number| link(number, target))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match (rewrite, self.state) {
|
||||
(Some(bytes), State::Held) => {
|
||||
self.held.clear();
|
||||
// An ESC that went on already starts the link.
|
||||
self.out.extend_from_slice(&bytes[usize::from(self.sent)..]);
|
||||
// CAN and SUB end the OSC and run as well.
|
||||
self.out.extend(end.filter(|&byte| byte != 0x07));
|
||||
true
|
||||
}
|
||||
(_, State::Held) => {
|
||||
self.held.extend(end);
|
||||
self.release();
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
self.out.extend(end);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mark's body opens a prompt or ends one, as `marks::read` reads its letter; none for neither.
|
||||
fn mark(body: &[u8]) -> Option<bool> {
|
||||
let (&letter, rest) = body.split_first()?;
|
||||
if !rest.is_empty() && rest[0] != b';' {
|
||||
return None;
|
||||
}
|
||||
match letter {
|
||||
b'A' | b'N' => Some(true),
|
||||
b'B' | b'I' | b'C' | b'D' => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an OSC 8 body opens a program's link, as alacritty reads its target, and that target; none for no link at all.
|
||||
fn user(body: &[u8]) -> Option<(bool, &[u8])> {
|
||||
let rest = body.strip_prefix(b"8;")?;
|
||||
let target = &rest[rest.iter().position(|&byte| byte == b';')? + 1..];
|
||||
// alacritty joins the target's parts with `;`, each one empty when it is not UTF-8.
|
||||
let opens =
|
||||
target.contains(&b';') || std::str::from_utf8(target).is_ok_and(|text| !text.is_empty());
|
||||
Some((opens, target))
|
||||
}
|
||||
|
||||
/// A link to `target` whose id carries prompt `number`.
|
||||
fn link(number: u32, target: &[u8]) -> Vec<u8> {
|
||||
let mut link = format!("\x1b]8;id={NUMBERED}{number};").into_bytes();
|
||||
link.extend_from_slice(target);
|
||||
link.push(0x07);
|
||||
link
|
||||
}
|
||||
|
||||
/// A program's link to `target`, its id its own again.
|
||||
fn plain(target: &[u8]) -> Vec<u8> {
|
||||
let mut link = b"\x1b]8;;".to_vec();
|
||||
link.extend_from_slice(target);
|
||||
link.push(0x07);
|
||||
link
|
||||
}
|
||||
|
||||
/// The number of the prompt `row` shows, as a link's id carries it.
|
||||
fn prompt(row: &Row<Cell>) -> Option<u32> {
|
||||
row.into_iter().find_map(|cell| {
|
||||
let link = cell.hyperlink()?;
|
||||
link.id().strip_prefix(NUMBERED)?.parse().ok()
|
||||
})
|
||||
}
|
||||
|
||||
/// The display offset that puts the prompt before or after the view's top at the top, as Ghostty jumps.
|
||||
pub fn jump<T>(term: &Term<T>, up: bool) -> Option<usize> {
|
||||
let top = -(term.grid().display_offset() as i32);
|
||||
let (first, last) = (term.topmost_line().0, term.bottommost_line().0);
|
||||
let number = |line: i32| prompt(&term.grid()[Line(line)]);
|
||||
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
|
||||
let found = if up {
|
||||
// The nearest prompt above the view's top, then its first row, blank rows within it passed.
|
||||
let (line, own) = (first..top)
|
||||
.rev()
|
||||
.find_map(|line| number(line).map(|own| (line, own)))?;
|
||||
let mut start = line;
|
||||
for above in (first..line).rev() {
|
||||
match number(above) {
|
||||
Some(other) if other != own => break,
|
||||
Some(_) => start = above,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
start
|
||||
} else {
|
||||
// The prompt at or above the view's top may go on below it; the next one starts.
|
||||
let here = (first..=top).rev().find_map(number);
|
||||
(top + 1..=last).find(|&line| number(line).is_some_and(|other| Some(other) != here))?
|
||||
};
|
||||
// A prompt on the screen means the bottom, which the view cannot pass.
|
||||
Some((-found).max(0) as usize)
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Scrolls to the prompt before or after the view's top; with none there, nothing moves.
|
||||
pub(super) fn scroll_to_prompt(&mut self, up: bool, cx: &mut Context<Self>) {
|
||||
let mut term = self.pty.term.lock();
|
||||
let Some(offset) = jump(&term, up) else {
|
||||
log::debug!(
|
||||
"terminal {}: no prompt {}",
|
||||
self.pty.pid,
|
||||
if up { "above" } else { "below" }
|
||||
);
|
||||
return;
|
||||
};
|
||||
let delta = offset as i32 - term.grid().display_offset() as i32;
|
||||
term.scroll_display(Scroll::Delta(delta));
|
||||
drop(term);
|
||||
log::debug!("terminal {}: to the prompt {offset} lines up", self.pty.pid);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,331 @@
|
||||
use alacritty_terminal::{
|
||||
event::VoidListener,
|
||||
index::Column,
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::term::marks::{self, Mark};
|
||||
|
||||
/// What the parser gets from `chunks`, each one read.
|
||||
fn passed(chunks: &[&[u8]]) -> Vec<u8> {
|
||||
let mut prompts = Prompts::default();
|
||||
let mut out = Vec::new();
|
||||
for chunk in chunks {
|
||||
out.extend_from_slice(prompts.pass(chunk));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A terminal `columns` wide and `lines` high after `bytes` passed through the rewrite.
|
||||
fn parsed(bytes: &[u8], columns: usize, lines: usize) -> Term<VoidListener> {
|
||||
let mut term = Term::new(
|
||||
Config::default(),
|
||||
&TermSize::new(columns, lines),
|
||||
VoidListener,
|
||||
);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, &passed(&[bytes]));
|
||||
term
|
||||
}
|
||||
|
||||
/// The target of the link at a cell.
|
||||
fn target_of(term: &Term<VoidListener>, line: i32, column: usize) -> Option<String> {
|
||||
let cell = &term.grid()[Line(line)][Column(column)];
|
||||
cell.hyperlink().map(|link| link.uri().to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_becomes_its_prompt_s_numbered_link() {
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x1b\\x"]),
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;;\x07\x1b\\x"
|
||||
);
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A;k=i\x07\x1b]133;D;1\x18"]),
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07\x1b]8;;\x07\x18"
|
||||
);
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;N\x07\x1b]133;C\x07\x1b]133;A\x07"]),
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07\x1b]8;;\x07\x1b]8;id=\x7f2;\x7f\x07"
|
||||
);
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;D;0\x07"]),
|
||||
b"\x1b]133;D;0\x07",
|
||||
"an end with no prompt open passes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rewrite_reads_a_mark_as_the_session_hears_it() {
|
||||
let bodies = [
|
||||
"A", "A;k=i", "N", "B", "I", "C;x", "D;1", "D;abc", "D", "", "AB", "L", "P;k=i", "Q", "a",
|
||||
];
|
||||
for body in bodies {
|
||||
let heard = marks::read(body.as_bytes()).map(|mark| mark == Mark::Prompt);
|
||||
assert_eq!(mark(body.as_bytes()), heard, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_split_across_reads_comes_out_whole() {
|
||||
let whole: &[u8] =
|
||||
b"a\x1b]133;A;k=i\x07\x1b]8;;file:///w\x1b\\w\x1b]8;;\x1b\\$ \x1b]133;B\x1b\\b";
|
||||
let once = passed(&[whole]);
|
||||
for cut in 0..=whole.len() {
|
||||
let (first, second) = whole.split_at(cut);
|
||||
assert_eq!(passed(&[first, second]), once, "cut at {cut}");
|
||||
}
|
||||
let bytes: Vec<&[u8]> = whole.chunks(1).collect();
|
||||
assert_eq!(passed(&bytes), once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_pass_as_they_came() {
|
||||
for bytes in [
|
||||
"\x1b]133;AB\x07x",
|
||||
"\x1b]1337;A\x07",
|
||||
"\x1b]133;P;k=i\x07",
|
||||
"\x1b]133;\x07",
|
||||
"\x1b]0;133;A\x07",
|
||||
"\x1b[31m\x1bc",
|
||||
"\x1b]8;;file:///x\x07x\x1b]8;;\x07",
|
||||
"text",
|
||||
] {
|
||||
assert_eq!(passed(&[bytes.as_bytes()]), bytes.as_bytes(), "{bytes:?}");
|
||||
}
|
||||
let mut long = b"\x1b]133;A;".to_vec();
|
||||
long.extend([b'x'; MOST]);
|
||||
long.push(0x07);
|
||||
assert_eq!(passed(&[&long]), long, "past the scanner's bound");
|
||||
let mut skipped = b"\x1b]133;A".to_vec();
|
||||
skipped.extend([0; 3 * MOST]);
|
||||
skipped.push(0x07);
|
||||
assert_eq!(
|
||||
passed(&[&skipped]),
|
||||
skipped,
|
||||
"skipped controls bound the bytes held"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_escape_of_controls_goes_on_as_it_comes() {
|
||||
let mut whole = vec![ESC];
|
||||
whole.extend(vec![0; 3 << 20]);
|
||||
whole.extend_from_slice(b"[0mREADY");
|
||||
let mut prompts = Prompts::default();
|
||||
let mut out = Vec::new();
|
||||
for chunk in whole.chunks(64 << 10) {
|
||||
out.extend_from_slice(prompts.pass(chunk));
|
||||
assert!(prompts.held() <= 1, "only the ESC waits");
|
||||
}
|
||||
assert_eq!(out.len(), whole.len());
|
||||
assert!(out.ends_with(b"\x1b[0mREADY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_s_link_inside_a_prompt_carries_its_number() {
|
||||
let whole = parsed(
|
||||
b"\x1b]133;A\x07\x1b]8;;file:///work\x1b\\/work $ \x1b]8;;\x1b\\\x1b]133;B\x07",
|
||||
12,
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
prompt(&whole.grid()[Line(0)]),
|
||||
Some(1),
|
||||
"a prompt all in a link"
|
||||
);
|
||||
assert_eq!(target_of(&whole, 0, 6).as_deref(), Some("file:///work"));
|
||||
let first = b"\x1b]133;A\x07\x1b]8;;file:///w\x1b\\/work\r\n\x1b]8;;\x1b\\$ \x1b]133;B\x07\r\no\r\no\r\no";
|
||||
let term = parsed(first, 8, 3);
|
||||
assert_eq!(jump(&term, true), Some(2), "its linked first row starts it");
|
||||
let open = parsed(
|
||||
b"\x1b]8;;file:///x\x1b\\x\x1b]133;A\x07y\x1b]133;B\x07z",
|
||||
8,
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
prompt(&open.grid()[Line(0)]),
|
||||
Some(1),
|
||||
"a prompt inside a link left open"
|
||||
);
|
||||
let (y, z) = (
|
||||
&open.grid()[Line(0)][Column(1)],
|
||||
&open.grid()[Line(0)][Column(2)],
|
||||
);
|
||||
assert!(
|
||||
z.hyperlink()
|
||||
.is_some_and(|link| !link.id().starts_with(NUMBERED))
|
||||
);
|
||||
assert_eq!(
|
||||
target_of(&open, 0, 2).as_deref(),
|
||||
Some("file:///x"),
|
||||
"its own again past the prompt"
|
||||
);
|
||||
assert!(
|
||||
y.hyperlink()
|
||||
.is_some_and(|link| link.id().starts_with(NUMBERED))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_target_numbers_a_prompt_in_either_order() {
|
||||
let target = format!("https://e.test/{}", "a".repeat(249));
|
||||
let open = format!("\x1b]8;;{target}\x1b\\");
|
||||
let before = format!("{open}x\x1b]133;A\x07y\x1b]133;B\x07z\x1b]8;;\x1b\\");
|
||||
let inside = format!("x\x1b]133;A\x07{open}y\x1b]133;B\x07z\x1b]8;;\x1b\\");
|
||||
for bytes in [before, inside] {
|
||||
let term = parsed(bytes.as_bytes(), 8, 2);
|
||||
let row = &term.grid()[Line(0)];
|
||||
let numbered = |column: usize| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.id().starts_with(NUMBERED))
|
||||
};
|
||||
assert_eq!(prompt(row), Some(1), "{bytes:?}");
|
||||
assert!(
|
||||
numbered(1) && !numbered(2),
|
||||
"the prompt's cell alone: {bytes:?}"
|
||||
);
|
||||
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(target.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_after_an_osc_s_closing_escape_run_where_vte_runs_them() {
|
||||
let whole: &[u8] = b"LEFT\x1b]0;title\x1b\r\n\\RIGHT";
|
||||
for cut in 0..=whole.len() {
|
||||
let (first, second) = whole.split_at(cut);
|
||||
assert_eq!(passed(&[first, second]), whole, "cut at {cut}");
|
||||
}
|
||||
let term = parsed(whole, 8, 2);
|
||||
let line = |at: i32| -> String {
|
||||
term.grid()[Line(at)]
|
||||
.into_iter()
|
||||
.map(|cell| cell.c)
|
||||
.collect()
|
||||
};
|
||||
assert_eq!((line(0).trim_end(), line(1).trim_end()), ("LEFT", "RIGHT"));
|
||||
let marked = parsed(b"\x1b]133;A\x1b\r\n\\$", 8, 2);
|
||||
assert_eq!(prompt(&marked.grid()[Line(1)]), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn del_and_high_bytes_in_an_escape_go_on_after_it() {
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b\x7f]133;A\x07$\x1b\xff", b"]133;B\x07"]),
|
||||
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;;\x07"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_vte_runs_inside_an_escape_go_on_before_the_link() {
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b\n\x07]133;A\x07$"]),
|
||||
b"\n\x07\x1b]8;id=\x7f1;\x7f\x07$"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
|
||||
let term = parsed(
|
||||
b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls",
|
||||
8,
|
||||
2,
|
||||
);
|
||||
assert_eq!(prompt(&term.grid()[Line(0)]), None);
|
||||
assert_eq!(prompt(&term.grid()[Line(1)]), Some(1));
|
||||
assert_eq!(target_of(&term, 1, 1).as_deref(), Some(LINK));
|
||||
assert_eq!(target_of(&term, 1, 2), None, "input");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_s_own_link_and_the_prompt_s_keep_each_other() {
|
||||
let term = parsed(
|
||||
b"\x1b]133;A\x07\x1b]8;;file:///work\x1b\\/work\x1b]8;;\x1b\\ $ \x1b]133;B\x07",
|
||||
12,
|
||||
2,
|
||||
);
|
||||
assert_eq!(target_of(&term, 0, 0).as_deref(), Some("file:///work"));
|
||||
assert_eq!(target_of(&term, 0, 6).as_deref(), Some(LINK));
|
||||
assert_eq!(prompt(&term.grid()[Line(0)]), Some(1));
|
||||
let term = parsed(
|
||||
b"\x1b]8;;file:///x\x1b\\x\x1b]133;A\x07y\x1b]8;;\x1b\\z\x1b]133;B\x07",
|
||||
12,
|
||||
2,
|
||||
);
|
||||
assert_eq!(target_of(&term, 0, 0).as_deref(), Some("file:///x"));
|
||||
assert_eq!(
|
||||
target_of(&term, 0, 1).as_deref(),
|
||||
Some("file:///x"),
|
||||
"a prompt inside a link keeps it"
|
||||
);
|
||||
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(LINK));
|
||||
}
|
||||
|
||||
/// Prompts on lines 0, 3 and 6 of a history four lines high, the last on the screen.
|
||||
fn history() -> Term<VoidListener> {
|
||||
let mut bytes = Vec::new();
|
||||
for _ in 0..3 {
|
||||
bytes.extend_from_slice(
|
||||
b"\x1b]133;A\x07$ \x1b]133;B\x07c\r\n\x1b]133;C\x07o\r\no\r\n\x1b]133;D;0\x07",
|
||||
);
|
||||
}
|
||||
bytes.extend_from_slice(b"\x1b]133;A\x07$ \x1b]133;B\x07");
|
||||
parsed(&bytes, 8, 4)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_and_down_go_to_the_prompts_beside_the_view_s_top() {
|
||||
let mut term = history();
|
||||
assert_eq!(term.grid().history_size(), 6);
|
||||
// From the bottom: the prompt at the view's top is passed, the one before it found.
|
||||
assert_eq!(jump(&term, true), Some(3));
|
||||
term.scroll_display(Scroll::Delta(3));
|
||||
assert_eq!(jump(&term, true), Some(6));
|
||||
term.scroll_display(Scroll::Delta(3));
|
||||
assert_eq!(jump(&term, true), None, "no prompt above the first");
|
||||
assert_eq!(jump(&term, false), Some(3));
|
||||
term.scroll_display(Scroll::Delta(-3));
|
||||
assert_eq!(
|
||||
jump(&term, false),
|
||||
Some(0),
|
||||
"a prompt on the screen means the bottom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_over_rows_starts_at_its_first_blank_rows_within_it_too() {
|
||||
let bytes = b"\x1b]133;A\x07FIRST\r\n\r\nLAST $ \x1b]133;B\x07\r\nout1\r\nout2\r\nout3\r\nout4";
|
||||
let mut term = parsed(bytes, 8, 3);
|
||||
assert_eq!(term.grid().history_size(), 4);
|
||||
assert_eq!(jump(&term, true), Some(4), "FIRST, not LAST");
|
||||
term.scroll_display(Scroll::Delta(4));
|
||||
assert_eq!(jump(&term, false), None, "LAST is the same prompt");
|
||||
let next = parsed(b"\x1b]133;A\x07$ \x1b]133;B\x07\r\n\x1b]133;A\x07$ ", 8, 2);
|
||||
assert_eq!(
|
||||
(prompt(&next.grid()[Line(0)]), prompt(&next.grid()[Line(1)])),
|
||||
(Some(1), Some(2)),
|
||||
"an empty line's next prompt is its own"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_keeps_its_start_through_a_resize() {
|
||||
let bytes =
|
||||
b"\x1b]133;A\x07~/folder $ \x1b]133;B\x07ls\r\nout\r\nout\r\nout\r\n\x1b]133;A\x07$ ";
|
||||
let mut term = parsed(bytes, 12, 3);
|
||||
term.resize(TermSize::new(6, 3));
|
||||
let rows = term.topmost_line().0..=term.bottommost_line().0;
|
||||
let numbers: Vec<Option<u32>> = rows.map(|line| prompt(&term.grid()[Line(line)])).collect();
|
||||
let first = numbers.iter().position(|number| *number == Some(1));
|
||||
// The first prompt wraps over two rows; the view jumps to the first of them.
|
||||
assert_eq!(
|
||||
numbers.iter().filter(|number| **number == Some(1)).count(),
|
||||
2
|
||||
);
|
||||
let offset = jump(&term, true).expect("a prompt above");
|
||||
let top = term.topmost_line().0 + first.expect("prompt 1") as i32;
|
||||
assert_eq!(offset as i32, -top);
|
||||
}
|
||||
+26
-170
@@ -11,7 +11,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::{mouse::MouseMode, tap::Tapped};
|
||||
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty.
|
||||
@@ -20,6 +20,18 @@ pub enum PtyEvent {
|
||||
Term(Event),
|
||||
Folder(PathBuf),
|
||||
Mouse(MouseMode),
|
||||
Mark(Mark),
|
||||
/// A program's status, OSC 7501.
|
||||
Status(Status),
|
||||
/// A notification or progress, OSC 9, 777 or 99.
|
||||
Ask(Ask),
|
||||
/// A title a program set, in the output's order: OSC 0 or 2, or the title stack's pop.
|
||||
Title(String),
|
||||
/// A full reset, `ESC c`.
|
||||
Reset,
|
||||
/// win32-input-mode set or reset.
|
||||
#[cfg(windows)]
|
||||
Win32(bool),
|
||||
}
|
||||
|
||||
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||
@@ -56,7 +68,7 @@ pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub pid: u32,
|
||||
notifier: Notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master: std::fs::File,
|
||||
}
|
||||
|
||||
@@ -111,7 +123,7 @@ pub fn spawn(
|
||||
)
|
||||
})?;
|
||||
let pid = child_id(&raw)?;
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
let master = raw
|
||||
.master()
|
||||
.map_err(|error| anyhow::anyhow!("pty {pid}: no copy of the master: {error}"))?;
|
||||
@@ -141,7 +153,7 @@ pub fn spawn(
|
||||
term,
|
||||
pid,
|
||||
notifier,
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(unix)]
|
||||
master,
|
||||
},
|
||||
events,
|
||||
@@ -184,6 +196,15 @@ impl Pty {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Pty {
|
||||
/// The process group the program runs in the foreground.
|
||||
pub fn foreground(&self) -> std::io::Result<i32> {
|
||||
let group = rustix_openpty::rustix::termios::tcgetpgrp(&self.master)?;
|
||||
Ok(group.as_raw_nonzero().get())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
impl Pty {
|
||||
/// Whether the program reads raw keys, canonical mode off.
|
||||
@@ -236,172 +257,7 @@ pub fn fit(columns: usize, lines: usize, cell: (u16, u16)) -> WindowSize {
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use alacritty_terminal::{
|
||||
grid::Dimensions,
|
||||
index::{Column, Line},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
struct Ran {
|
||||
folders: Vec<PathBuf>,
|
||||
text: String,
|
||||
success: bool,
|
||||
pid: u32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let (folders, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut success) = (Vec::new(), false);
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||
Ran {
|
||||
folders,
|
||||
text: text.trim_end().to_string(),
|
||||
success,
|
||||
pid: pty.pid,
|
||||
columns: term.columns(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
||||
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
||||
assert!(ran.folders.is_empty());
|
||||
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
||||
assert!(ran.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_reaches_the_session_as_a_folder() {
|
||||
let ran = run(
|
||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||
}
|
||||
|
||||
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
||||
fn close_after_ready(script: &str, reaped: &str) {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
.collect();
|
||||
drop(term);
|
||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let pid = pty.pid;
|
||||
drop(pty);
|
||||
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::testlog::logged(&hung_up) {
|
||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||
assert!(!alive, "the program was reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_session_hangs_up_its_program() {
|
||||
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
||||
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
||||
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
program: None,
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).expect("a temp folder");
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
||||
let size = fit(70_000, 24, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||
let size = fit(0, 0, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod scrollback {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
use alacritty_terminal::{
|
||||
grid::Dimensions,
|
||||
index::{Column, Line},
|
||||
};
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
use crate::term::{
|
||||
notify::Ask,
|
||||
status::{Record, State},
|
||||
};
|
||||
|
||||
struct Ran {
|
||||
folders: Vec<PathBuf>,
|
||||
marks: Vec<Mark>,
|
||||
/// What programs said of their status, a full reset as none.
|
||||
statuses: Vec<Option<Status>>,
|
||||
/// Notifications and progress programs asked for.
|
||||
asks: Vec<Ask>,
|
||||
/// Titles, notifications and full resets, in the output's order.
|
||||
said: Vec<String>,
|
||||
text: String,
|
||||
/// The first line's cells that carry a prompt's link.
|
||||
prompt: Vec<usize>,
|
||||
success: bool,
|
||||
pid: u32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let (folders, marks, statuses, asks, said, success) = futures::executor::block_on(async {
|
||||
let (mut folders, mut marks, mut statuses, mut asks, mut said, mut success) = (
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
false,
|
||||
);
|
||||
while let Some(event) = events.next().await {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => folders.push(folder),
|
||||
PtyEvent::Mark(mark) => marks.push(mark),
|
||||
PtyEvent::Status(status) => statuses.push(Some(status)),
|
||||
PtyEvent::Reset => {
|
||||
said.push("reset".to_string());
|
||||
statuses.push(None);
|
||||
}
|
||||
PtyEvent::Title(title) => said.push(format!("title {title}")),
|
||||
PtyEvent::Ask(ask) => {
|
||||
if let Ask::Show(notice) = &ask {
|
||||
said.push(format!("show {}", notice.title));
|
||||
}
|
||||
asks.push(ask);
|
||||
}
|
||||
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
|
||||
PtyEvent::Term(Event::Exit) => break,
|
||||
PtyEvent::Term(_) | PtyEvent::Mouse(_) => {}
|
||||
}
|
||||
}
|
||||
(folders, marks, statuses, asks, said, success)
|
||||
});
|
||||
let term = pty.term.lock();
|
||||
let row = &term.grid()[Line(0)];
|
||||
let text: String = (0..24).map(|column| row[Column(column)].c).collect();
|
||||
let prompt = (0..24).filter(|&column| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.uri() == crate::term::prompts::LINK)
|
||||
});
|
||||
Ran {
|
||||
folders,
|
||||
marks,
|
||||
statuses,
|
||||
asks,
|
||||
said,
|
||||
text: text.trim_end().to_string(),
|
||||
prompt: prompt.collect(),
|
||||
success,
|
||||
pid: pty.pid,
|
||||
columns: term.columns(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_prints_into_the_grid_with_its_variables_and_id() {
|
||||
let ran = run("printf 'ely %s %s' \"$ELY\" $$", (40, 4));
|
||||
assert!(ran.folders.is_empty());
|
||||
assert_eq!(ran.text, format!("ely ok {}", ran.pid));
|
||||
assert!(ran.success);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7_reaches_the_session_as_a_folder() {
|
||||
let ran = run(
|
||||
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.folders, [PathBuf::from("/tmp/a b")]);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_133_reaches_the_session_as_marks() {
|
||||
let ran = run(
|
||||
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
||||
(40, 4),
|
||||
);
|
||||
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
||||
assert_eq!(ran.marks, marks);
|
||||
assert_eq!(ran.text, ">x");
|
||||
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_7501_and_a_full_reset_reach_the_session_in_order() {
|
||||
let ran = run(
|
||||
"printf '\\033]7501;state=working:progress=40\\033\\\\\\033]7501;?\\007\\033cx'",
|
||||
(40, 4),
|
||||
);
|
||||
let working = Record {
|
||||
state: State::Working,
|
||||
kind: None,
|
||||
progress: Some(40),
|
||||
app: None,
|
||||
title: None,
|
||||
msg: None,
|
||||
};
|
||||
let said = [
|
||||
Some(Status::Report(String::new(), working)),
|
||||
Some(Status::Query),
|
||||
None,
|
||||
];
|
||||
assert_eq!(ran.statuses, said);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notifications_and_progress_reach_the_session_in_order() {
|
||||
let ran = run(
|
||||
"printf '\\033]9;Hello\\007\\033]777;notify;T;B\\007\\033]99;i=a:d=0;Hel\\033\\\\\\033]99;i=a;lo\\033\\\\\\033]9;4;1;40\\007x'",
|
||||
(40, 4),
|
||||
);
|
||||
let titles: Vec<String> = ran
|
||||
.asks
|
||||
.iter()
|
||||
.map(|ask| match ask {
|
||||
Ask::Show(notice) => format!("{}/{}", notice.title, notice.body),
|
||||
other => format!("{other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
titles,
|
||||
["Hello/", "T/B", "Hello/", "Progress(Set(Some(40)))"]
|
||||
);
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_99_gathers_a_full_encoded_chunk_and_the_lines_base64_carries() {
|
||||
let ran = run(
|
||||
"printf '\\033]99;i=m:d=0:e=1;'; yes YWFh | head -n 1024 | tr -d '\\n'; printf '\\033\\\\\\033]99;i=m:d=0:e=1;RU5E\\033\\\\\\033]99;i=m:p=body:e=1;TGluZSAxCkxpbmUgMg==\\007x'",
|
||||
(40, 4),
|
||||
);
|
||||
let [Ask::Show(notice)] = ran.asks.as_slice() else {
|
||||
panic!("one notification: {:?}", ran.asks);
|
||||
};
|
||||
assert_eq!(notice.title, format!("{}END", "a".repeat(3072)));
|
||||
assert_eq!(notice.body, "Line 1\nLine 2");
|
||||
assert_eq!(ran.text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titles_reach_the_session_in_their_place_among_notifications() {
|
||||
let long = "y".repeat(5000);
|
||||
let many: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
|
||||
let parts = [
|
||||
"\\033]2;Old\\007".to_string(),
|
||||
"\\033[22t".into(),
|
||||
"\\033]2;New\\007".into(),
|
||||
"\\033]9;a\\007".into(),
|
||||
"\\033[23t".into(),
|
||||
"\\033]9;b\\007".into(),
|
||||
"\\033c".into(),
|
||||
"\\033]9;c\\007".into(),
|
||||
format!("\\033]2;{long}\\007"),
|
||||
format!("\\033]2;{}\\007", many.join(";")),
|
||||
"\\033]9;d\\007".into(),
|
||||
];
|
||||
let whole = run(&format!("printf '{}x'", parts.concat()), (40, 4));
|
||||
let split: Vec<String> = parts
|
||||
.iter()
|
||||
.map(|part| format!("printf '{part}'; sleep 0.1"))
|
||||
.collect();
|
||||
let split = run(&format!("{}; printf x", split.join("; ")), (40, 4));
|
||||
let said = [
|
||||
"title Old".to_string(),
|
||||
"title New".into(),
|
||||
"show a".into(),
|
||||
"title Old".into(),
|
||||
"show b".into(),
|
||||
"reset".into(),
|
||||
"show c".into(),
|
||||
format!("title {long}"),
|
||||
format!("title {}", many[..15].join(";")),
|
||||
"show d".into(),
|
||||
];
|
||||
assert_eq!(whole.said, said, "one write");
|
||||
assert_eq!(split.said, said, "a write a sequence");
|
||||
assert_eq!((whole.text.as_str(), split.text.as_str()), ("x", "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_escape_of_controls_keeps_the_pty_reading() {
|
||||
let ran = run(
|
||||
"printf '\\033'; head -c 3000000 /dev/zero; printf '[0mREADY'",
|
||||
(40, 4),
|
||||
);
|
||||
assert_eq!(ran.text, "READY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_foreground_is_the_program_s_own_group() {
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "exec sleep 30".into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while pty.foreground().ok() != Some(pty.pid as i32) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{:?}",
|
||||
pty.foreground()
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_size_past_the_kernel_is_one_size_for_grid_and_program() {
|
||||
let ran = run("stty size", (9000, 8));
|
||||
assert_eq!(ran.columns, usize::from(u16::MAX / 8));
|
||||
assert_eq!(ran.text, format!("8 {}", u16::MAX / 8));
|
||||
}
|
||||
|
||||
/// Starts `script`, waits for it to print READY, closes it, and waits for the reap's log.
|
||||
fn close_after_ready(script: &str, reaped: &str) {
|
||||
crate::testlog::init();
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
futures::executor::block_on(async {
|
||||
while let Some(event) = events.next().await {
|
||||
let term = pty.term.lock();
|
||||
let text: String = (0..5)
|
||||
.map(|column| term.grid()[Line(0)][Column(column)].c)
|
||||
.collect();
|
||||
drop(term);
|
||||
if matches!(event, PtyEvent::Term(Event::Wakeup)) && text == "READY" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let pid = pty.pid;
|
||||
drop(pty);
|
||||
let hung_up = format!("pty {pid}: hung up, {reaped}");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !crate::testlog::logged(&hung_up) {
|
||||
assert!(std::time::Instant::now() < deadline, "no `{hung_up}`");
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
assert!(crate::testlog::logged(&format!("pty {pid}: closing")));
|
||||
let alive = unsafe { libc::kill(pid as libc::pid_t, 0) } == 0;
|
||||
assert!(!alive, "the program was reaped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_session_hangs_up_its_program() {
|
||||
close_after_ready("printf READY; exec cat", "signal: 1 (SIGHUP)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_ignores_sighup_ends_when_the_pty_closes() {
|
||||
// cat reads EOF on macOS and EIO on Linux; the shell's own exit holds on both.
|
||||
close_after_ready("trap '' HUP; printf READY; cat; exit 7", "exit status: 7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_folder_stops_the_start() {
|
||||
let launch = Launch {
|
||||
program: None,
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
.expect("a missing folder is an error");
|
||||
assert!(error.to_string().contains("/nonexistent/ely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let folder = std::env::temp_dir().join(format!("elyterminal-shut-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&folder).expect("a temp folder");
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o600)).expect("600");
|
||||
let launch = Launch {
|
||||
program: Some(("/bin/sh".into(), vec!["-c".into(), "true".into()])),
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
std::fs::remove_dir(&folder).expect("the temp folder goes");
|
||||
let error = started.err().expect("a folder it cannot enter is an error");
|
||||
let error = error.to_string();
|
||||
assert!(error.contains("Permission denied"), "{error}");
|
||||
assert!(error.contains(&folder.display().to_string()), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_hold_at_least_two_by_one_and_cap_at_the_kernel() {
|
||||
let size = fit(70_000, 24, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
|
||||
let size = fit(0, 0, (8, 16));
|
||||
assert_eq!((size.num_cols, size.num_lines), (2, 1));
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{Context, SharedString, SystemNotification};
|
||||
|
||||
use super::{
|
||||
notify::{self, Ask, CLOSES, Notice},
|
||||
status::{self, Record, State, Status},
|
||||
view::{TerminalEvent, TerminalView},
|
||||
};
|
||||
use crate::{
|
||||
sessions::{line, plain},
|
||||
workspace::TITLE,
|
||||
};
|
||||
|
||||
/// One notification a session every 3 seconds at most.
|
||||
const GAP: Duration = Duration::from_secs(3);
|
||||
|
||||
/// The notifications a session keeps for presses and answers; past them the oldest goes.
|
||||
const POSTED: usize = 64;
|
||||
|
||||
/// The system notifications a session posted and has not seen go.
|
||||
#[derive(Default)]
|
||||
pub struct Notices {
|
||||
posted: Vec<Posted>,
|
||||
/// When it last posted.
|
||||
last: Option<Instant>,
|
||||
/// Posts so far: they tell apart notifications without an id, and an expiry finds its own post.
|
||||
count: u64,
|
||||
}
|
||||
|
||||
/// A notification posted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Posted {
|
||||
tag: SharedString,
|
||||
/// The OSC 99 id its program gave; none for the session's own and for OSC 9 and 777.
|
||||
id: Option<String>,
|
||||
focus: bool,
|
||||
report: bool,
|
||||
count: u64,
|
||||
}
|
||||
|
||||
impl Notices {
|
||||
/// Program `pid`'s turn to post at `now`, kept as posted; none within 3 seconds of its last.
|
||||
fn turn(&mut self, pid: u32, asked: Option<&Notice>, now: Instant) -> Option<Posted> {
|
||||
if self.last.is_some_and(|last| now.duration_since(last) < GAP) {
|
||||
return None;
|
||||
}
|
||||
self.last = Some(now);
|
||||
self.count += 1;
|
||||
let id = asked.and_then(|notice| notice.id.clone());
|
||||
// The session's own share a tag, so the last takes its place; an OSC 99 id shares its own.
|
||||
let tag = match (asked, &id) {
|
||||
(None, _) => format!("{pid}"),
|
||||
(Some(_), Some(id)) => format!("{pid}:{id}"),
|
||||
(Some(_), None) => format!("{pid}#{}", self.count),
|
||||
};
|
||||
let posted = Posted {
|
||||
tag: tag.into(),
|
||||
id,
|
||||
focus: asked.is_none_or(|notice| notice.focus),
|
||||
report: asked.is_some_and(|notice| notice.report),
|
||||
count: self.count,
|
||||
};
|
||||
self.posted.retain(|old| old.tag != posted.tag);
|
||||
if self.posted.len() == POSTED {
|
||||
let gone = self.posted.remove(0);
|
||||
log::debug!(
|
||||
"pty {pid}: notification {} goes from the {POSTED} kept",
|
||||
gone.tag
|
||||
);
|
||||
}
|
||||
self.posted.push(posted.clone());
|
||||
Some(posted)
|
||||
}
|
||||
|
||||
/// Takes notification `tag` out; with `count`, only that post of it.
|
||||
fn take(&mut self, tag: &str, count: Option<u64>) -> Option<Posted> {
|
||||
let at = self.posted.iter().position(|posted| {
|
||||
posted.tag == tag && count.is_none_or(|count| posted.count == count)
|
||||
})?;
|
||||
Some(self.posted.remove(at))
|
||||
}
|
||||
|
||||
/// Whether a post after post `count` holds `tag`.
|
||||
fn replaced(&self, tag: &str, count: u64) -> bool {
|
||||
self.posted
|
||||
.iter()
|
||||
.any(|posted| posted.tag == tag && posted.count > count)
|
||||
}
|
||||
|
||||
/// The ids of the notifications still alive: posted, and not pressed, closed or expired.
|
||||
fn alive(&self) -> Vec<&str> {
|
||||
self.posted
|
||||
.iter()
|
||||
.filter_map(|posted| posted.id.as_deref())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Keeps a program's report, drops what it clears, and answers its query.
|
||||
pub(super) fn status(&mut self, status: Status) {
|
||||
match status {
|
||||
Status::Report(id, record) => self.records.report(id, record),
|
||||
Status::Clear(id) => self.records.clear(id.as_deref()),
|
||||
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps progress, answers the query and alive in order, and posts and closes notifications.
|
||||
pub(super) fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
|
||||
match ask {
|
||||
Ask::Progress(progress) => self.records.progress(progress),
|
||||
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
|
||||
Ask::Alive(id) => {
|
||||
let alive = self.notices.alive();
|
||||
log::debug!("pty {}: notifications alive: {alive:?}", self.pty.pid);
|
||||
self.pty.write(notify::alive(&id, &alive).into_bytes());
|
||||
}
|
||||
Ask::Show(notice) => self.post(said(¬ice), Some(¬ice), cx),
|
||||
Ask::Close(id) => self.close(&id, cx),
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes the state its row shows; a change goes to the owner, and one to waiting, done or error posts.
|
||||
pub(super) fn restate(&mut self, cx: &mut Context<Self>) {
|
||||
if self.records.restate() {
|
||||
let state = self.records.shows();
|
||||
log::info!("terminal {}: its row shows {state:?}", self.pty.pid);
|
||||
cx.emit(TerminalEvent::Shows(state));
|
||||
self.tell_state(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// herdr's agents in place of the last ones; an open hover reads them, so the view draws again.
|
||||
pub(super) fn agents(&mut self, agents: &[(String, Record)], cx: &mut Context<Self>) {
|
||||
if self.records.herdr(agents) {
|
||||
self.restate(cx);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts the state its row shows when it turned to waiting, done or error.
|
||||
fn tell_state(&mut self, cx: &mut Context<Self>) {
|
||||
let body = match self.records.shown() {
|
||||
Some((id, record)) if tells(record.state) => line(&self.records, id, record),
|
||||
_ => return,
|
||||
};
|
||||
self.post(body, None, cx);
|
||||
}
|
||||
|
||||
/// A command the shell ran ended with `status`, which a notification tells.
|
||||
pub(super) fn finished(&mut self, status: Option<i32>, cx: &mut Context<Self>) {
|
||||
if self.ended != status {
|
||||
let ended = status.map_or("with no status".into(), |code| format!("with {code}"));
|
||||
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
|
||||
}
|
||||
self.ended = status;
|
||||
// Only a command C started posts its end, so a prompt bash 3.2 draws again posts nothing.
|
||||
if std::mem::take(&mut self.ran) {
|
||||
self.post(finished(status), None, cx);
|
||||
} else {
|
||||
log::debug!(
|
||||
"terminal {}: an end without its start posts nothing",
|
||||
self.pty.pid
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts `body` while the window is not active or the session hidden, one every 3 seconds at most.
|
||||
fn post(&mut self, body: String, asked: Option<&Notice>, cx: &mut Context<Self>) {
|
||||
let pid = self.pty.pid;
|
||||
if !unseen(self.front, self.active) {
|
||||
log::debug!("terminal {pid}: in view, so it posts no {body:?}");
|
||||
return;
|
||||
}
|
||||
let Some(posted) = self.notices.turn(pid, asked, Instant::now()) else {
|
||||
log::info!("terminal {pid}: posted within 3 s, so it posts no {body:?}");
|
||||
return;
|
||||
};
|
||||
// Its title names the session, so no program speaks as another.
|
||||
let title = plain(if self.title.is_empty() {
|
||||
TITLE
|
||||
} else {
|
||||
&self.title
|
||||
});
|
||||
log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag);
|
||||
// Linux's notification daemons read the body as markup.
|
||||
let body = if cfg!(target_os = "linux") {
|
||||
escape(&body)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
cx.show_system_notification(SystemNotification {
|
||||
tag: posted.tag.clone(),
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
actions: Vec::new(),
|
||||
});
|
||||
let Some(notice) = asked else {
|
||||
return;
|
||||
};
|
||||
// gpui hears of no close, so a program that asks hears that none is tracked.
|
||||
if notice.close {
|
||||
let id = posted.id.as_deref().unwrap_or("0");
|
||||
self.pty.write(notify::untracked(id).into_bytes());
|
||||
}
|
||||
match notice.expire {
|
||||
Some(ms) if CLOSES => self.expire(posted, ms, cx),
|
||||
Some(_) => log::debug!(
|
||||
"terminal {pid}: gpui closes none on Linux, so {} stays",
|
||||
posted.tag
|
||||
),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes `posted` back once `ms` run out, though its session be gone, unless a later post holds its tag.
|
||||
fn expire(&mut self, posted: Posted, ms: u32, cx: &mut Context<Self>) {
|
||||
let (tag, count) = (posted.tag, posted.count);
|
||||
cx.spawn(async move |view, cx| {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(u64::from(ms)))
|
||||
.await;
|
||||
let held = view.update(cx, |view, cx| {
|
||||
if view.notices.replaced(&tag, count) {
|
||||
return;
|
||||
}
|
||||
if view.notices.take(&tag, Some(count)).is_some() {
|
||||
log::info!("terminal {}: notification {tag} expires", view.pty.pid);
|
||||
}
|
||||
cx.dismiss_system_notification(&tag);
|
||||
});
|
||||
// The system keeps what a closed session posted, so its expiry still takes it.
|
||||
if held.is_err() {
|
||||
log::info!("app: notification {tag} expires after its session");
|
||||
cx.update(|cx| cx.dismiss_system_notification(&tag));
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// A program's close of notification `id`, where gpui can close one.
|
||||
fn close(&mut self, id: &str, cx: &mut Context<Self>) {
|
||||
let tag = format!("{}:{id}", self.pty.pid);
|
||||
if !CLOSES {
|
||||
log::debug!(
|
||||
"terminal {}: gpui closes none on Linux, so {tag} stays",
|
||||
self.pty.pid
|
||||
);
|
||||
} else if self.notices.take(&tag, None).is_some() {
|
||||
log::info!("terminal {}: closes notification {tag}", self.pty.pid);
|
||||
cx.dismiss_system_notification(&tag);
|
||||
}
|
||||
}
|
||||
|
||||
/// A press on notification `tag`, if this session posted it: a program that asked hears of it; whether the session shows.
|
||||
pub fn pressed(&mut self, tag: &str) -> Option<bool> {
|
||||
let pid = self.pty.pid;
|
||||
let Some(posted) = self.notices.take(tag, None) else {
|
||||
// A twin Linux stacked, pressed before, or one past the 64 kept, still shows its session.
|
||||
let own = owns(pid, tag);
|
||||
if own {
|
||||
log::info!(
|
||||
"terminal {pid}: notification {tag} pressed, its record gone, so it tells no program"
|
||||
);
|
||||
}
|
||||
return own.then_some(true);
|
||||
};
|
||||
log::info!("terminal {pid}: notification {tag} pressed");
|
||||
if posted.report {
|
||||
let id = posted.id.as_deref().unwrap_or("0");
|
||||
self.pty.write(notify::activated(id).into_bytes());
|
||||
}
|
||||
Some(posted.focus)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a session posts: not while it is the session shown in an active window.
|
||||
fn unseen(front: bool, active: bool) -> bool {
|
||||
!(front && active)
|
||||
}
|
||||
|
||||
/// Whether the row's turn to `state` posts: waiting, done and error do.
|
||||
fn tells(state: State) -> bool {
|
||||
matches!(state, State::Blocked | State::Done | State::Error)
|
||||
}
|
||||
|
||||
/// Whether program `pid`'s session posted notification `tag`: its own, `PID`, else `PID:ID` or `PID#N`.
|
||||
fn owns(pid: u32, tag: &str) -> bool {
|
||||
tag.strip_prefix(&pid.to_string())
|
||||
.is_some_and(|rest| rest.is_empty() || rest.starts_with([':', '#']))
|
||||
}
|
||||
|
||||
/// `text` as markup reads it as text.
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// What a command's end says: it finished, or the status it failed with.
|
||||
fn finished(status: Option<i32>) -> String {
|
||||
match status {
|
||||
Some(code) if code != 0 => format!("Command exited with {code}"),
|
||||
_ => "Command finished".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A program's notification as posted: its title, then its body, each line shown as text.
|
||||
fn said(notice: &Notice) -> String {
|
||||
let lines = notice.title.lines().chain(notice.body.lines());
|
||||
let shown: Vec<String> = lines.map(plain).filter(|line| !line.is_empty()).collect();
|
||||
shown.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
|
||||
fn notice(id: Option<&str>, title: &str, body: &str) -> Notice {
|
||||
Notice {
|
||||
id: id.map(String::from),
|
||||
title: title.into(),
|
||||
body: body.into(),
|
||||
focus: true,
|
||||
report: false,
|
||||
close: false,
|
||||
expire: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_posts_once_in_3_seconds_its_own_under_one_tag() {
|
||||
let mut notices = Notices::default();
|
||||
let start = Instant::now();
|
||||
let first = notices.turn(7, None, start).expect("the first post");
|
||||
assert_eq!((&*first.tag, first.focus, first.report), ("7", true, false));
|
||||
assert_eq!(notices.turn(7, None, start + Duration::from_secs(2)), None);
|
||||
let again = notices.turn(7, None, start + GAP).expect("past the gap");
|
||||
assert_eq!(&*again.tag, "7");
|
||||
assert_eq!(notices.posted.len(), 1, "the last takes its place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_s_notifications_go_by_their_id_and_live_until_taken() {
|
||||
let mut notices = Notices::default();
|
||||
let start = Instant::now();
|
||||
let mut asked = notice(Some("build"), "T", "");
|
||||
asked.report = true;
|
||||
asked.focus = false;
|
||||
let posted = notices.turn(7, Some(&asked), start).expect("a post");
|
||||
assert_eq!(
|
||||
(&*posted.tag, posted.focus, posted.report),
|
||||
("7:build", false, true)
|
||||
);
|
||||
let untagged = notices
|
||||
.turn(7, Some(¬ice(None, "T", "")), start + GAP)
|
||||
.expect("a post");
|
||||
assert!(untagged.tag.starts_with("7#"), "{}", untagged.tag);
|
||||
assert_eq!(notices.alive(), ["build"]);
|
||||
let update = notices
|
||||
.turn(7, Some(&asked), start + GAP * 2)
|
||||
.expect("an update");
|
||||
assert!(
|
||||
notices.replaced(&posted.tag, posted.count),
|
||||
"a later post holds the tag"
|
||||
);
|
||||
assert!(!notices.replaced(&update.tag, update.count));
|
||||
assert_eq!(
|
||||
notices.take("7:build", Some(posted.count)),
|
||||
None,
|
||||
"an expiry takes its own post only"
|
||||
);
|
||||
assert_eq!(notices.take("7:build", Some(update.count)), Some(update));
|
||||
assert!(notices.alive().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_keeps_its_last_64_notifications() {
|
||||
let mut notices = Notices::default();
|
||||
let start = Instant::now();
|
||||
for at in 0..=POSTED as u32 {
|
||||
let asked = notice(Some(format!("n{at}").as_str()), "T", "");
|
||||
notices
|
||||
.turn(7, Some(&asked), start + GAP * at)
|
||||
.expect("a post");
|
||||
}
|
||||
assert_eq!(notices.posted.len(), POSTED);
|
||||
assert!(!notices.alive().contains(&"n0"), "the oldest went");
|
||||
assert!(notices.alive().contains(&"n64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_notification_says_its_text_as_text() {
|
||||
let asked = notice(None, "Build\u{202e} done", "Line 1\n\nLine 2\u{200b}");
|
||||
assert_eq!(said(&asked), "Build done\nLine 1\nLine 2");
|
||||
assert_eq!(finished(Some(0)), "Command finished");
|
||||
assert_eq!(finished(None), "Command finished");
|
||||
assert_eq!(finished(Some(2)), "Command exited with 2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_posts_unless_it_is_the_one_shown_in_an_active_window() {
|
||||
assert!(!unseen(true, true));
|
||||
assert!(unseen(true, false), "the window inactive");
|
||||
assert!(unseen(false, true), "the session hidden");
|
||||
assert!(unseen(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_done_and_error_post_and_work_and_rest_do_not() {
|
||||
for (state, posts) in [
|
||||
(State::Blocked, true),
|
||||
(State::Done, true),
|
||||
(State::Error, true),
|
||||
(State::Working, false),
|
||||
(State::Idle, false),
|
||||
] {
|
||||
assert_eq!(tells(state), posts, "{state:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_names_the_session_that_posted_it() {
|
||||
for tag in ["12", "12:build", "12#3"] {
|
||||
assert!(owns(12, tag), "{tag}");
|
||||
}
|
||||
for tag in ["123", "1", "123:build", "13#3", ""] {
|
||||
assert!(!owns(12, tag), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_goes_as_markup_s_text() {
|
||||
assert_eq!(
|
||||
escape("<b>a</b> & <a href=x>"),
|
||||
"<b>a</b> & <a href=x>"
|
||||
);
|
||||
assert_eq!(escape("plain"), "plain");
|
||||
}
|
||||
+167
-11
@@ -1,4 +1,7 @@
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{
|
||||
sync::atomic::{AtomicUsize, Ordering},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use alacritty_terminal::{event::Event, grid::Dimensions, index::Line};
|
||||
use futures::channel::mpsc::TryRecvError;
|
||||
@@ -6,7 +9,7 @@ use futures::channel::mpsc::TryRecvError;
|
||||
use super::*;
|
||||
use crate::{
|
||||
settings::Settings,
|
||||
term::{Events, Pty, PtyEvent, config, spawn},
|
||||
term::{Events, Pty, PtyEvent, config, marks::Mark, spawn},
|
||||
};
|
||||
|
||||
/// A folder name with a space, a percent, a bell and a newline.
|
||||
@@ -43,6 +46,9 @@ struct Run {
|
||||
start: PathBuf,
|
||||
folders: Vec<PathBuf>,
|
||||
titles: Vec<String>,
|
||||
marks: Vec<Mark>,
|
||||
/// Lines typed, each followed by a prompt of its own.
|
||||
lines: usize,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
@@ -50,9 +56,12 @@ impl Run {
|
||||
/// Starts `shell` in a fresh folder; `files` go under its HOME first.
|
||||
fn start(shell: &str, files: &[(&str, &str)]) -> Self {
|
||||
crate::testlog::init();
|
||||
// Two tests may run one shell at once.
|
||||
static RUNS: AtomicUsize = AtomicUsize::new(0);
|
||||
let root = folder(&format!(
|
||||
"shell-{}",
|
||||
shell.rsplit('/').next().expect("a name")
|
||||
"shell-{}-{}",
|
||||
shell.rsplit('/').next().expect("a name"),
|
||||
RUNS.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let (dir, home, start) = (
|
||||
root.join("integration"),
|
||||
@@ -79,6 +88,8 @@ impl Run {
|
||||
start,
|
||||
folders: Vec::new(),
|
||||
titles: Vec::new(),
|
||||
marks: Vec::new(),
|
||||
lines: 0,
|
||||
deadline: Instant::now() + Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
@@ -88,15 +99,17 @@ impl Run {
|
||||
while !done(self) {
|
||||
assert!(
|
||||
Instant::now() < self.deadline,
|
||||
"{}: no {what}; folders {:?}, titles {:?}, screen\n{}",
|
||||
"{}: no {what}; folders {:?}, titles {:?}, marks {:?}, screen\n{}",
|
||||
self.shell,
|
||||
self.folders,
|
||||
self.titles,
|
||||
self.marks,
|
||||
self.screen()
|
||||
);
|
||||
match self.events.try_recv() {
|
||||
Ok(PtyEvent::Folder(folder)) => self.folders.push(folder),
|
||||
Ok(PtyEvent::Term(Event::Title(title))) => self.titles.push(title),
|
||||
Ok(PtyEvent::Title(title)) => self.titles.push(title),
|
||||
Ok(PtyEvent::Mark(mark)) => self.marks.push(mark),
|
||||
Ok(PtyEvent::Term(Event::PtyWrite(reply))) => self.pty.write(reply.into_bytes()),
|
||||
Ok(PtyEvent::Term(Event::ChildExit(status))) => {
|
||||
panic!("{} ended, {status}:\n{}", self.shell, self.screen())
|
||||
@@ -112,6 +125,7 @@ impl Run {
|
||||
fn send(&mut self, line: &str) {
|
||||
self.until("line editor", |run| run.pty.reads_keys());
|
||||
self.pty.write(format!("{line}\r").into_bytes());
|
||||
self.lines += 1;
|
||||
}
|
||||
|
||||
fn shows(&self, text: &str) -> bool {
|
||||
@@ -132,6 +146,10 @@ impl Run {
|
||||
|
||||
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
|
||||
fn walk(&mut self, probe: &str) {
|
||||
let first = [Mark::Prompt, Mark::Input];
|
||||
self.until("the first prompt's marks", |run| {
|
||||
run.marks.starts_with(&first)
|
||||
});
|
||||
let start = self.start.clone();
|
||||
self.send(probe);
|
||||
self.until("first folder", |run| run.folders.contains(&start));
|
||||
@@ -142,6 +160,23 @@ impl Run {
|
||||
self.until("inner folder", |run| run.folders.contains(&inner));
|
||||
}
|
||||
|
||||
/// Types `line` once the prompt before it is marked, then waits for the marks it ends with.
|
||||
fn ends(&mut self, line: &str, marks: &[Mark]) {
|
||||
let prompts = self.lines + 1;
|
||||
self.until("the prompt's marks", |run| {
|
||||
run.marks
|
||||
.iter()
|
||||
.filter(|mark| **mark == Mark::Input)
|
||||
.count()
|
||||
>= prompts
|
||||
});
|
||||
let from = self.marks.len();
|
||||
self.send(line);
|
||||
self.until(&format!("the marks of {line}"), |run| {
|
||||
run.marks[from..].ends_with(marks)
|
||||
});
|
||||
}
|
||||
|
||||
fn finish(mut self) {
|
||||
self.send("exit");
|
||||
loop {
|
||||
@@ -163,6 +198,35 @@ impl Run {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this bash has PS0, 4.4 on, so it marks a command's start.
|
||||
fn bash_ps0(shell: &str) -> bool {
|
||||
let version = std::process::Command::new(shell)
|
||||
.args(["-c", "echo $((BASH_VERSINFO[0] * 100 + BASH_VERSINFO[1]))"])
|
||||
.output()
|
||||
.expect("bash runs");
|
||||
let version: u32 = String::from_utf8(version.stdout)
|
||||
.expect("utf-8")
|
||||
.trim()
|
||||
.parse()
|
||||
.expect("a version");
|
||||
version >= 404
|
||||
}
|
||||
|
||||
/// The marks a bash command that ends with `status` leaves; bash 3.2 marks no start.
|
||||
fn bash_ended(ps0: bool, status: i32) -> Vec<Mark> {
|
||||
ended(status)[usize::from(!ps0)..].to_vec()
|
||||
}
|
||||
|
||||
/// The marks a command that ends with `status` leaves: its output, its end, the next prompt.
|
||||
fn ended(status: i32) -> [Mark; 4] {
|
||||
[
|
||||
Mark::Output,
|
||||
Mark::Done(Some(status)),
|
||||
Mark::Prompt,
|
||||
Mark::Input,
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scripts_install_once_and_mend_a_changed_one() {
|
||||
let dir = folder("install");
|
||||
@@ -193,7 +257,10 @@ fn a_script_it_cannot_read_fails_the_install() {
|
||||
|
||||
#[test]
|
||||
fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
||||
let mut run = Run::start(&installed("zsh"), &[]);
|
||||
// A theme that sets PS1 in its own hook, which runs after ours.
|
||||
let theme =
|
||||
"autoload -Uz add-zsh-hook\n_theme() { PS1='THEME %? > ' }\nadd-zsh-hook precmd _theme";
|
||||
let mut run = Run::start(&installed("zsh"), &[(".zshrc", theme)]);
|
||||
run.walk("(( $+functions[_ely_precmd] )) && echo ELY-LOAD''ED");
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
@@ -203,20 +270,86 @@ fn zsh_reports_its_folder_byte_for_byte_and_its_titles() {
|
||||
"{:?}",
|
||||
run.titles
|
||||
);
|
||||
run.ends("false", &ended(1));
|
||||
run.until("the theme's prompt", |run| {
|
||||
run.shows("THEME 0 > false") && run.shows("THEME 1 >")
|
||||
});
|
||||
run.send("false | true");
|
||||
run.send("echo PIPE=$pipestatus");
|
||||
run.until("the pipeline's statuses", |run| run.shows("PIPE=1 0"));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zsh_says_why_it_marks_no_prompt_without_prompt_percent() {
|
||||
let mut run = Run::start(&installed("zsh"), &[(".zshrc", "unsetopt prompt_percent")]);
|
||||
run.until("the warning", |run| {
|
||||
run.shows("ELYTerminal: zsh marks no prompt while prompt_percent is off")
|
||||
});
|
||||
run.send("false");
|
||||
let ended = [Mark::Output, Mark::Done(Some(1))];
|
||||
run.until("the command's marks", |run| run.marks.ends_with(&ended));
|
||||
assert!(!run.marks.contains(&Mark::Prompt), "{:?}", run.marks);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
||||
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'"#;
|
||||
let status = r#"PROMPT_COMMAND='printf "USER_STATUS=%s\n" "$?"'; shopt -s extdebug"#;
|
||||
let files = [(".bashrc", status), (".bash_profile", status)];
|
||||
let mut run = Run::start(&installed("bash"), &files);
|
||||
let shell = installed("bash");
|
||||
let ps0 = bash_ps0(&shell);
|
||||
let mut run = Run::start(&shell, &files);
|
||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
});
|
||||
run.send("false");
|
||||
run.ends("false", &bash_ended(ps0, 1));
|
||||
run.until("kept status", |run| run.shows("USER_STATUS=1"));
|
||||
assert!(run.shows("$ false"), "{}", run.screen());
|
||||
// Under extdebug a DEBUG trap that fails skips its command.
|
||||
run.ends("touch ran", &bash_ended(ps0, 0));
|
||||
assert!(run.start.join(INNER).join("ran").exists());
|
||||
run.ends("(exit 7)", &bash_ended(ps0, 7));
|
||||
run.send(r#"ls / >/dev/null; echo "LAST=$_""#);
|
||||
run.until("the last argument", |run| run.shows("LAST=/\n"));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_leaves_the_user_s_trap_and_keys_alone() {
|
||||
let rc = r#"_user_trap() { USER_TRAP=yes; }; trap '_user_trap "$_"' DEBUG
|
||||
bind -x '"\C-t": :'"#;
|
||||
let files = [(".bashrc", rc), (".bash_profile", rc)];
|
||||
let shell = installed("bash");
|
||||
let ps0 = bash_ps0(&shell);
|
||||
let mut run = Run::start(&shell, &files);
|
||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||
run.ends("false", &bash_ended(ps0, 1));
|
||||
// A key binding runs no command, so it marks none.
|
||||
let from = run.marks.len();
|
||||
run.pty.write(b"\x14".to_vec());
|
||||
run.ends(r#"echo "TRAP=$USER_TRAP""#, &bash_ended(ps0, 0));
|
||||
assert!(run.shows("TRAP=yes"), "{}", run.screen());
|
||||
let starts = run.marks[from..]
|
||||
.iter()
|
||||
.filter(|mark| **mark == Mark::Output);
|
||||
assert_eq!(starts.count(), usize::from(ps0), "{:?}", &run.marks[from..]);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_says_why_it_marks_no_command_without_promptvars() {
|
||||
let files = [
|
||||
(".bashrc", "shopt -u promptvars"),
|
||||
(".bash_profile", "shopt -u promptvars"),
|
||||
];
|
||||
let mut run = Run::start(&installed("bash"), &files);
|
||||
run.until("the warning", |run| {
|
||||
run.shows("ELYTerminal: bash marks no command while promptvars is off")
|
||||
});
|
||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||
run.ends("false", &[Mark::Prompt, Mark::Input]);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
@@ -228,6 +361,10 @@ fn fish_loads_its_script_and_restores_its_data_dirs() {
|
||||
run.send(r#"echo "DIRS[$ELY_FISH_XDG_DATA_DIRS][$XDG_DATA_DIRS]""#);
|
||||
let restored = format!("DIRS[][{previous}]");
|
||||
run.until("restored dirs", |run| run.shows(&restored));
|
||||
// The prompt hears each command's own status, none failed so far.
|
||||
assert!(!run.shows("[1]"), "{}", run.screen());
|
||||
run.ends("false", &ended(1));
|
||||
run.until("the failed status", |run| run.shows("[1]>"));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
@@ -244,8 +381,27 @@ fn powershell_reports_its_folder_byte_for_byte_and_keeps_the_status() {
|
||||
run.until("title", |run| {
|
||||
run.titles.iter().any(|title| title == "in ner%xy")
|
||||
});
|
||||
run.send("/usr/bin/false");
|
||||
run.ends("/usr/bin/false", &ended(1));
|
||||
run.until("kept status", |run| run.shows("USER_SUCCESS=False"));
|
||||
assert!(run.shows("> /usr/bin/false"), "{}", run.screen());
|
||||
// A cmdlet's failure leaves no error behind, a native code may repeat, and the line's last pipeline counts.
|
||||
run.ends("/usr/bin/true", &ended(0));
|
||||
run.ends("Get-Item ./missing -ErrorAction Ignore", &ended(1));
|
||||
run.ends("sh -c 'exit 3'", &ended(3));
|
||||
run.ends("sh -c 'exit 3'", &ended(3));
|
||||
run.ends("Get-Item ./missing", &ended(1));
|
||||
run.ends("sh -c 'exit 7'; Get-Item ./missing", &ended(1));
|
||||
run.ends(
|
||||
"sh -c 'exit 7'; Get-Item ./missing -ErrorAction Ignore",
|
||||
&ended(1),
|
||||
);
|
||||
run.ends("Get-Item ./missing; sh -c 'exit 7'", &ended(7));
|
||||
// A call through a variable runs a program the line does not name.
|
||||
run.send("$exe = 'sh'");
|
||||
run.ends("& $exe -c 'exit 7'", &ended(7));
|
||||
run.ends("& \"$exe\" -c 'exit 9'", &ended(9));
|
||||
let native = "$PSNativeCommandUseErrorActionPreference = $true; sh -c 'exit 7'";
|
||||
run.ends(native, &ended(7));
|
||||
run.finish();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
use base64::{
|
||||
Engine,
|
||||
alphabet::STANDARD,
|
||||
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
|
||||
};
|
||||
|
||||
use super::notify::Progress;
|
||||
|
||||
/// What a program says it is doing, OSC 7501.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum State {
|
||||
Idle,
|
||||
Working,
|
||||
Done,
|
||||
Blocked,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// What a blocked program waits for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Permission,
|
||||
Question,
|
||||
Auth,
|
||||
}
|
||||
|
||||
/// A record, as the last report for its id left it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Record {
|
||||
pub state: State,
|
||||
pub kind: Option<Kind>,
|
||||
pub progress: Option<u8>,
|
||||
pub app: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub msg: Option<String>,
|
||||
}
|
||||
|
||||
/// An OSC 7501 sequence, checked whole; an id is empty for the root record.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
Report(String, Record),
|
||||
/// Drops the record at the id and those under it; with no id, all.
|
||||
Clear(Option<String>),
|
||||
/// Asks which states and kinds the terminal takes.
|
||||
Query,
|
||||
}
|
||||
|
||||
/// The answer to the query: every state and kind this terminal takes.
|
||||
pub const REPLY: &str =
|
||||
"\x1b]7501;?:states=idle,working,done,blocked,error:kinds=permission,question,auth\x1b\\";
|
||||
|
||||
/// The spec's limits: a whole sequence, a key, an app, an id, its segments and levels.
|
||||
const SEQUENCE: usize = 4096;
|
||||
const KEY: usize = 16;
|
||||
const APP: usize = 32;
|
||||
const ID: usize = 128;
|
||||
const SEGMENT: usize = 32;
|
||||
const LEVELS: usize = 8;
|
||||
|
||||
/// The ids herdr's agents take, one by pane: no OSC 7501 id holds a colon, so none is taken for theirs.
|
||||
pub(super) const HERDR: &str = "herdr:";
|
||||
|
||||
/// Records a terminal keeps; past them, the one updated longest ago goes.
|
||||
pub const RECORDS: usize = 256;
|
||||
|
||||
/// Standard base64, its padding optional.
|
||||
pub(super) const BASE64: GeneralPurpose = GeneralPurpose::new(
|
||||
&STANDARD,
|
||||
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
|
||||
);
|
||||
|
||||
/// The sequence in an OSC 7501 body past `7501;`, `length` bytes from OSC through ST; none for one the spec drops, logged with why.
|
||||
pub fn read(body: &[u8], length: usize) -> Option<Status> {
|
||||
parse(body, length)
|
||||
.inspect_err(|why| log::warn!("pty: OSC 7501 dropped: {why}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// A value's bytes, as the spec allows them.
|
||||
fn valued(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || b"_.,+/=-".contains(&byte)
|
||||
}
|
||||
|
||||
/// An id's or an app's bytes.
|
||||
fn named(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || b"_.+-".contains(&byte)
|
||||
}
|
||||
|
||||
fn parse(body: &[u8], length: usize) -> Result<Status, String> {
|
||||
if length > SEQUENCE {
|
||||
return Err(format!("{length} bytes, over {SEQUENCE}"));
|
||||
}
|
||||
if body.trim_ascii() == b"?" {
|
||||
return Ok(Status::Query);
|
||||
}
|
||||
// Every pair is checked before the last of each key wins.
|
||||
let (mut state, mut id, mut kind, mut progress, mut app, mut title, mut msg) =
|
||||
(None, None, None, None, None, None, None);
|
||||
for pair in body.split(|&byte| byte == b':') {
|
||||
// A malformed pair is skipped: no `=`, no key, or a byte outside the set.
|
||||
let Some(at) = pair.iter().position(|&byte| byte == b'=') else {
|
||||
continue;
|
||||
};
|
||||
let (key, value) = (pair[..at].trim_ascii(), pair[at + 1..].trim_ascii());
|
||||
if key.is_empty()
|
||||
|| !key.iter().all(u8::is_ascii_lowercase)
|
||||
|| !value.iter().all(|&byte| valued(byte))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if key.len() > KEY {
|
||||
return Err(format!("a key over {KEY} bytes"));
|
||||
}
|
||||
let value = String::from_utf8(value.to_vec()).expect("ASCII");
|
||||
match key {
|
||||
b"state" => state = Some(value),
|
||||
b"id" => id = Some(self::id(&value)?),
|
||||
b"kind" => kind = Some(value),
|
||||
b"progress" => progress = Some(value),
|
||||
b"app" if value.len() > APP => return Err(format!("an app over {APP} bytes")),
|
||||
b"app" => app = Some(value),
|
||||
b"title" => title = Some(text(&value, "title", 256, 192)?),
|
||||
b"msg" => msg = Some(text(&value, "msg", 2732, 2048)?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let state = match state.as_deref().ok_or("no state")? {
|
||||
"idle" => State::Idle,
|
||||
"working" => State::Working,
|
||||
"done" => State::Done,
|
||||
"blocked" => State::Blocked,
|
||||
"error" => State::Error,
|
||||
"clear" => return Ok(Status::Clear(id)),
|
||||
other => return Err(format!("state {other} is not the spec's")),
|
||||
};
|
||||
let kind = match (state, kind.as_deref()) {
|
||||
(State::Blocked, Some("permission")) => Some(Kind::Permission),
|
||||
(State::Blocked, Some("question")) => Some(Kind::Question),
|
||||
(State::Blocked, Some("auth")) => Some(Kind::Auth),
|
||||
_ => None,
|
||||
};
|
||||
let progress = match state {
|
||||
State::Working | State::Blocked => progress
|
||||
.filter(|value| value.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
.and_then(|value| value.parse::<u8>().ok())
|
||||
.filter(|&value| value <= 100),
|
||||
_ => None,
|
||||
};
|
||||
let app = app.filter(|app| !app.is_empty() && app.bytes().all(named));
|
||||
let record = Record {
|
||||
state,
|
||||
kind,
|
||||
progress,
|
||||
app,
|
||||
title,
|
||||
msg,
|
||||
};
|
||||
Ok(Status::Report(id.unwrap_or_default(), record))
|
||||
}
|
||||
|
||||
/// An id that keeps to the spec's grammar and limits.
|
||||
fn id(value: &str) -> Result<String, String> {
|
||||
let segments: Vec<&str> = value.split('/').collect();
|
||||
let fits = value.len() <= ID
|
||||
&& segments.len() <= LEVELS
|
||||
&& segments
|
||||
.iter()
|
||||
.all(|segment| (1..=SEGMENT).contains(&segment.len()) && segment.bytes().all(named));
|
||||
if fits {
|
||||
Ok(value.to_string())
|
||||
} else {
|
||||
Err(format!("id {value} is not the spec's"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The text a base64 value names, one line, within `encoded` and `decoded` bytes.
|
||||
fn text(value: &str, key: &str, encoded: usize, decoded: usize) -> Result<String, String> {
|
||||
if value.len() > encoded {
|
||||
return Err(format!("a {key} over {encoded} bytes"));
|
||||
}
|
||||
let bytes = BASE64
|
||||
.decode(value)
|
||||
.map_err(|error| format!("{key} is no base64: {error}"))?;
|
||||
if bytes.len() > decoded {
|
||||
return Err(format!("a {key} over {decoded} bytes decoded"));
|
||||
}
|
||||
let text = String::from_utf8(bytes).map_err(|_| format!("{key} is not UTF-8"))?;
|
||||
if text.chars().any(char::is_control) {
|
||||
return Err(format!("{key} holds a control character"));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// The records one terminal keeps, by id, each with when it was last reported.
|
||||
#[derive(Default)]
|
||||
pub struct Records {
|
||||
list: Vec<(String, Record, u64)>,
|
||||
clock: u64,
|
||||
/// A program has reported through OSC 7501 since the last full reset, so OSC 9;4 maps no more.
|
||||
reported: bool,
|
||||
/// The state a row showed at the last note.
|
||||
shows: Option<State>,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
/// A program's report: replaces the record at `id` whole.
|
||||
pub fn report(&mut self, id: String, record: Record) {
|
||||
self.reported = true;
|
||||
self.put(id, record);
|
||||
}
|
||||
|
||||
/// OSC 9;4 as the root record, until a program reports through OSC 7501.
|
||||
pub fn progress(&mut self, progress: Progress) {
|
||||
if self.reported {
|
||||
log::debug!("status: OSC 9;4 passed by, a program reports through OSC 7501");
|
||||
return;
|
||||
}
|
||||
let root = |state, progress| Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress,
|
||||
app: None,
|
||||
title: None,
|
||||
msg: None,
|
||||
};
|
||||
match progress {
|
||||
Progress::Remove => self.list.retain(|(id, ..)| !id.is_empty()),
|
||||
// Paused work is work still, its progress kept.
|
||||
Progress::Set(value) | Progress::Pause(value) => {
|
||||
self.put(String::new(), root(State::Working, value))
|
||||
}
|
||||
Progress::Indeterminate => self.put(String::new(), root(State::Working, None)),
|
||||
Progress::Error(_) => self.put(String::new(), root(State::Error, None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// herdr's agents in place of the last ones; true when they differ.
|
||||
pub fn herdr(&mut self, agents: &[(String, Record)]) -> bool {
|
||||
let held = self.list.iter().filter(|(id, ..)| id.starts_with(HERDR));
|
||||
if held
|
||||
.map(|(id, record, _)| (id, record))
|
||||
.eq(agents.iter().map(|(id, record)| (id, record)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.list.retain(|(id, ..)| !id.starts_with(HERDR));
|
||||
for (id, record) in agents {
|
||||
self.put(id.clone(), record.clone());
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// A full reset: every record goes, and OSC 9;4 maps again.
|
||||
pub fn reset(&mut self) {
|
||||
self.list.clear();
|
||||
self.reported = false;
|
||||
}
|
||||
|
||||
fn put(&mut self, id: String, record: Record) {
|
||||
self.clock += 1;
|
||||
if let Some(entry) = self.list.iter_mut().find(|(at, ..)| *at == id) {
|
||||
*entry = (id, record, self.clock);
|
||||
return;
|
||||
}
|
||||
if self.list.len() == RECORDS {
|
||||
let oldest = (0..self.list.len())
|
||||
.min_by_key(|&at| self.list[at].2)
|
||||
.expect("records");
|
||||
let (gone, ..) = self.list.remove(oldest);
|
||||
log::debug!("status: record {gone:?} goes, the one updated longest ago");
|
||||
}
|
||||
self.list.push((id, record, self.clock));
|
||||
}
|
||||
|
||||
/// A program's clear: drops the record at `id` and every one under it; with none, all.
|
||||
pub fn clear(&mut self, id: Option<&str>) {
|
||||
self.reported = true;
|
||||
match id {
|
||||
None => self.list.clear(),
|
||||
Some(id) => {
|
||||
let under = format!("{id}/");
|
||||
self.list
|
||||
.retain(|(at, ..)| at != id && !at.starts_with(&under));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A new prompt: working, blocked and idle records go, done and error stay.
|
||||
pub fn prompt(&mut self) {
|
||||
self.list
|
||||
.retain(|(_, record, _)| matches!(record.state, State::Done | State::Error));
|
||||
}
|
||||
|
||||
/// The user typed: done and error records are seen and go, but herdr's, which herdr marks seen; true when any went.
|
||||
pub fn seen(&mut self) -> bool {
|
||||
let before = self.list.len();
|
||||
self.list.retain(|(id, record, _)| {
|
||||
id.starts_with(HERDR) || !matches!(record.state, State::Done | State::Error)
|
||||
});
|
||||
self.list.len() != before
|
||||
}
|
||||
|
||||
/// The record a row shows, with its id: blocked, else error, working, done or idle, the one reported last in its state.
|
||||
pub fn shown(&self) -> Option<(&str, &Record)> {
|
||||
[
|
||||
State::Blocked,
|
||||
State::Error,
|
||||
State::Working,
|
||||
State::Done,
|
||||
State::Idle,
|
||||
]
|
||||
.into_iter()
|
||||
.find_map(|state| {
|
||||
self.list
|
||||
.iter()
|
||||
.filter(|(_, record, _)| record.state == state)
|
||||
.max_by_key(|(.., at)| *at)
|
||||
.map(|(id, record, _)| (id.as_str(), record))
|
||||
})
|
||||
}
|
||||
|
||||
/// Notes the state a row shows now; true when it changed since the last note.
|
||||
pub fn restate(&mut self) -> bool {
|
||||
let now = self.shown().map(|(_, record)| record.state);
|
||||
let changed = now != self.shows;
|
||||
self.shows = now;
|
||||
changed
|
||||
}
|
||||
|
||||
/// The state a row showed at the last note.
|
||||
pub fn shows(&self) -> Option<State> {
|
||||
self.shows
|
||||
}
|
||||
|
||||
/// Each record with its id, in the order first reported.
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&str, &Record)> {
|
||||
self.list
|
||||
.iter()
|
||||
.map(|(id, record, _)| (id.as_str(), record))
|
||||
}
|
||||
|
||||
/// The app of the record at `id`: its own, else its nearest ancestor's, the root's last; herdr's agents have none.
|
||||
pub fn app(&self, id: &str) -> Option<&str> {
|
||||
let mut at = id;
|
||||
loop {
|
||||
let app = self
|
||||
.list
|
||||
.iter()
|
||||
.find(|(name, ..)| name == at)
|
||||
.and_then(|(_, record, _)| record.app.as_deref());
|
||||
if app.is_some() || at.is_empty() || at.starts_with(HERDR) {
|
||||
return app;
|
||||
}
|
||||
at = at.rfind('/').map_or("", |slash| &at[..slash]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,395 @@
|
||||
use super::*;
|
||||
use crate::term::notify::Progress;
|
||||
|
||||
fn encoded(text: &[u8]) -> String {
|
||||
BASE64.encode(text)
|
||||
}
|
||||
|
||||
/// What `body` says when its sequence is as long as its frame makes it.
|
||||
fn said(body: &[u8]) -> Option<Status> {
|
||||
read(body, body.len() + 9)
|
||||
}
|
||||
|
||||
fn record(state: State, app: Option<&str>) -> Record {
|
||||
Record {
|
||||
state,
|
||||
kind: None,
|
||||
progress: None,
|
||||
app: app.map(str::to_string),
|
||||
title: None,
|
||||
msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reported(body: &str) -> Record {
|
||||
match said(body.as_bytes()) {
|
||||
Some(Status::Report(_, record)) => record,
|
||||
other => panic!("{body}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spec_s_examples_read_as_it_means_them() {
|
||||
let body = b"state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/";
|
||||
let blocked = Record {
|
||||
kind: Some(Kind::Permission),
|
||||
msg: Some("Apply 3 to add, 1 to change, 0 to destroy?".into()),
|
||||
..record(State::Blocked, Some("terraform"))
|
||||
};
|
||||
assert_eq!(said(body), Some(Status::Report(String::new(), blocked)));
|
||||
let body = b"state=working:id=us-east:title=VVMgRWFzdA==:progress=40:msg=UHVzaGluZyBpbWFnZQ==";
|
||||
let working = Record {
|
||||
progress: Some(40),
|
||||
title: Some("US East".into()),
|
||||
msg: Some("Pushing image".into()),
|
||||
..record(State::Working, None)
|
||||
};
|
||||
assert_eq!(said(body), Some(Status::Report("us-east".into(), working)));
|
||||
assert_eq!(
|
||||
said(b"state=clear:id=us-east"),
|
||||
Some(Status::Clear(Some("us-east".into())))
|
||||
);
|
||||
assert_eq!(said(b"state=clear"), Some(Status::Clear(None)));
|
||||
assert_eq!(said(b"?"), Some(Status::Query));
|
||||
assert_eq!(
|
||||
reported("state=done:msg=VXBncmFkZWQgMTIgcGFja2FnZXM")
|
||||
.msg
|
||||
.as_deref(),
|
||||
Some("Upgraded 12 packages"),
|
||||
"padding is optional"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pair_is_skipped_the_last_of_a_key_wins_and_unknown_keys_pass() {
|
||||
let record = reported(" junk : =x : state = idle :Key=v:app=a b:color=red:state=done:app=ok ");
|
||||
assert_eq!(record, self::record(State::Done, Some("ok")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequence_that_breaks_a_rule_drops_whole() {
|
||||
let long = |key: &str, length: usize| format!("state=done:{key}={}", "a".repeat(length));
|
||||
let drops = [
|
||||
"app=x".to_string(),
|
||||
"state=sleeping".into(),
|
||||
"state=done:id=".into(),
|
||||
"state=done:id=a//b".into(),
|
||||
"state=done:id=a/b/c/d/e/f/g/h/i".into(),
|
||||
format!("state=done:id={}", "a".repeat(33)),
|
||||
format!("state=done:{}=1", "k".repeat(17)),
|
||||
long("app", 33),
|
||||
long("title", 257),
|
||||
long("msg", 2733),
|
||||
"state=done:msg=A".into(),
|
||||
format!("state=done:msg={}", encoded(b"a\nb")),
|
||||
format!("state=done:msg={}", encoded("a\u{85}b".as_bytes())),
|
||||
format!("state=done:msg={}", encoded(&[0xff])),
|
||||
format!("state=done:title={}", encoded(&[b'a'; 193])),
|
||||
format!("state=done:x={}", "a".repeat(4096 - 9 - 12)),
|
||||
];
|
||||
for body in drops {
|
||||
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||
}
|
||||
assert!(said(format!("state=done:x={}", "a".repeat(4096 - 9 - 14)).as_bytes()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_progress_and_app_count_only_where_the_spec_has_them() {
|
||||
let done = reported("state=done:kind=auth:progress=5");
|
||||
assert_eq!((done.kind, done.progress), (None, None));
|
||||
let blocked = reported("state=blocked:kind=nope:progress=101");
|
||||
assert_eq!((blocked.kind, blocked.progress), (None, None));
|
||||
assert_eq!(reported("state=working:progress=007").progress, Some(7));
|
||||
assert_eq!(reported("state=working:progress=+5").progress, None);
|
||||
assert_eq!(reported("state=blocked:kind=auth").kind, Some(Kind::Auth));
|
||||
assert_eq!(
|
||||
reported("state=working:app=a.b+c_d-e").app.as_deref(),
|
||||
Some("a.b+c_d-e")
|
||||
);
|
||||
assert_eq!(reported("state=working:app=a,b").app, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_replace_and_clear_as_the_spec_says() {
|
||||
let mut records = Records::default();
|
||||
records.report(String::new(), record(State::Working, Some("deploy")));
|
||||
records.report("us-east".into(), record(State::Working, None));
|
||||
records.report("us-east/push".into(), record(State::Blocked, None));
|
||||
records.report("us-easter".into(), record(State::Done, Some("other")));
|
||||
records.report("us-east".into(), record(State::Done, None));
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["", "us-east", "us-east/push", "us-easter"]);
|
||||
let east = records
|
||||
.list
|
||||
.iter()
|
||||
.find(|(id, ..)| id == "us-east")
|
||||
.map(|(_, record, _)| record.clone());
|
||||
assert_eq!(
|
||||
east,
|
||||
Some(record(State::Done, None)),
|
||||
"a report replaces its record whole"
|
||||
);
|
||||
records.clear(Some("us-east"));
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
["", "us-easter"],
|
||||
"clear takes the record and those under it"
|
||||
);
|
||||
records.clear(None);
|
||||
assert!(records.list.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn herdr_s_agents_replace_their_own_records_alone() {
|
||||
let mut records = Records::default();
|
||||
records.report("build".into(), record(State::Working, None));
|
||||
// A program's id ends at a colon, so `herdr:w1:p1` names its record `herdr`.
|
||||
for body in [
|
||||
&b"state=done:id=herdr/build"[..],
|
||||
b"state=done:id=herdr:w1:p1",
|
||||
] {
|
||||
let Some(Status::Report(id, done)) = said(body) else {
|
||||
panic!("a program's report");
|
||||
};
|
||||
records.report(id, done);
|
||||
}
|
||||
let ids = |records: &Records| -> Vec<String> {
|
||||
records.list.iter().map(|(id, ..)| id.clone()).collect()
|
||||
};
|
||||
records.herdr(&[]);
|
||||
assert_eq!(
|
||||
ids(&records),
|
||||
["build", "herdr/build", "herdr"],
|
||||
"an empty snapshot takes no program's record"
|
||||
);
|
||||
let codex = [(
|
||||
"herdr:w1:p1".to_string(),
|
||||
record(State::Working, Some("codex")),
|
||||
)];
|
||||
records.herdr(&codex);
|
||||
let clock = records.clock;
|
||||
records.herdr(&codex);
|
||||
assert_eq!(records.clock, clock, "the same agents change nothing");
|
||||
let claude = [(
|
||||
"herdr:w1:p2".to_string(),
|
||||
record(State::Blocked, Some("claude")),
|
||||
)];
|
||||
records.herdr(&claude);
|
||||
assert_eq!(
|
||||
ids(&records),
|
||||
["build", "herdr/build", "herdr", "herdr:w1:p2"]
|
||||
);
|
||||
records.herdr(&[]);
|
||||
assert_eq!(
|
||||
ids(&records),
|
||||
["build", "herdr/build", "herdr"],
|
||||
"herdr's leaving takes its own alone"
|
||||
);
|
||||
records.reset();
|
||||
records.herdr(&claude);
|
||||
assert_eq!(
|
||||
ids(&records),
|
||||
["herdr:w1:p2"],
|
||||
"the next snapshot puts them back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_leaves_done_and_error() {
|
||||
let mut records = Records::default();
|
||||
for (id, state) in [
|
||||
("a", State::Idle),
|
||||
("b", State::Working),
|
||||
("c", State::Done),
|
||||
("d", State::Blocked),
|
||||
("e", State::Error),
|
||||
] {
|
||||
records.report(id.into(), record(state, None));
|
||||
}
|
||||
records.prompt();
|
||||
let ids: Vec<&str> = records.list.iter().map(|(id, ..)| id.as_str()).collect();
|
||||
assert_eq!(ids, ["c", "e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_cap_the_record_updated_longest_ago_goes() {
|
||||
let mut records = Records::default();
|
||||
for at in 0..RECORDS {
|
||||
records.report(format!("r{at}"), record(State::Working, None));
|
||||
}
|
||||
records.report("r0".into(), record(State::Done, None));
|
||||
records.report("new".into(), record(State::Working, None));
|
||||
assert_eq!(records.list.len(), RECORDS);
|
||||
assert!(
|
||||
records.list.iter().all(|(id, ..)| id != "r1"),
|
||||
"r1 was updated longest ago"
|
||||
);
|
||||
assert!(records.list.iter().any(|(id, ..)| id == "r0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clear_that_breaks_a_rule_drops_whole() {
|
||||
let drops = [
|
||||
"state=clear:msg=Cg==".to_string(),
|
||||
"state=clear:msg=A".into(),
|
||||
format!("state=clear:app={}", "a".repeat(33)),
|
||||
format!("state=clear:title={}", "a".repeat(257)),
|
||||
"state=clear:id=a//b".into(),
|
||||
];
|
||||
for body in drops {
|
||||
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_pair_counts_though_the_last_of_a_key_wins() {
|
||||
let drops = [
|
||||
format!("state=done:app={}:app=ok", "a".repeat(33)),
|
||||
"state=done:title=A:title=VVMgRWFzdA==".into(),
|
||||
"state=done:id=a//b:id=ok".into(),
|
||||
format!("state=done:msg={}:msg=b2s=", encoded(b"a\nb")),
|
||||
];
|
||||
for body in drops {
|
||||
assert_eq!(said(body.as_bytes()), None, "{body}");
|
||||
}
|
||||
assert_eq!(reported("state=done:app=x:app=y").app.as_deref(), Some("y"));
|
||||
assert_eq!(
|
||||
reported("state=done:app=a,b:app=ok").app.as_deref(),
|
||||
Some("ok")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sequence_counts_from_osc_through_st() {
|
||||
assert_eq!(read(b"state=done", 4097), None);
|
||||
assert!(read(b"state=done", 4096).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
|
||||
let root = |records: &Records| {
|
||||
records
|
||||
.list
|
||||
.iter()
|
||||
.find(|(id, ..)| id.is_empty())
|
||||
.map(|(_, record, _)| (record.state, record.progress))
|
||||
};
|
||||
let mut records = Records::default();
|
||||
records.progress(Progress::Set(Some(40)));
|
||||
assert_eq!(root(&records), Some((State::Working, Some(40))));
|
||||
records.progress(Progress::Pause(Some(5)));
|
||||
assert_eq!(root(&records), Some((State::Working, Some(5))));
|
||||
records.progress(Progress::Indeterminate);
|
||||
assert_eq!(root(&records), Some((State::Working, None)));
|
||||
records.progress(Progress::Error(Some(9)));
|
||||
assert_eq!(root(&records), Some((State::Error, None)));
|
||||
records.progress(Progress::Remove);
|
||||
assert_eq!(root(&records), None);
|
||||
records.report("a".into(), record(State::Idle, None));
|
||||
records.progress(Progress::Set(Some(1)));
|
||||
assert_eq!(root(&records), None, "a program reports through OSC 7501");
|
||||
records.reset();
|
||||
assert!(records.list.is_empty());
|
||||
records.progress(Progress::Set(Some(2)));
|
||||
assert_eq!(
|
||||
root(&records),
|
||||
Some((State::Working, Some(2))),
|
||||
"a full reset maps again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_shows_the_most_pressing_record_the_last_reported_in_its_state() {
|
||||
let mut records = Records::default();
|
||||
assert_eq!(records.shown(), None);
|
||||
let shown = |records: &Records| {
|
||||
records
|
||||
.shown()
|
||||
.map(|(_, record)| (record.state, record.kind))
|
||||
};
|
||||
records.report("a".into(), record(State::Idle, None));
|
||||
assert_eq!(shown(&records), Some((State::Idle, None)));
|
||||
records.report("b".into(), record(State::Done, None));
|
||||
assert_eq!(shown(&records), Some((State::Done, None)));
|
||||
records.report("c".into(), record(State::Working, None));
|
||||
assert_eq!(shown(&records), Some((State::Working, None)));
|
||||
records.report("d".into(), record(State::Error, None));
|
||||
assert_eq!(shown(&records), Some((State::Error, None)));
|
||||
let asks = |kind| Record {
|
||||
kind: Some(kind),
|
||||
..record(State::Blocked, None)
|
||||
};
|
||||
records.report("e".into(), asks(Kind::Auth));
|
||||
records.report("f".into(), asks(Kind::Question));
|
||||
assert_eq!(
|
||||
shown(&records),
|
||||
Some((State::Blocked, Some(Kind::Question)))
|
||||
);
|
||||
records.report("e".into(), asks(Kind::Auth));
|
||||
assert_eq!(
|
||||
shown(&records),
|
||||
Some((State::Blocked, Some(Kind::Auth))),
|
||||
"the last reported speaks"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_without_an_app_takes_its_nearest_ancestor_s() {
|
||||
let mut records = Records::default();
|
||||
records.report("us/east".into(), record(State::Working, None));
|
||||
assert_eq!(records.app("us/east"), None);
|
||||
records.report(String::new(), record(State::Working, Some("deploy")));
|
||||
records.report("us".into(), record(State::Working, Some("push")));
|
||||
records.report("us/east/a".into(), record(State::Working, None));
|
||||
records.report("eu/west".into(), record(State::Working, None));
|
||||
assert_eq!(
|
||||
records.app("us/east/a"),
|
||||
Some("push"),
|
||||
"past a parent without one"
|
||||
);
|
||||
assert_eq!(records.app("eu/west"), Some("deploy"), "the root's last");
|
||||
assert_eq!(records.app(""), Some("deploy"));
|
||||
assert_eq!(
|
||||
records.app("usa"),
|
||||
Some("deploy"),
|
||||
"a sibling's name is no parent"
|
||||
);
|
||||
records.herdr(&[("herdr:w1:p1".into(), record(State::Working, None))]);
|
||||
assert_eq!(
|
||||
records.app("herdr:w1:p1"),
|
||||
None,
|
||||
"herdr's agents are no program's children"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_sees_done_and_error_but_herdr_s() {
|
||||
let mut records = Records::default();
|
||||
records.report("a".into(), record(State::Done, None));
|
||||
records.report("b".into(), record(State::Error, None));
|
||||
records.report("c".into(), record(State::Working, None));
|
||||
assert!(records.herdr(&[("herdr:w1:p1".into(), record(State::Done, None))]));
|
||||
assert!(records.seen());
|
||||
let ids: Vec<&str> = records.iter().map(|(id, _)| id).collect();
|
||||
assert_eq!(ids, ["c", "herdr:w1:p1"]);
|
||||
assert!(!records.seen(), "nothing more to see");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_change_of_the_state_a_row_shows_is_noted() {
|
||||
let mut records = Records::default();
|
||||
assert!(!records.restate(), "none shows yet");
|
||||
records.report(String::new(), record(State::Blocked, None));
|
||||
assert!(records.restate());
|
||||
records.report(String::new(), record(State::Working, None));
|
||||
assert!(records.restate());
|
||||
records.report(String::new(), record(State::Blocked, None));
|
||||
assert!(records.restate(), "waiting again after work in one output");
|
||||
assert_eq!(records.shows(), Some(State::Blocked));
|
||||
records.report("b".into(), record(State::Working, None));
|
||||
assert!(!records.restate(), "the row shows what it showed");
|
||||
records.reset();
|
||||
assert!(records.restate());
|
||||
assert_eq!(records.shows(), None);
|
||||
}
|
||||
+210
-126
@@ -7,16 +7,26 @@ use alacritty_terminal::{
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use polling::{Event, PollMode, Poller};
|
||||
|
||||
use super::{mouse::MouseMode, pty::PtyEvent};
|
||||
use super::{
|
||||
marks,
|
||||
notify::{self, Kitty},
|
||||
prompts::{self, Prompts},
|
||||
pty::PtyEvent,
|
||||
status,
|
||||
titles::{self, Titles},
|
||||
};
|
||||
|
||||
/// The longest OSC body kept; longer ones are dropped whole.
|
||||
const MOST: usize = 4096;
|
||||
pub(super) const MOST: usize = 4096;
|
||||
|
||||
/// A pty whose reader shows each byte to its scanners before the parser.
|
||||
pub(crate) struct Tapped<P> {
|
||||
pub inner: P,
|
||||
scanner: Osc7,
|
||||
mouse: MouseModes,
|
||||
scanner: Osc,
|
||||
modes: Modes,
|
||||
prompts: Prompts,
|
||||
kitty: Kitty,
|
||||
titles: Titles,
|
||||
events: UnboundedSender<PtyEvent>,
|
||||
pid: u32,
|
||||
last: Option<PathBuf>,
|
||||
@@ -26,8 +36,11 @@ impl<P> Tapped<P> {
|
||||
pub fn new(inner: P, events: UnboundedSender<PtyEvent>, pid: u32) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
scanner: Osc7::default(),
|
||||
mouse: MouseModes::default(),
|
||||
scanner: Osc::default(),
|
||||
modes: Modes::default(),
|
||||
prompts: Prompts::default(),
|
||||
kitty: Kitty::default(),
|
||||
titles: Titles::default(),
|
||||
events,
|
||||
pid,
|
||||
last: None,
|
||||
@@ -37,29 +50,112 @@ impl<P> Tapped<P> {
|
||||
|
||||
impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
let count = self.inner.reader().read(buf)?;
|
||||
// What a rewrite adds stays inside the buffer, with room for the held sequence's own.
|
||||
let room = buf
|
||||
.len()
|
||||
.saturating_sub(self.prompts.held() + prompts::SLACK);
|
||||
let take = room / prompts::GROWTH;
|
||||
if take == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let count = self.inner.reader().read(&mut buf[..take])?;
|
||||
let read = &buf[..count];
|
||||
let (events, pid, last) = (&self.events, self.pid, &mut self.last);
|
||||
self.scanner.feed(&buf[..count], &mut |body| {
|
||||
let Some(folder) = folder(body) else {
|
||||
return;
|
||||
};
|
||||
if last.as_ref() == Some(&folder) {
|
||||
log::debug!("pty {pid}: folder {} again", folder.display());
|
||||
} else {
|
||||
log::info!("pty {pid}: folder {}", folder.display());
|
||||
*last = Some(folder.clone());
|
||||
}
|
||||
if events.unbounded_send(PtyEvent::Folder(folder)).is_err() {
|
||||
log::debug!("pty {pid}: a folder report after its session closed");
|
||||
}
|
||||
let (kitty, titles) = (&mut self.kitty, &mut self.titles);
|
||||
self.scanner.feed(read, &mut |found| {
|
||||
report(found, events, pid, last, kitty, titles)
|
||||
});
|
||||
if let Some(mode) = self.mouse.feed(&buf[..count]) {
|
||||
#[cfg(windows)]
|
||||
let win32 = self.modes.win32;
|
||||
if let Some(mode) = self.modes.feed(read) {
|
||||
log::info!("pty {pid}: mouse {mode:?}");
|
||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
#[cfg(windows)]
|
||||
if self.modes.win32 != win32 {
|
||||
log::info!("pty {pid}: win32-input-mode {}", self.modes.win32);
|
||||
if events
|
||||
.unbounded_send(PtyEvent::Win32(self.modes.win32))
|
||||
.is_err()
|
||||
{
|
||||
log::debug!("pty {pid}: win32-input-mode after its session closed");
|
||||
}
|
||||
}
|
||||
let out = self.prompts.pass(&buf[..count]);
|
||||
buf[..out.len()].copy_from_slice(out);
|
||||
Ok(out.len())
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends the session what the scanner found: a full reset, a title, a mark, a program's status or ask, or a folder.
|
||||
fn report(
|
||||
found: Found,
|
||||
events: &UnboundedSender<PtyEvent>,
|
||||
pid: u32,
|
||||
last: &mut Option<PathBuf>,
|
||||
kitty: &mut Kitty,
|
||||
titles: &mut Titles,
|
||||
) {
|
||||
let event = match found {
|
||||
Found::Reset => {
|
||||
log::debug!("pty {pid}: a full reset");
|
||||
titles.reset();
|
||||
PtyEvent::Reset
|
||||
}
|
||||
Found::Push => {
|
||||
titles.push();
|
||||
return;
|
||||
}
|
||||
Found::Pop => match titles.pop() {
|
||||
Some(title) => PtyEvent::Title(title),
|
||||
None => return,
|
||||
},
|
||||
Found::Osc(body, length) => {
|
||||
if let Some(mark) = body.strip_prefix(b"133;").and_then(marks::read) {
|
||||
log::debug!("pty {pid}: {mark:?}");
|
||||
PtyEvent::Mark(mark)
|
||||
} else if let Some(status) = body
|
||||
.strip_prefix(b"7501;")
|
||||
.and_then(|rest| status::read(rest, length))
|
||||
{
|
||||
log::debug!("pty {pid}: {status:?}");
|
||||
PtyEvent::Status(status)
|
||||
} else if let Some(ask) = ask(body, kitty) {
|
||||
log::debug!("pty {pid}: {ask:?}");
|
||||
PtyEvent::Ask(ask)
|
||||
} else if let Some(folder) = body.strip_prefix(b"7;").and_then(folder) {
|
||||
if last.as_ref() == Some(&folder) {
|
||||
log::debug!("pty {pid}: folder {} again", folder.display());
|
||||
} else {
|
||||
log::info!("pty {pid}: folder {}", folder.display());
|
||||
*last = Some(folder.clone());
|
||||
}
|
||||
PtyEvent::Folder(folder)
|
||||
} else if let Some(rest) = body
|
||||
.strip_prefix(b"0;")
|
||||
.or_else(|| body.strip_prefix(b"2;"))
|
||||
{
|
||||
PtyEvent::Title(titles.set(rest))
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if events.unbounded_send(event).is_err() {
|
||||
log::debug!("pty {pid}: an event after its session closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification or progress an OSC 99, 777 or 9 body asks for.
|
||||
fn ask(body: &[u8], kitty: &mut Kitty) -> Option<notify::Ask> {
|
||||
if let Some(rest) = body.strip_prefix(b"99;") {
|
||||
kitty.read(rest)
|
||||
} else if let Some(rest) = body.strip_prefix(b"777;") {
|
||||
notify::rxvt(rest)
|
||||
} else {
|
||||
body.strip_prefix(b"9;").and_then(notify::nine)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,19 +207,32 @@ enum State {
|
||||
Ground,
|
||||
Escape,
|
||||
Osc,
|
||||
OscEscape,
|
||||
Csi,
|
||||
}
|
||||
|
||||
/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads.
|
||||
/// What the scanner finds: an OSC's body with the sequence's length from OSC through ST, a full reset, or the title stack's push and pop.
|
||||
pub(crate) enum Found<'a> {
|
||||
Osc(&'a [u8], usize),
|
||||
Reset,
|
||||
Push,
|
||||
Pop,
|
||||
}
|
||||
|
||||
/// Finds each OSC body across reads, ended as vte ends it: at BEL, ESC, CAN or SUB, each full reset, and CSI 22 t and 23 t.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Osc7 {
|
||||
pub(crate) struct Osc {
|
||||
state: State,
|
||||
body: Vec<u8>,
|
||||
/// The sequence's bytes so far, the controls the body leaves out too.
|
||||
length: usize,
|
||||
overflow: bool,
|
||||
/// A CSI's first parameter while it reads as a plain number, and its fields so far.
|
||||
first: Option<u32>,
|
||||
fields: usize,
|
||||
}
|
||||
|
||||
impl Osc7 {
|
||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
|
||||
impl Osc {
|
||||
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(Found)) {
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == State::Ground {
|
||||
@@ -141,42 +250,97 @@ impl Osc7 {
|
||||
self.state = match (self.state, byte) {
|
||||
(State::Escape, b']') => {
|
||||
self.body.clear();
|
||||
self.length = 2;
|
||||
self.overflow = false;
|
||||
State::Osc
|
||||
}
|
||||
(State::Escape, 0x1b) => State::Escape,
|
||||
(State::Escape, _) => State::Ground,
|
||||
(State::Osc, 0x07) | (State::OscEscape, b'\\') => {
|
||||
self.finish(found);
|
||||
(State::Escape, b'c') => {
|
||||
found(Found::Reset);
|
||||
State::Ground
|
||||
}
|
||||
(State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground,
|
||||
(State::Osc, 0x1b) => State::OscEscape,
|
||||
(State::OscEscape, b']') => {
|
||||
self.body.clear();
|
||||
self.overflow = false;
|
||||
(State::Escape, b'[') => {
|
||||
self.first = Some(0);
|
||||
self.fields = 1;
|
||||
State::Csi
|
||||
}
|
||||
// vte runs controls in an escape, passes DEL and high bytes by, and stays there.
|
||||
(State::Escape, 0x00..=0x17 | 0x19 | 0x1b..=0x1f | 0x7f..=0xff) => State::Escape,
|
||||
(State::Escape, _) => State::Ground,
|
||||
(State::Osc, 0x07 | 0x18 | 0x1a) => {
|
||||
self.finish(1, found);
|
||||
State::Ground
|
||||
}
|
||||
// ESC ends the body, then reads as an escape: `\` makes ST, counted so.
|
||||
(State::Osc, 0x1b) => {
|
||||
self.finish(2, found);
|
||||
State::Escape
|
||||
}
|
||||
(State::Osc, 0x00..=0x06 | 0x08..=0x17 | 0x19 | 0x1c..=0x1f) => {
|
||||
self.length += 1;
|
||||
State::Osc
|
||||
}
|
||||
(State::OscEscape, 0x1b) => State::Escape,
|
||||
(State::OscEscape, _) => State::Ground,
|
||||
(State::Osc, byte) => {
|
||||
if self.body.len() < MOST {
|
||||
self.length += 1;
|
||||
if self.body.len() < self.most() {
|
||||
self.body.push(byte);
|
||||
} else {
|
||||
self.overflow = true;
|
||||
}
|
||||
State::Osc
|
||||
}
|
||||
// In a CSI, as in vte: CAN and SUB cancel, ESC restarts, controls run, DEL and high bytes pass by.
|
||||
(State::Csi, 0x18 | 0x1a) => State::Ground,
|
||||
(State::Csi, 0x1b) => State::Escape,
|
||||
(State::Csi, 0x00..=0x1f | 0x7f..=0xff) => State::Csi,
|
||||
(State::Csi, b'0'..=b'9') => {
|
||||
if self.fields == 1 {
|
||||
self.first = self.first.map(|first| {
|
||||
first
|
||||
.saturating_mul(10)
|
||||
.saturating_add(u32::from(byte - b'0'))
|
||||
});
|
||||
}
|
||||
State::Csi
|
||||
}
|
||||
(State::Csi, b';' | b':') => {
|
||||
self.fields += 1;
|
||||
State::Csi
|
||||
}
|
||||
(State::Csi, b't') => {
|
||||
match self.first.filter(|_| self.fields <= PARAMS) {
|
||||
Some(22) => found(Found::Push),
|
||||
Some(23) => found(Found::Pop),
|
||||
_ => {}
|
||||
}
|
||||
State::Ground
|
||||
}
|
||||
(State::Csi, 0x40..=0x7e) => State::Ground,
|
||||
// A private marker or an intermediate makes another sequence.
|
||||
(State::Csi, _) => {
|
||||
self.first = None;
|
||||
State::Csi
|
||||
}
|
||||
(State::Ground, _) => unreachable!("ground scans by position"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
|
||||
/// The longest body kept: an OSC 99's holds its payload past its metadata, a title's more.
|
||||
fn most(&self) -> usize {
|
||||
if self.body.starts_with(b"99;") {
|
||||
notify::BODY
|
||||
} else if self.body.starts_with(b"0;") || self.body.starts_with(b"2;") {
|
||||
titles::LONGEST
|
||||
} else {
|
||||
MOST
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, end: usize, found: &mut impl FnMut(Found)) {
|
||||
if self.overflow {
|
||||
log::warn!("pty: an OSC over {MOST} bytes dropped");
|
||||
} else if let Some(body) = self.body.strip_prefix(b"7;") {
|
||||
found(body);
|
||||
log::warn!("pty: an OSC over {} bytes dropped", self.most());
|
||||
} else {
|
||||
found(Found::Osc(&self.body, self.length + end));
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
@@ -185,89 +349,6 @@ impl Osc7 {
|
||||
/// The most values, subparameters too, one sequence holds before vte drops it.
|
||||
const PARAMS: usize = 32;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Csi {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
Bracket,
|
||||
Private,
|
||||
}
|
||||
|
||||
/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MouseModes {
|
||||
state: Csi,
|
||||
params: Vec<u32>,
|
||||
/// Values begun, subparameters too.
|
||||
fields: usize,
|
||||
/// Past a `:`, whose subparameters the parser's mode sets skip.
|
||||
sub: bool,
|
||||
mode: MouseMode,
|
||||
}
|
||||
|
||||
impl MouseModes {
|
||||
/// The mode after `bytes`, when they changed it.
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> Option<MouseMode> {
|
||||
let was = self.mode;
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == Csi::Ground {
|
||||
let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else {
|
||||
break;
|
||||
};
|
||||
self.state = Csi::Escape;
|
||||
rest = &rest[at + 1..];
|
||||
continue;
|
||||
}
|
||||
let byte = rest[0];
|
||||
rest = &rest[1..];
|
||||
self.state = match (self.state, byte) {
|
||||
(_, 0x18 | 0x1a) => Csi::Ground,
|
||||
(_, 0x1b) => Csi::Escape,
|
||||
// Mid-sequence, C0 runs and DEL is skipped, as in vte.
|
||||
(state, 0x00..=0x1f | 0x7f..=0xff) => state,
|
||||
(Csi::Escape, b'[') => Csi::Bracket,
|
||||
(Csi::Escape, b'c') => {
|
||||
self.mode = MouseMode::default();
|
||||
Csi::Ground
|
||||
}
|
||||
(Csi::Bracket, b'?') => {
|
||||
self.params = vec![0];
|
||||
self.fields = 1;
|
||||
self.sub = false;
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'0'..=b'9') => {
|
||||
if !self.sub {
|
||||
let last = self.params.last_mut().expect("a parameter in progress");
|
||||
*last = last
|
||||
.saturating_mul(10)
|
||||
.saturating_add(u32::from(byte - b'0'));
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b':' | b';') if self.fields < PARAMS => {
|
||||
self.fields += 1;
|
||||
self.sub = byte == b':';
|
||||
if !self.sub {
|
||||
self.params.push(0);
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'h' | b'l') => {
|
||||
for &mode in &self.params {
|
||||
self.mode.apply(mode, byte == b'h');
|
||||
}
|
||||
Csi::Ground
|
||||
}
|
||||
_ => Csi::Ground,
|
||||
};
|
||||
}
|
||||
(self.mode != was).then_some(self.mode)
|
||||
}
|
||||
}
|
||||
|
||||
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
|
||||
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
|
||||
let Some(rest) = body.strip_prefix(b"file://") else {
|
||||
@@ -327,5 +408,8 @@ fn decode(bytes: &[u8]) -> Option<String> {
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
mod modes;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) use modes::Modes;
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The private modes the tap follows before the parser.
|
||||
|
||||
use super::PARAMS;
|
||||
use crate::term::mouse::MouseMode;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Csi {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
Bracket,
|
||||
Private,
|
||||
}
|
||||
|
||||
/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9. On Windows, win32-input-mode too.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Modes {
|
||||
state: Csi,
|
||||
params: Vec<u32>,
|
||||
/// Values begun, subparameters too.
|
||||
fields: usize,
|
||||
/// Past a `:`, whose subparameters the parser's mode sets skip.
|
||||
sub: bool,
|
||||
pub(super) mode: MouseMode,
|
||||
/// Keys go as Windows' records, as ConPTY asks; a full reset ends it, as in Windows Terminal.
|
||||
#[cfg(windows)]
|
||||
pub(super) win32: bool,
|
||||
}
|
||||
|
||||
impl Modes {
|
||||
/// The mode after `bytes`, when they changed it.
|
||||
pub fn feed(&mut self, bytes: &[u8]) -> Option<MouseMode> {
|
||||
let was = self.mode;
|
||||
let mut rest = bytes;
|
||||
while !rest.is_empty() {
|
||||
if self.state == Csi::Ground {
|
||||
let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else {
|
||||
break;
|
||||
};
|
||||
self.state = Csi::Escape;
|
||||
rest = &rest[at + 1..];
|
||||
continue;
|
||||
}
|
||||
let byte = rest[0];
|
||||
rest = &rest[1..];
|
||||
self.state = match (self.state, byte) {
|
||||
(_, 0x18 | 0x1a) => Csi::Ground,
|
||||
(_, 0x1b) => Csi::Escape,
|
||||
// Mid-sequence, C0 runs and DEL is skipped, as in vte.
|
||||
(state, 0x00..=0x1f | 0x7f..=0xff) => state,
|
||||
(Csi::Escape, b'[') => Csi::Bracket,
|
||||
(Csi::Escape, b'c') => {
|
||||
self.mode = MouseMode::default();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
self.win32 = false;
|
||||
}
|
||||
Csi::Ground
|
||||
}
|
||||
(Csi::Bracket, b'?') => {
|
||||
self.params = vec![0];
|
||||
self.fields = 1;
|
||||
self.sub = false;
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'0'..=b'9') => {
|
||||
if !self.sub {
|
||||
let last = self.params.last_mut().expect("a parameter in progress");
|
||||
*last = last
|
||||
.saturating_mul(10)
|
||||
.saturating_add(u32::from(byte - b'0'));
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b':' | b';') if self.fields < PARAMS => {
|
||||
self.fields += 1;
|
||||
self.sub = byte == b':';
|
||||
if !self.sub {
|
||||
self.params.push(0);
|
||||
}
|
||||
Csi::Private
|
||||
}
|
||||
(Csi::Private, b'h' | b'l') => {
|
||||
for &mode in &self.params {
|
||||
self.mode.apply(mode, byte == b'h');
|
||||
#[cfg(windows)]
|
||||
if mode == crate::term::win32::MODE {
|
||||
self.win32 = byte == b'h';
|
||||
}
|
||||
}
|
||||
Csi::Ground
|
||||
}
|
||||
_ => Csi::Ground,
|
||||
};
|
||||
}
|
||||
(self.mode != was).then_some(self.mode)
|
||||
}
|
||||
}
|
||||
+128
-15
@@ -1,28 +1,71 @@
|
||||
use super::*;
|
||||
use crate::term::mouse::MouseMode;
|
||||
|
||||
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
|
||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||
let mut scanner = Osc7::default();
|
||||
let mut scanner = Osc::default();
|
||||
let mut found = Vec::new();
|
||||
for chunk in chunks {
|
||||
scanner.feed(chunk, &mut |body| {
|
||||
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
|
||||
scanner.feed(chunk, &mut |thing| {
|
||||
found.push(match thing {
|
||||
Found::Osc(body, _) => String::from_utf8(body.to_vec()).expect("utf-8"),
|
||||
Found::Reset => "RESET".into(),
|
||||
Found::Push => "PUSH".into(),
|
||||
Found::Pop => "POP".into(),
|
||||
})
|
||||
});
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
|
||||
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
|
||||
fn finds_a_full_reset_between_bodies() {
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]0;a\x07\x1bc\x1b", b"c\x1b]0;b\x1b\\"]),
|
||||
["0;a", "RESET", "RESET", "0;b"]
|
||||
);
|
||||
assert!(
|
||||
scan(&[b"\x1b[c\x1b]0;c"]).is_empty(),
|
||||
"DA1 and an unfinished body"
|
||||
);
|
||||
// vte passes DEL and high bytes by in an escape, and the reset still comes.
|
||||
assert_eq!(scan(&[b"\x1b\x7fc\x1b\xff", b"c"]), ["RESET", "RESET"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_sequences_and_cancels_pass() {
|
||||
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
|
||||
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
|
||||
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
|
||||
fn a_sequence_counts_from_osc_through_st_skipped_controls_too() {
|
||||
let mut lengths = Vec::new();
|
||||
let mut osc = b"\x1b]7501;".to_vec();
|
||||
osc.extend([0; 5000]);
|
||||
osc.extend_from_slice(b"state=done\x07\x1b]0;t\x1b\\");
|
||||
Osc::default().feed(&osc, &mut |found| {
|
||||
if let Found::Osc(body, length) = found {
|
||||
lengths.push((body.len(), length));
|
||||
}
|
||||
});
|
||||
assert_eq!(lengths, [(15, 5018), (3, 7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_each_osc_body_across_reads_ended_as_vte_ends_it() {
|
||||
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["7;file:///a"]);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]),
|
||||
["7;file:///b"]
|
||||
);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b", b"]133;A\x07\x1b]0;t\x1b\\"]),
|
||||
["133;A", "0;t"]
|
||||
);
|
||||
assert_eq!(scan(&[b"\x1b]7;x\x18\x1b]7;y\x1a"]), ["7;x", "7;y"]);
|
||||
assert_eq!(scan(&[b"\x1b]7;cut\x1b]133;D;1\x07"]), ["7;cut", "133;D;1"]);
|
||||
assert_eq!(scan(&[b"\x1b]0;t\x1b[31m"]), ["0;t"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_vte_skips_stay_out_of_a_body() {
|
||||
assert_eq!(scan(&[b"\x1b\x00]1\x0033;\x1fA\x07"]), ["133;A"]);
|
||||
assert_eq!(scan(&[b"\x1b[31m\x1bc"]), ["RESET"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -31,13 +74,20 @@ fn an_overlong_body_is_dropped() {
|
||||
long.extend(std::iter::repeat_n(b'a', MOST + 1));
|
||||
long.push(0x07);
|
||||
assert!(scan(&[&long]).is_empty());
|
||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
|
||||
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["7;b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_osc_99_body_holds_its_metadata_and_a_full_payload() {
|
||||
let osc = |length: usize| [&b"\x1b]99;"[..], &vec![b'a'; length - 3], b"\x07"].concat();
|
||||
assert_eq!(scan(&[&osc(notify::BODY)]).concat().len(), notify::BODY);
|
||||
assert!(scan(&[&osc(notify::BODY + 1)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
let mut modes = Modes::default();
|
||||
assert_eq!(modes.feed(b"a\x1b[?"), None);
|
||||
assert!(modes.feed(b"1006;9h").is_some());
|
||||
assert_eq!(modes.mode.format, Format::Sgr);
|
||||
@@ -57,7 +107,7 @@ fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||
#[test]
|
||||
fn mouse_modes_read_through_controls_as_the_parser_does() {
|
||||
use crate::term::mouse::{Format, Tracking};
|
||||
let mut modes = MouseModes::default();
|
||||
let mut modes = Modes::default();
|
||||
modes.feed(b"\x1b[?1000\x00h\x1b[?1006");
|
||||
modes.feed(b"\x00h");
|
||||
let on = MouseMode {
|
||||
@@ -171,10 +221,73 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
|
||||
.chain([values(31), values(32)])
|
||||
{
|
||||
let case = case.as_slice();
|
||||
let mut modes = MouseModes::default();
|
||||
let mut modes = Modes::default();
|
||||
for end in 1..=case.len() {
|
||||
modes.feed(&case[end - 1..end]);
|
||||
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_title_stack_s_push_and_pop_as_vte_reads_them() {
|
||||
assert_eq!(scan(&[b"\x1b[22;0t\x1b[23;2t"]), ["PUSH", "POP"]);
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b[2", b"2:1\x07t\x1b[23t"]),
|
||||
["PUSH", "POP"],
|
||||
"across reads, a control run"
|
||||
);
|
||||
for other in [
|
||||
&b"\x1b[?22t"[..],
|
||||
b"\x1b[22$t",
|
||||
b"\x1b[1;22t",
|
||||
b"\x1b[t",
|
||||
b"\x1b[22\x18t",
|
||||
b"\x1b[24t",
|
||||
] {
|
||||
assert!(
|
||||
scan(&[other]).is_empty(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(other)
|
||||
);
|
||||
}
|
||||
let mut many = b"\x1b[22".to_vec();
|
||||
many.extend(std::iter::repeat_n(&b";1"[..], 32).flatten());
|
||||
many.push(b't');
|
||||
assert!(scan(&[&many]).is_empty(), "vte drops one past 32 fields");
|
||||
assert_eq!(
|
||||
scan(&[b"\x1b[22t\x1b]2;t\x07\x1b[23t"]),
|
||||
["PUSH", "2;t", "POP"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_s_body_holds_65536_bytes_and_another_s_4096() {
|
||||
let osc = |number: &str, length: usize| {
|
||||
[
|
||||
format!("\x1b]{number};").as_bytes(),
|
||||
&vec![b'a'; length],
|
||||
b"\x07",
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
let longest = scan(&[&osc("2", titles::LONGEST - 2)]);
|
||||
assert_eq!(longest.concat().len(), titles::LONGEST);
|
||||
assert!(scan(&[&osc("0", titles::LONGEST - 1)]).is_empty());
|
||||
assert!(
|
||||
scan(&[&osc("7", MOST - 1)]).is_empty(),
|
||||
"another stays at 4096"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn win32_input_mode_follows_conptys_ask_until_a_full_reset() {
|
||||
let mut modes = Modes::default();
|
||||
modes.feed(b"\x1b[c\x1b[?1004h\x1b[?9001h");
|
||||
assert!(modes.win32);
|
||||
modes.feed(b"\x1b[?9001l");
|
||||
assert!(!modes.win32);
|
||||
modes.feed(b"\x1b[?9001h\x1bc");
|
||||
assert!(!modes.win32, "a full reset ends it");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// The titles alacritty saves before it drops the oldest.
|
||||
const SAVED: usize = 4096;
|
||||
|
||||
/// vte keeps 16 parameters of an OSC, its number among them.
|
||||
const PARTS: usize = 15;
|
||||
|
||||
/// The longest OSC 0 or 2 body the tap keeps; a longer one drops, logged.
|
||||
pub(super) const LONGEST: usize = 65536;
|
||||
|
||||
/// The title a session shows, read in the output's order as vte and alacritty read it.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Titles {
|
||||
/// Empty while the program names none.
|
||||
title: String,
|
||||
saved: Vec<String>,
|
||||
}
|
||||
|
||||
impl Titles {
|
||||
/// Sets the title from an OSC 0 or 2 body past its number, as vte splits it and alacritty joins it.
|
||||
pub fn set(&mut self, rest: &[u8]) -> String {
|
||||
let parts: Vec<&str> = rest
|
||||
.split(|&byte| byte == b';')
|
||||
.take(PARTS)
|
||||
.filter_map(|part| std::str::from_utf8(part).ok())
|
||||
.collect();
|
||||
self.title = parts.join(";").trim().to_owned();
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
/// Saves the title: CSI 22 t.
|
||||
pub fn push(&mut self) {
|
||||
if self.saved.len() == SAVED {
|
||||
self.saved.remove(0);
|
||||
}
|
||||
self.saved.push(self.title.clone());
|
||||
}
|
||||
|
||||
/// Brings the last title saved back: CSI 23 t; none when none was saved.
|
||||
pub fn pop(&mut self) -> Option<String> {
|
||||
let title = self.saved.pop()?;
|
||||
self.title.clone_from(&title);
|
||||
Some(title)
|
||||
}
|
||||
|
||||
/// A full reset empties the title and those saved.
|
||||
pub fn reset(&mut self) {
|
||||
self.title.clear();
|
||||
self.saved.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_title_reads_as_alacritty_joins_it() {
|
||||
let mut titles = Titles::default();
|
||||
assert_eq!(titles.set(b" a;b "), "a;b");
|
||||
assert_eq!(
|
||||
titles.set(b"a;\xff;b"),
|
||||
"a;b",
|
||||
"a part not in UTF-8 passes by"
|
||||
);
|
||||
assert_eq!(titles.set(b""), "");
|
||||
let parts: Vec<String> = (1..=17).map(|at| at.to_string()).collect();
|
||||
assert_eq!(
|
||||
titles.set(parts.join(";").as_bytes()),
|
||||
parts[..PARTS].join(";"),
|
||||
"vte keeps 16 parameters, the number among them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_saved_comes_back_and_a_reset_empties_both() {
|
||||
let mut titles = Titles::default();
|
||||
assert_eq!(titles.pop(), None, "none saved");
|
||||
titles.set(b"shell");
|
||||
titles.push();
|
||||
titles.set(b"vim");
|
||||
assert_eq!(titles.pop().as_deref(), Some("shell"));
|
||||
assert_eq!(titles.pop(), None);
|
||||
titles.push();
|
||||
titles.reset();
|
||||
assert_eq!(titles.pop(), None, "a reset empties those saved");
|
||||
for at in 0..=SAVED {
|
||||
titles.set(at.to_string().as_bytes());
|
||||
titles.push();
|
||||
}
|
||||
assert_eq!(titles.saved.len(), SAVED);
|
||||
assert_eq!(titles.saved[0], "1", "the oldest went");
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -45,8 +45,7 @@ impl Pty {
|
||||
self.child.id()
|
||||
}
|
||||
|
||||
/// A copy of the master; tests read its modes.
|
||||
#[cfg(test)]
|
||||
/// A copy of the master, which reads the foreground and, in tests, the modes.
|
||||
pub fn master(&self) -> io::Result<File> {
|
||||
self.file.try_clone()
|
||||
}
|
||||
|
||||
+105
-18
@@ -1,4 +1,4 @@
|
||||
use std::{collections::HashSet, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
|
||||
use std::{collections::HashMap, path::PathBuf, process::ExitStatus, rc::Rc, time::Duration};
|
||||
|
||||
use alacritty_terminal::{
|
||||
event::Event,
|
||||
@@ -21,11 +21,14 @@ use super::{
|
||||
find::Finder,
|
||||
frame::frame,
|
||||
ink::Ink,
|
||||
input::{self, Find},
|
||||
input::{self, Find, Held, NextPrompt, PreviousPrompt},
|
||||
marks::Mark,
|
||||
mouse::MouseMode,
|
||||
paint::{self, Look},
|
||||
pointer::Pointer,
|
||||
pty::{Events, Pty, PtyEvent, config, fit},
|
||||
reports::Notices,
|
||||
status::{Records, State},
|
||||
zoom,
|
||||
};
|
||||
use crate::{
|
||||
@@ -48,6 +51,8 @@ pub enum TerminalEvent {
|
||||
Title(String),
|
||||
Folder(PathBuf),
|
||||
Bell,
|
||||
/// The state its row shows changed, to this one.
|
||||
Shows(Option<State>),
|
||||
Exited(ExitStatus),
|
||||
}
|
||||
|
||||
@@ -62,10 +67,31 @@ pub struct TerminalView {
|
||||
pub(super) pointer: Pointer,
|
||||
/// The mouse mode, as the tap follows it.
|
||||
pub(super) mouse: MouseMode,
|
||||
/// Keys whose press this terminal saw, so their releases may go out.
|
||||
pub(super) held: HashSet<String>,
|
||||
/// Keys go as Windows' records, as ConPTY asks.
|
||||
#[cfg(windows)]
|
||||
pub(super) win32: bool,
|
||||
/// herdr's client leads the session, so herdr opens its panes' links.
|
||||
pub(super) herdr: bool,
|
||||
/// Keys whose press this terminal saw, so their releases may go out; true where it went as Windows' record.
|
||||
pub(super) held: HashMap<Held, bool>,
|
||||
pub(super) finder: Option<Finder>,
|
||||
pub(super) size: (usize, usize),
|
||||
/// How many folders the program has reported.
|
||||
pub(super) reports: u64,
|
||||
/// What programs say they are doing, OSC 7501.
|
||||
pub(super) records: Records,
|
||||
/// The status its shell's last command ended with; none before one, or when it gave none.
|
||||
pub(super) ended: Option<i32>,
|
||||
/// A command started, as OSC 133 C says, since the last one ended.
|
||||
pub(super) ran: bool,
|
||||
/// The system notifications it posted.
|
||||
pub(super) notices: Notices,
|
||||
/// The program's title; empty while it names none.
|
||||
pub(super) title: String,
|
||||
/// It is the session shown.
|
||||
pub(super) front: bool,
|
||||
/// Its window is active.
|
||||
pub(super) active: bool,
|
||||
cell: (u16, u16),
|
||||
/// Focused in an active window, as the program last heard.
|
||||
focused: bool,
|
||||
@@ -73,6 +99,7 @@ pub struct TerminalView {
|
||||
shown: bool,
|
||||
_events: Task<()>,
|
||||
_blink: Task<()>,
|
||||
_herdr: Task<()>,
|
||||
_focus: [Subscription; 3],
|
||||
_settings: Subscription,
|
||||
_zoom: Subscription,
|
||||
@@ -108,15 +135,27 @@ impl TerminalView {
|
||||
caret: Bounds::default(),
|
||||
pointer: Pointer::default(),
|
||||
mouse: MouseMode::default(),
|
||||
held: HashSet::new(),
|
||||
#[cfg(windows)]
|
||||
win32: false,
|
||||
herdr: false,
|
||||
held: HashMap::new(),
|
||||
finder: None,
|
||||
size,
|
||||
reports: 0,
|
||||
records: Records::default(),
|
||||
ended: None,
|
||||
ran: false,
|
||||
notices: Notices::default(),
|
||||
title: String::new(),
|
||||
front: true,
|
||||
active: false,
|
||||
cell: (8, 16),
|
||||
focused: false,
|
||||
blinking: false,
|
||||
shown: true,
|
||||
_events: Self::pump(events, cx),
|
||||
_blink: Self::blink(cx),
|
||||
_herdr: super::herdr::follow(cx),
|
||||
_settings: cx.observe_global::<SettingsStore>(|view, cx| {
|
||||
view.pty
|
||||
.term
|
||||
@@ -133,6 +172,21 @@ impl TerminalView {
|
||||
self.pty.pid
|
||||
}
|
||||
|
||||
/// What programs in this terminal say they are doing.
|
||||
pub fn records(&self) -> &Records {
|
||||
&self.records
|
||||
}
|
||||
|
||||
/// The status its shell's last command ended with.
|
||||
pub fn ended(&self) -> Option<i32> {
|
||||
self.ended
|
||||
}
|
||||
|
||||
/// Whether it is the session shown: one shown in an active window posts no notification.
|
||||
pub fn set_front(&mut self, front: bool) {
|
||||
self.front = front;
|
||||
}
|
||||
|
||||
fn pump(mut events: Events, cx: &mut Context<Self>) -> Task<()> {
|
||||
cx.spawn(async move |view, cx| {
|
||||
while let Some(first) = events.next().await {
|
||||
@@ -180,6 +234,7 @@ impl TerminalView {
|
||||
|
||||
/// Tells a program that asked, by mode 1004, when the terminal gains or loses focus.
|
||||
fn refocus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.active = window.is_window_active();
|
||||
let focused = self.focus.is_focused(window) && window.is_window_active();
|
||||
if focused != self.focused {
|
||||
self.focused = focused;
|
||||
@@ -207,9 +262,40 @@ impl TerminalView {
|
||||
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
|
||||
for event in events {
|
||||
match event {
|
||||
PtyEvent::Folder(folder) => cx.emit(TerminalEvent::Folder(folder)),
|
||||
PtyEvent::Folder(folder) => {
|
||||
self.reports += 1;
|
||||
cx.emit(TerminalEvent::Folder(folder));
|
||||
}
|
||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||
#[cfg(windows)]
|
||||
PtyEvent::Win32(on) => self.win32 = on,
|
||||
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
|
||||
// A new prompt ends what was working, waiting or idle; done and error stay.
|
||||
PtyEvent::Mark(Mark::Prompt) => {
|
||||
self.records.prompt();
|
||||
self.restate(cx);
|
||||
}
|
||||
PtyEvent::Mark(Mark::Output) => self.ran = true,
|
||||
PtyEvent::Mark(_) => {}
|
||||
PtyEvent::Status(status) => {
|
||||
self.status(status);
|
||||
self.restate(cx);
|
||||
}
|
||||
PtyEvent::Ask(ask) => {
|
||||
self.ask(ask, cx);
|
||||
self.restate(cx);
|
||||
}
|
||||
PtyEvent::Title(title) => {
|
||||
self.title.clone_from(&title);
|
||||
cx.emit(TerminalEvent::Title(title));
|
||||
}
|
||||
PtyEvent::Reset => {
|
||||
self.records.reset();
|
||||
self.title.clear();
|
||||
cx.emit(TerminalEvent::Title(String::new()));
|
||||
self.restate(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
|
||||
@@ -222,14 +308,11 @@ impl TerminalView {
|
||||
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
|
||||
match event {
|
||||
Event::Wakeup => self.shown = true,
|
||||
Event::MouseCursorDirty => {}
|
||||
// A reset empties the title and sends only this; setting the options sends the title.
|
||||
Event::CursorBlinkingChange => {
|
||||
let settings = config(SettingsStore::get(cx));
|
||||
self.pty.term.lock().set_options(settings);
|
||||
}
|
||||
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
|
||||
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
|
||||
// The tap reads the title in the output's order; alacritty's title events come after it.
|
||||
Event::MouseCursorDirty
|
||||
| Event::CursorBlinkingChange
|
||||
| Event::Title(_)
|
||||
| Event::ResetTitle => {}
|
||||
Event::Bell => {
|
||||
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
|
||||
cx.emit(TerminalEvent::Bell);
|
||||
@@ -243,9 +326,9 @@ impl TerminalView {
|
||||
self.pty.pid
|
||||
),
|
||||
Event::ColorRequest(index, reply) => {
|
||||
let theme = themes::active(SettingsStore::get(cx), cx.window_appearance());
|
||||
let theme = themes::active(SettingsStore::get(cx), cx.window_appearance(), cx);
|
||||
let term = self.pty.term.lock();
|
||||
let rgb = Ink::new(theme, term.colors()).query(index, term.colors());
|
||||
let rgb = Ink::new(&theme, term.colors()).query(index, term.colors());
|
||||
drop(term);
|
||||
self.pty.write(reply(rgb).into_bytes());
|
||||
}
|
||||
@@ -322,9 +405,9 @@ fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow:
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = SettingsStore::get(cx).clone();
|
||||
let theme = themes::active(&settings, window.appearance());
|
||||
let theme = themes::active(&settings, window.appearance(), cx);
|
||||
let term = self.pty.term.lock();
|
||||
let ink = Ink::new(theme, term.colors());
|
||||
let ink = Ink::new(&theme, term.colors());
|
||||
let lit = self.finder.as_ref().map_or_else(Vec::new, |finder| {
|
||||
finder.lit(&term, cx.theme().colors.warning)
|
||||
});
|
||||
@@ -357,6 +440,10 @@ impl Render for TerminalView {
|
||||
.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_action(
|
||||
cx.listener(|view, _: &PreviousPrompt, _, cx| view.scroll_to_prompt(true, cx)),
|
||||
)
|
||||
.on_action(cx.listener(|view, _: &NextPrompt, _, cx| view.scroll_to_prompt(false, cx)))
|
||||
.on_any_mouse_down(cx.listener(Self::mouse_down))
|
||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||
.on_scroll_wheel(cx.listener(Self::wheel))
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! win32-input-mode, which ConPTY asks for: a key goes as Windows' own record, as Windows Terminal sends it.
|
||||
|
||||
use gpui::Keystroke;
|
||||
use windows_sys::Win32::{
|
||||
System::Console::{
|
||||
CAPSLOCK_ON, ENHANCED_KEY, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, NUMLOCK_ON,
|
||||
RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SCROLLLOCK_ON, SHIFT_PRESSED,
|
||||
},
|
||||
UI::Input::KeyboardAndMouse::{
|
||||
GetKeyState, GetKeyboardState, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW,
|
||||
ToUnicode, VK_APPS, VK_BACK, VK_BROWSER_BACK, VK_BROWSER_FORWARD, VK_CAPITAL, VK_DELETE,
|
||||
VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, VK_LMENU,
|
||||
VK_NEXT, VK_NUMLOCK, VK_PRIOR, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_SCROLL,
|
||||
VK_SHIFT, VK_SPACE, VK_TAB, VK_UP, VkKeyScanW,
|
||||
},
|
||||
};
|
||||
|
||||
use super::keys::Action;
|
||||
|
||||
/// The mode ConPTY sets, `CSI ? 9001 h`.
|
||||
pub const MODE: u32 = 9001;
|
||||
|
||||
/// `keystroke` as ConPTY reads it, `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`; none for a key no virtual key names.
|
||||
pub fn record(keystroke: &Keystroke, action: Action) -> Option<Vec<u8>> {
|
||||
let (key, enhanced) = virtual_key(&keystroke.key)?;
|
||||
let scan = unsafe { MapVirtualKeyW(u32::from(key), MAPVK_VK_TO_VSC) };
|
||||
let mut state = [0u8; 256];
|
||||
let mut unit = [0u16; 2];
|
||||
// Flag 4 leaves the keyboard's dead key as it is.
|
||||
let typed = unsafe {
|
||||
if GetKeyboardState(state.as_mut_ptr()) == 0 {
|
||||
log::error!("windows: the keyboard state did not read");
|
||||
return None;
|
||||
}
|
||||
ToUnicode(
|
||||
u32::from(key),
|
||||
scan,
|
||||
state.as_ptr(),
|
||||
unit.as_mut_ptr(),
|
||||
unit.len() as i32,
|
||||
4,
|
||||
)
|
||||
};
|
||||
let unit = if typed == 1 { unit[0] } else { 0 };
|
||||
let down = u8::from(action != Action::Release);
|
||||
let control = control(enhanced);
|
||||
Some(format!("\x1b[{key};{scan};{unit};{down};{control};1_").into_bytes())
|
||||
}
|
||||
|
||||
/// The key's own character, where gpui named `made`, what Shift made of it, and dropped Shift.
|
||||
pub fn unshifted(made: &str) -> Option<char> {
|
||||
let mut chars = made.chars();
|
||||
let (Some(made), None) = (chars.next(), chars.next()) else {
|
||||
return None;
|
||||
};
|
||||
if unsafe { GetKeyState(i32::from(VK_SHIFT)) } >= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buffer = [0u16; 2];
|
||||
let &mut [unit] = made.encode_utf16(&mut buffer) else {
|
||||
return None;
|
||||
};
|
||||
// The high byte names the modifiers the character takes: here Shift alone.
|
||||
let scan = unsafe { VkKeyScanW(unit) } as u16;
|
||||
if scan == u16::MAX || scan >> 8 != 1 {
|
||||
return None;
|
||||
}
|
||||
let own = unsafe { MapVirtualKeyW(u32::from(scan & 0xFF), MAPVK_VK_TO_CHAR) } & 0xFFFF;
|
||||
char::from_u32(own)
|
||||
.map(|own| own.to_ascii_lowercase())
|
||||
.filter(|own| !own.is_control())
|
||||
}
|
||||
|
||||
/// The virtual key gpui's `name` stands for, and whether it sits in the enhanced block.
|
||||
pub fn virtual_key(name: &str) -> Option<(u16, bool)> {
|
||||
let named = match name {
|
||||
"enter" => (VK_RETURN, false),
|
||||
"tab" => (VK_TAB, false),
|
||||
"escape" => (VK_ESCAPE, false),
|
||||
"backspace" => (VK_BACK, false),
|
||||
"space" => (VK_SPACE, false),
|
||||
"menu" => (VK_APPS, true),
|
||||
"back" => (VK_BROWSER_BACK, true),
|
||||
"forward" => (VK_BROWSER_FORWARD, true),
|
||||
"left" => (VK_LEFT, true),
|
||||
"up" => (VK_UP, true),
|
||||
"right" => (VK_RIGHT, true),
|
||||
"down" => (VK_DOWN, true),
|
||||
"home" => (VK_HOME, true),
|
||||
"end" => (VK_END, true),
|
||||
"pageup" => (VK_PRIOR, true),
|
||||
"pagedown" => (VK_NEXT, true),
|
||||
"insert" => (VK_INSERT, true),
|
||||
"delete" => (VK_DELETE, true),
|
||||
name => {
|
||||
if let Some(number) = name
|
||||
.strip_prefix('f')
|
||||
.and_then(|number| number.parse::<u16>().ok())
|
||||
.filter(|number| (1..=24).contains(number))
|
||||
{
|
||||
return Some((VK_F1 + number - 1, false));
|
||||
}
|
||||
let mut chars = name.chars();
|
||||
let (Some(ch), None) = (chars.next(), chars.next()) else {
|
||||
return None;
|
||||
};
|
||||
let mut buffer = [0u16; 2];
|
||||
let &mut [unit] = ch.encode_utf16(&mut buffer) else {
|
||||
return None;
|
||||
};
|
||||
let scan = unsafe { VkKeyScanW(unit) } as u16;
|
||||
return (scan != u16::MAX).then_some((scan & 0xFF, false));
|
||||
}
|
||||
};
|
||||
Some(named)
|
||||
}
|
||||
|
||||
/// Windows' control key state now: the modifier keys, the locks, and the enhanced block.
|
||||
fn control(enhanced: bool) -> u32 {
|
||||
let down = |key: u16| unsafe { GetKeyState(i32::from(key)) } < 0;
|
||||
let on = |key: u16| unsafe { GetKeyState(i32::from(key)) } & 1 != 0;
|
||||
[
|
||||
(down(VK_SHIFT), SHIFT_PRESSED),
|
||||
(down(VK_LCONTROL), LEFT_CTRL_PRESSED),
|
||||
(down(VK_RCONTROL), RIGHT_CTRL_PRESSED),
|
||||
(down(VK_LMENU), LEFT_ALT_PRESSED),
|
||||
(down(VK_RMENU), RIGHT_ALT_PRESSED),
|
||||
(on(VK_CAPITAL), CAPSLOCK_ON),
|
||||
(on(VK_NUMLOCK), NUMLOCK_ON),
|
||||
(on(VK_SCROLL), SCROLLLOCK_ON),
|
||||
(enhanced, ENHANCED_KEY),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(held, _)| *held)
|
||||
.fold(0, |state, (_, flag)| state | flag)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_keys_and_letters_find_their_virtual_keys() {
|
||||
assert_eq!(virtual_key("enter"), Some((VK_RETURN, false)));
|
||||
assert_eq!(virtual_key("left"), Some((VK_LEFT, true)));
|
||||
assert_eq!(virtual_key("f12"), Some((VK_F1 + 11, false)));
|
||||
assert_eq!(virtual_key("f25"), None);
|
||||
assert_eq!(virtual_key("a"), Some((u16::from(b'A'), false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_names_the_key_its_scan_code_and_its_press() {
|
||||
let key = Keystroke::parse("enter").expect("a keystroke");
|
||||
let text =
|
||||
|action| String::from_utf8(record(&key, action).expect("a record")).expect("text");
|
||||
let scan = unsafe { MapVirtualKeyW(u32::from(VK_RETURN), MAPVK_VK_TO_VSC) };
|
||||
let down = text(Action::Press);
|
||||
assert!(
|
||||
down.starts_with(&format!("\x1b[13;{scan};13;1;")),
|
||||
"{down:?}"
|
||||
);
|
||||
assert!(down.ends_with(";1_"), "{down:?}");
|
||||
let up = text(Action::Release);
|
||||
assert!(up.starts_with(&format!("\x1b[13;{scan};13;0;")), "{up:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Xvfb for the X11 tests.
|
||||
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
process::{Child, Command, Stdio},
|
||||
};
|
||||
|
||||
/// An X server of the test's own, gone with it.
|
||||
pub struct Xvfb {
|
||||
child: Child,
|
||||
pub display: String,
|
||||
}
|
||||
|
||||
impl Xvfb {
|
||||
pub fn start() -> Self {
|
||||
let mut child = Command::new("Xvfb")
|
||||
.args(["-displayfd", "1", "-nolisten", "tcp"])
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Xvfb starts: the X11 tests need it");
|
||||
let mut number = String::new();
|
||||
let stdout = child.stdout.take().expect("Xvfb's stdout");
|
||||
BufReader::new(stdout)
|
||||
.read_line(&mut number)
|
||||
.expect("Xvfb names its display");
|
||||
let display = format!(":{}", number.trim());
|
||||
Self { child, display }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Xvfb {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = self.child.kill().and_then(|()| self.child.wait().map(drop)) {
|
||||
eprintln!("Xvfb {} did not stop: {error}", self.display);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,14 +76,14 @@ pub fn palette(theme: &TerminalTheme, opacity: f32) -> Palette {
|
||||
/// Dresses the app in the theme the settings choose for the system's appearance.
|
||||
pub fn apply(cx: &mut App) {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let theme = active(settings, cx.window_appearance());
|
||||
let theme = active(settings, cx.window_appearance(), cx);
|
||||
let opacity = settings.background_opacity;
|
||||
let mode = if theme.is_dark() {
|
||||
Mode::Dark
|
||||
} else {
|
||||
Mode::Light
|
||||
};
|
||||
Theme::set_palette(mode, Some(palette(theme, opacity)), cx);
|
||||
Theme::set_palette(mode, Some(palette(&theme, opacity)), cx);
|
||||
Theme::set_mode_now(mode, cx);
|
||||
log::info!("theme: {} in {mode:?}, glass at {opacity}", theme.id);
|
||||
}
|
||||
+39
-5
@@ -1,10 +1,12 @@
|
||||
mod chrome;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod omarchy;
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use ely_gpui_component::theme::Palette;
|
||||
use gpui::{Hsla, Rgba, WindowAppearance};
|
||||
use gpui::{App, Global, Hsla, Rgba, WindowAppearance};
|
||||
|
||||
pub use chrome::{apply, palette};
|
||||
|
||||
@@ -32,6 +34,14 @@ impl TerminalTheme {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings' name for Omarchy's theme, on Linux.
|
||||
pub const OMARCHY: &str = "omarchy";
|
||||
|
||||
/// Omarchy's current theme, while ELYTerminal follows it.
|
||||
pub struct Omarchy(pub TerminalTheme);
|
||||
|
||||
impl Global for Omarchy {}
|
||||
|
||||
/// Upstream Ghostty files from iTerm2-Color-Schemes: id, shown name, text.
|
||||
const FILES: [(&str, &str, &str); 12] = [
|
||||
(
|
||||
@@ -112,17 +122,41 @@ pub fn find(id: &str) -> Option<&'static TerminalTheme> {
|
||||
all().iter().find(|theme| theme.id == id)
|
||||
}
|
||||
|
||||
/// A theme the settings may name: built in, or Omarchy's on Linux.
|
||||
pub fn known(id: &str) -> bool {
|
||||
find(id).is_some() || (cfg!(target_os = "linux") && id == OMARCHY)
|
||||
}
|
||||
|
||||
/// A fresh install wears Omarchy's theme unless its file is surely absent.
|
||||
pub fn default_id() -> &'static str {
|
||||
#[cfg(target_os = "linux")]
|
||||
if omarchy::path().is_some_and(|path| !matches!(path.try_exists(), Ok(false))) {
|
||||
return OMARCHY;
|
||||
}
|
||||
"catppuccin-mocha"
|
||||
}
|
||||
|
||||
/// The theme the settings choose under the system's appearance.
|
||||
pub fn active(settings: &Settings, appearance: WindowAppearance) -> &'static TerminalTheme {
|
||||
pub fn chosen(settings: &Settings, appearance: WindowAppearance) -> &str {
|
||||
let dark = matches!(
|
||||
appearance,
|
||||
WindowAppearance::Dark | WindowAppearance::VibrantDark
|
||||
);
|
||||
let id = match (&settings.dark_theme, dark) {
|
||||
match (&settings.dark_theme, dark) {
|
||||
(Some(id), true) => id,
|
||||
_ => &settings.theme,
|
||||
};
|
||||
find(id).unwrap_or_else(|| panic!("settings name theme {id}, which validation admits"))
|
||||
}
|
||||
}
|
||||
|
||||
/// The chosen theme's colors; Omarchy's come from its global.
|
||||
pub fn active(settings: &Settings, appearance: WindowAppearance, cx: &App) -> TerminalTheme {
|
||||
let id = chosen(settings, appearance);
|
||||
if id == OMARCHY {
|
||||
return cx.global::<Omarchy>().0.clone();
|
||||
}
|
||||
find(id)
|
||||
.unwrap_or_else(|| panic!("settings name theme {id}, which validation admits"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn ely(id: &'static str, name: &'static str, palette: Palette) -> TerminalTheme {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::App;
|
||||
|
||||
use super::{OMARCHY, Omarchy, TerminalTheme, apply, parse};
|
||||
use crate::settings::SettingsStore;
|
||||
|
||||
/// How often Omarchy's theme file is read for a switch.
|
||||
const CHECK: Duration = Duration::from_millis(500);
|
||||
|
||||
/// A Ghostty theme file's keys; Omarchy's other lines pass by.
|
||||
const COLORS: [&str; 7] = [
|
||||
"palette",
|
||||
"background",
|
||||
"foreground",
|
||||
"cursor-color",
|
||||
"cursor-text",
|
||||
"selection-background",
|
||||
"selection-foreground",
|
||||
];
|
||||
|
||||
/// Omarchy 4's current theme for Ghostty, under $HOME whatever XDG_STATE_HOME says.
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|home| home.join(".local/state/omarchy/current/theme/ghostty.conf"))
|
||||
}
|
||||
|
||||
/// Follows Omarchy's theme; an unreadable file, or a bad one worn, stops the start.
|
||||
pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let worn = settings.theme == OMARCHY || settings.dark_theme.as_deref() == Some(OMARCHY);
|
||||
let path = path().context("omarchy: no home folder")?;
|
||||
match read(&path) {
|
||||
Ok((text, theme)) => {
|
||||
log::info!("omarchy: the theme follows {}", path.display());
|
||||
cx.set_global(Omarchy(theme));
|
||||
watch(path, text, cx);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if worn => Err(error.context("settings wear the Omarchy theme")),
|
||||
Err(error) if missing(&error) => {
|
||||
log::info!("omarchy: none at {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.is::<io::Error>() => Err(error),
|
||||
Err(error) => {
|
||||
log::error!("{error:#}; no Omarchy theme to follow");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Omarchy signals only Alacritty, kitty and Ghostty, so this reads the file.
|
||||
fn watch(path: PathBuf, mut shown: String, cx: &mut App) {
|
||||
cx.spawn(async move |cx| {
|
||||
let mut failed = String::new();
|
||||
loop {
|
||||
cx.background_executor().timer(CHECK).await;
|
||||
match check(&path, &shown) {
|
||||
Ok(None) => failed.clear(),
|
||||
Ok(Some((text, theme))) => {
|
||||
shown = text;
|
||||
failed.clear();
|
||||
cx.update(|cx| {
|
||||
log::info!("omarchy: the theme changed");
|
||||
cx.set_global(Omarchy(theme));
|
||||
apply(cx);
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let error = format!("{error:#}");
|
||||
if error != failed {
|
||||
log::error!("{error}; the last Omarchy theme stays");
|
||||
failed = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The file's theme when its text is not `shown`'s; none while unchanged.
|
||||
pub(super) fn check(path: &Path, shown: &str) -> anyhow::Result<Option<(String, TerminalTheme)>> {
|
||||
match read(path) {
|
||||
Ok((text, _)) if text == shown => Ok(None),
|
||||
// Gone mid-switch: the next check finds the new folder.
|
||||
Err(error) if missing(&error) => Ok(None),
|
||||
next => next.map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> anyhow::Result<(String, TerminalTheme)> {
|
||||
let text =
|
||||
fs::read_to_string(path).with_context(|| format!("omarchy: reading {}", path.display()))?;
|
||||
let theme = colors(&text).with_context(|| format!("omarchy: {}", path.display()))?;
|
||||
Ok((text, theme))
|
||||
}
|
||||
|
||||
fn missing(error: &anyhow::Error) -> bool {
|
||||
error
|
||||
.downcast_ref::<io::Error>()
|
||||
.is_some_and(|error| error.kind() == ErrorKind::NotFound)
|
||||
}
|
||||
|
||||
/// Omarchy writes no cursor-text; the background stands in, as Ghostty's default.
|
||||
pub(super) fn colors(text: &str) -> anyhow::Result<TerminalTheme> {
|
||||
let key = |line: &str| {
|
||||
line.split_once('=')
|
||||
.map_or("", |(key, _)| key.trim())
|
||||
.to_owned()
|
||||
};
|
||||
let mut lines: Vec<&str> = text
|
||||
.lines()
|
||||
.filter(|line| COLORS.contains(&key(line).as_str()))
|
||||
.collect();
|
||||
let background = lines
|
||||
.iter()
|
||||
.find(|line| key(line) == "background")
|
||||
.and_then(|line| line.split_once('='))
|
||||
.map(|(_, value)| format!("cursor-text = {}", value.trim()));
|
||||
if !lines.iter().any(|line| key(line) == "cursor-text") {
|
||||
lines.extend(background.as_deref());
|
||||
}
|
||||
parse(OMARCHY, "Omarchy", &lines.join("\n"))
|
||||
}
|
||||
+87
-6
@@ -86,6 +86,90 @@ fn a_missing_key_or_bad_color_names_it() {
|
||||
assert!(parse("x", "X", "palette = 16=#000000").is_err());
|
||||
}
|
||||
|
||||
/// Omarchy 4 renders Tokyo Night so; glass mode appends the last four lines.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn tokyo_night() -> String {
|
||||
[
|
||||
"background = #1a1b26",
|
||||
"foreground = #a9b1d6",
|
||||
"cursor-color = #c0caf5",
|
||||
"selection-background = #292e42",
|
||||
"selection-foreground = #c0caf5",
|
||||
"",
|
||||
"palette = 0=#1a1b26",
|
||||
"palette = 1=#f7768e",
|
||||
"palette = 2=#9ece6a",
|
||||
"palette = 3=#e0af68",
|
||||
"palette = 4=#7aa2f7",
|
||||
"palette = 5=#ad8ee6",
|
||||
"palette = 6=#449dab",
|
||||
"palette = 7=#a9b1d6",
|
||||
"palette = 8=#414868",
|
||||
"palette = 9=#ff7a93",
|
||||
"palette = 10=#b9f27c",
|
||||
"palette = 11=#ff9e64",
|
||||
"palette = 12=#7da6ff",
|
||||
"palette = 13=#bb9af7",
|
||||
"palette = 14=#0db9d7",
|
||||
"palette = 15=#c0caf5",
|
||||
"# omarchy-glass begin",
|
||||
"background-opacity = 0.65",
|
||||
"gtk-custom-css = /usr/share/omarchy/default/gtk/glass.css",
|
||||
"# omarchy-glass end",
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn omarchys_ghostty_conf_reads_without_its_glass() {
|
||||
let rendered = tokyo_night();
|
||||
let theme = omarchy::colors(&rendered).expect("Omarchy's file reads");
|
||||
assert_eq!(theme.id, OMARCHY);
|
||||
assert_eq!(theme.background, rgb(0x1a1b26));
|
||||
assert_eq!(theme.cursor, rgb(0xc0caf5));
|
||||
assert_eq!(theme.cursor_text, theme.background, "Ghostty's default");
|
||||
assert_eq!(theme.ansi[8], rgb(0x414868));
|
||||
let cut = rendered.replace("palette = 7=#a9b1d6\n", "");
|
||||
assert_eq!(
|
||||
omarchy::colors(&cut).expect_err("no 7").to_string(),
|
||||
"no palette 7"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn omarchys_file_is_worn_again_once_it_reads() {
|
||||
use std::{fs, os::unix::fs::PermissionsExt};
|
||||
let folder = std::env::temp_dir().join(format!("elyterminal-omarchy-{}", std::process::id()));
|
||||
if folder.exists() {
|
||||
fs::remove_dir_all(&folder).expect("a stale test folder goes");
|
||||
}
|
||||
fs::create_dir_all(&folder).expect("a test folder");
|
||||
let path = folder.join("ghostty.conf");
|
||||
let text = tokyo_night();
|
||||
fs::write(&path, &text).expect("a theme");
|
||||
assert!(omarchy::check(&path, &text).expect("reads").is_none());
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o000)).expect("000");
|
||||
// Root reads any mode.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
let error = omarchy::check(&path, "").expect_err("unreadable");
|
||||
assert!(
|
||||
format!("{error:#}").contains("Permission denied"),
|
||||
"{error:#}"
|
||||
);
|
||||
}
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("644");
|
||||
let (shown, theme) = omarchy::check(&path, "").expect("reads").expect("a theme");
|
||||
assert_eq!((shown, theme.background), (text, rgb(0x1a1b26)));
|
||||
fs::remove_dir_all(&folder).expect("the test folder goes");
|
||||
assert!(
|
||||
omarchy::check(&path, "")
|
||||
.expect("gone mid-switch")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dark_theme_wins_only_in_dark_mode() {
|
||||
let settings = Settings {
|
||||
@@ -94,21 +178,18 @@ fn the_dark_theme_wins_only_in_dark_mode() {
|
||||
..Settings::default()
|
||||
};
|
||||
assert_eq!(
|
||||
active(&settings, WindowAppearance::Light).id,
|
||||
chosen(&settings, WindowAppearance::Light),
|
||||
"catppuccin-latte"
|
||||
);
|
||||
assert_eq!(
|
||||
active(&settings, WindowAppearance::VibrantDark).id,
|
||||
chosen(&settings, WindowAppearance::VibrantDark),
|
||||
"catppuccin-mocha"
|
||||
);
|
||||
let single = Settings {
|
||||
dark_theme: None,
|
||||
..settings
|
||||
};
|
||||
assert_eq!(
|
||||
active(&single, WindowAppearance::Dark).id,
|
||||
"catppuccin-latte"
|
||||
);
|
||||
assert_eq!(chosen(&single, WindowAppearance::Dark), "catppuccin-latte");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+62
-58
@@ -1,15 +1,17 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton, SegmentedControl},
|
||||
lists::List,
|
||||
primitives::{FocusScope, IconName},
|
||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
theme::ControlSize,
|
||||
typography::Caption,
|
||||
};
|
||||
use gpui::{
|
||||
Action, App, AppContext, Context, Entity, EntityId, FocusHandle, Focusable, InteractiveElement,
|
||||
Action, App, AppContext, Context, Entity, FocusHandle, Focusable, InteractiveElement,
|
||||
IntoElement, KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement,
|
||||
StyleRefinement, Styled, Subscription, Window, actions, div, prelude::FluentBuilder,
|
||||
StyleRefinement, Styled, Subscription, Task, Window, actions, div, prelude::FluentBuilder,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -17,15 +19,20 @@ use crate::{
|
||||
files::Files,
|
||||
git::panel::{Acted, GitPanel},
|
||||
menus,
|
||||
sessions::{self, Session},
|
||||
sessions::{self, Session, orb},
|
||||
settings::{PanelTab, Settings, SettingsStore, StartIn, window::OpenSettings},
|
||||
term::{
|
||||
Events, Launch, Pty, TerminalEvent, TerminalView, config,
|
||||
Events, Launch, Pty, TerminalView, config,
|
||||
shell::{ShellIntegration, launch},
|
||||
spawn,
|
||||
},
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod widths;
|
||||
|
||||
use widths::{Dragged, Side};
|
||||
|
||||
actions!(
|
||||
elyterminal,
|
||||
[
|
||||
@@ -88,6 +95,10 @@ pub struct Workspace {
|
||||
git: Entity<GitPanel>,
|
||||
/// What the window shows of the desktop, and the blur behind it.
|
||||
background: Blur,
|
||||
/// Column widths a drag shows before the settings hold them.
|
||||
dragged: Dragged,
|
||||
/// Draws the window at each step of the status marks while one shown moves.
|
||||
ticking: Option<Task<()>>,
|
||||
_settings: Subscription,
|
||||
_acted: Subscription,
|
||||
}
|
||||
@@ -114,6 +125,8 @@ impl Workspace {
|
||||
files: cx.new(|_| Files::new(launch.cwd.clone())),
|
||||
git,
|
||||
background: Blur::new(window, SettingsStore::get(cx)),
|
||||
dragged: Dragged::default(),
|
||||
ticking: None,
|
||||
_settings: cx.observe_global_in::<SettingsStore>(window, |this, window, cx| {
|
||||
this.background.follow(window, SettingsStore::get(cx));
|
||||
cx.notify();
|
||||
@@ -146,10 +159,37 @@ impl Workspace {
|
||||
folder: launch.cwd,
|
||||
reports: launch.reports,
|
||||
bell: false,
|
||||
waiting: false,
|
||||
look: None,
|
||||
_events: events,
|
||||
});
|
||||
}
|
||||
|
||||
/// Notes each session's mark `now`, and draws the window at each step while one shown moves.
|
||||
fn tick(&mut self, now: Instant, cx: &mut Context<Self>) {
|
||||
let mut moving = false;
|
||||
for session in &mut self.sessions {
|
||||
moving |= session.note(now, cx);
|
||||
}
|
||||
let moving = moving && SettingsStore::get(cx).show_sessions;
|
||||
if moving == self.ticking.is_some() {
|
||||
return;
|
||||
}
|
||||
log::debug!("app: status marks {}", if moving { "move" } else { "rest" });
|
||||
self.ticking = moving.then(|| {
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(orb::wait(Instant::now()))
|
||||
.await;
|
||||
if this.update(cx, |_, cx| cx.notify()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// A session of the shell the settings name, in home or the shown session's folder.
|
||||
fn new_session(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
@@ -175,7 +215,7 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows session `index`, clears its bell, and gives its terminal the keys.
|
||||
/// Shows session `index`, clears its bell and its waiting mark, and gives its terminal the keys.
|
||||
pub fn select(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if index >= self.sessions.len() {
|
||||
return;
|
||||
@@ -183,10 +223,15 @@ impl Workspace {
|
||||
if index != self.active
|
||||
&& let Some(hidden) = self.sessions.get(self.active)
|
||||
{
|
||||
hidden.terminal.update(cx, |view, cx| view.let_go(cx));
|
||||
hidden.terminal.update(cx, |view, cx| {
|
||||
view.let_go(cx);
|
||||
view.set_front(false);
|
||||
});
|
||||
}
|
||||
let session = &mut self.sessions[index];
|
||||
session.bell = false;
|
||||
session.waiting = false;
|
||||
session.terminal.update(cx, |view, _| view.set_front(true));
|
||||
window.focus(&session.terminal.focus_handle(cx), cx);
|
||||
let folder = session.folder.clone();
|
||||
self.files.update(cx, |files, cx| files.show(&folder, cx));
|
||||
@@ -218,6 +263,11 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
/// Its sessions, in their rows' order.
|
||||
pub fn sessions(&self) -> &[Session] {
|
||||
&self.sessions
|
||||
}
|
||||
|
||||
/// The shown session's title, or the app's name while its program names none.
|
||||
fn title(&self) -> &str {
|
||||
self.sessions
|
||||
@@ -235,51 +285,6 @@ impl Workspace {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_event(
|
||||
&mut self,
|
||||
id: EntityId,
|
||||
event: &TerminalEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(index) = self
|
||||
.sessions
|
||||
.iter()
|
||||
.position(|session| session.terminal.entity_id() == id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let session = &mut self.sessions[index];
|
||||
match event {
|
||||
TerminalEvent::Title(title) => session.title.clone_from(title),
|
||||
TerminalEvent::Folder(folder) => {
|
||||
session.folder.clone_from(folder);
|
||||
session.reports = true;
|
||||
if index == self.active {
|
||||
self.files.update(cx, |files, cx| files.show(folder, cx));
|
||||
self.git.update(cx, |git, cx| git.show(folder, cx));
|
||||
}
|
||||
}
|
||||
TerminalEvent::Bell if index != self.active => {
|
||||
if !session.bell {
|
||||
log::debug!("app: a bell in hidden session {}", index + 1);
|
||||
}
|
||||
session.bell = true;
|
||||
}
|
||||
TerminalEvent::Bell => {}
|
||||
TerminalEvent::Exited(status) => {
|
||||
let pid = session.terminal.read(cx).pid();
|
||||
log::info!("app: session {pid}'s program ended, {status}");
|
||||
self.close(index, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if index == self.active {
|
||||
self.retitle(window);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Shows or hides a side column, as the settings keep it.
|
||||
fn toggle(&mut self, flip: fn(&mut Settings), cx: &mut Context<Self>) {
|
||||
match SettingsStore::update(cx, flip) {
|
||||
@@ -338,8 +343,9 @@ fn column_toggle(
|
||||
impl Render for Workspace {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
self.background.fit(window);
|
||||
let now = Instant::now();
|
||||
self.tick(now, cx);
|
||||
let settings = SettingsStore::get(cx);
|
||||
let width = cx.theme().sidebar_width(false);
|
||||
let workspace = cx.entity();
|
||||
let tip =
|
||||
|shown: bool, what: &str| format!("{} {what}", if shown { "Hide" } else { "Show" });
|
||||
@@ -382,7 +388,7 @@ impl Render for Workspace {
|
||||
let home = dirs::home_dir();
|
||||
let rows = self.sessions.iter().enumerate().map(|(index, session)| {
|
||||
let active = index == self.active;
|
||||
sessions::row(&workspace, index, session, active, home.as_deref(), cx)
|
||||
sessions::row(&workspace, index, session, active, home.as_deref(), now, cx)
|
||||
});
|
||||
let list = List::new().children(rows);
|
||||
let new = IconButton::new("new-session", IconName::Plus)
|
||||
@@ -393,8 +399,7 @@ impl Render for Workspace {
|
||||
workspace.update(cx, |this, cx| this.new_session(window, cx))
|
||||
});
|
||||
shell = shell.column(
|
||||
ShellColumn::new("sessions")
|
||||
.width(width)
|
||||
self.side_column(Side::Sessions, cx)
|
||||
.leading(left)
|
||||
.action(new)
|
||||
.child(
|
||||
@@ -436,8 +441,7 @@ impl Render for Workspace {
|
||||
// Git's list draws every row, so terminal output must not redraw it.
|
||||
let fill = || StyleRefinement::default().size_full();
|
||||
shell = shell.column(terminal).column(
|
||||
ShellColumn::new("panel")
|
||||
.width(width)
|
||||
self.side_column(Side::Panel, cx)
|
||||
.action(end)
|
||||
// Below the title bar, clear of Windows' caption buttons.
|
||||
.child(div().flex().justify_center().px_2().pt_2().child(switch))
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use gpui::{Context, EntityId, Window};
|
||||
|
||||
use super::Workspace;
|
||||
use crate::term::{State, TerminalEvent};
|
||||
|
||||
impl Workspace {
|
||||
/// What session `id`'s terminal reported.
|
||||
pub(super) fn session_event(
|
||||
&mut self,
|
||||
id: EntityId,
|
||||
event: &TerminalEvent,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(index) = self
|
||||
.sessions
|
||||
.iter()
|
||||
.position(|session| session.terminal.entity_id() == id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let session = &mut self.sessions[index];
|
||||
match event {
|
||||
TerminalEvent::Title(title) => session.title.clone_from(title),
|
||||
TerminalEvent::Folder(folder) => {
|
||||
session.folder.clone_from(folder);
|
||||
session.reports = true;
|
||||
if index == self.active {
|
||||
self.files.update(cx, |files, cx| files.show(folder, cx));
|
||||
self.git.update(cx, |git, cx| git.show(folder, cx));
|
||||
}
|
||||
}
|
||||
TerminalEvent::Bell if index != self.active => {
|
||||
if !session.bell {
|
||||
log::debug!("app: a bell in hidden session {}", index + 1);
|
||||
}
|
||||
session.bell = true;
|
||||
}
|
||||
TerminalEvent::Bell => {}
|
||||
// A hidden session that starts waiting marks its row until it is shown.
|
||||
TerminalEvent::Shows(Some(State::Blocked)) if index != self.active => {
|
||||
log::debug!("app: hidden session {} waits", index + 1);
|
||||
session.waiting = true;
|
||||
}
|
||||
TerminalEvent::Shows(_) => {}
|
||||
TerminalEvent::Exited(status) => {
|
||||
let pid = session.terminal.read(cx).pid();
|
||||
log::info!("app: session {pid}'s program ended, {status}");
|
||||
self.close(index, window, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if index == self.active {
|
||||
self.retitle(window);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A press on notification `tag`: its session hears of it and shows, unless it asked not to; false when no session here posted it.
|
||||
pub fn pressed(&mut self, tag: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
|
||||
let found = self
|
||||
.sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, session)| {
|
||||
let focus = session.terminal.update(cx, |view, _| view.pressed(tag))?;
|
||||
Some((index, focus))
|
||||
});
|
||||
let Some((index, focus)) = found else {
|
||||
return false;
|
||||
};
|
||||
if focus {
|
||||
cx.activate(true);
|
||||
window.activate_window();
|
||||
self.select(index, window, cx);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_gpui_component::shell::ShellColumn;
|
||||
use gpui::{Context, Pixels, Task, px};
|
||||
|
||||
use super::Workspace;
|
||||
use crate::settings::{COLUMN, SettingsStore};
|
||||
|
||||
/// How long a dragged border rests before its width is saved.
|
||||
const SETTLE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// A side column, whose border toward the terminal drags its width.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum Side {
|
||||
Sessions,
|
||||
Panel,
|
||||
}
|
||||
|
||||
/// Widths a drag shows that the settings do not hold yet.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Dragged {
|
||||
sessions: Option<f32>,
|
||||
panel: Option<f32>,
|
||||
saving: Option<Task<()>>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Column `side`, as wide as it was dragged or saved.
|
||||
pub(super) fn side_column(&self, side: Side, cx: &Context<Self>) -> ShellColumn {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let (id, dragged, saved) = match side {
|
||||
Side::Sessions => ("sessions", self.dragged.sessions, settings.sessions_width),
|
||||
Side::Panel => ("panel", self.dragged.panel, settings.panel_width),
|
||||
};
|
||||
let workspace = cx.entity();
|
||||
ShellColumn::new(id).resizable(
|
||||
px(dragged.unwrap_or(saved)),
|
||||
px(*COLUMN.start()),
|
||||
px(*COLUMN.end()),
|
||||
move |width, _, cx| workspace.update(cx, |this, cx| this.drag(side, width, cx)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows `width` at once and saves it once the drag rests.
|
||||
fn drag(&mut self, side: Side, width: Pixels, cx: &mut Context<Self>) {
|
||||
let width = f32::from(width).round();
|
||||
match side {
|
||||
Side::Sessions => self.dragged.sessions = Some(width),
|
||||
Side::Panel => self.dragged.panel = Some(width),
|
||||
}
|
||||
self.dragged.saving = Some(cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(SETTLE).await;
|
||||
if let Err(error) = this.update(cx, |this, cx| this.save(cx)) {
|
||||
log::error!("app: the columns' widths were not saved: {error:#}");
|
||||
}
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn save(&mut self, cx: &mut Context<Self>) {
|
||||
let (sessions, panel) = (self.dragged.sessions.take(), self.dragged.panel.take());
|
||||
let saved = SettingsStore::update(cx, |settings| {
|
||||
settings.sessions_width = sessions.unwrap_or(settings.sessions_width);
|
||||
settings.panel_width = panel.unwrap_or(settings.panel_width);
|
||||
});
|
||||
match saved {
|
||||
Ok(()) => {
|
||||
let settings = SettingsStore::get(cx);
|
||||
log::info!(
|
||||
"app: the sessions column is {} px wide, the right column {} px",
|
||||
settings.sessions_width,
|
||||
settings.panel_width
|
||||
);
|
||||
}
|
||||
// The columns go back to the saved widths.
|
||||
Err(error) => log::error!("app: the columns' widths are not kept: {error:#}"),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
+41
-19
@@ -2,15 +2,15 @@
|
||||
|
||||
`PRD.md` line by line, as of T23. Status: **Pass** when a test, a run or a review shows it; **Partial** when part of it was shown; **Open** when nothing has shown it yet. Reviews are in `tasks/reviews.md`; tests are named by module (`cargo test -- MODULE`).
|
||||
|
||||
What no check reached: no one has looked at the window on Windows, which CI builds and tests; no real KWin or Wayland desktop drew the blur. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
||||
What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyprland, on the owner's Omarchy, has drawn it. On 2026-10-15 macOS gave `shot` the window's ground alone, for old and new builds alike, while the terminal it ran under lacked Screen Recording (`CGPreflightScreenCaptureAccess` false); that day's macOS checks read the app's log.
|
||||
|
||||
## 1. Product
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| One window: sessions left, terminal middle, Git or Files right | T11 | T11 review; a real X11 window shows the terminal and the right column | Pass |
|
||||
| Every setting has a control in the settings window | T16–T18 | 17 of the 20 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, which §6 keeps in the file | Pass |
|
||||
| Silent: no sound, bell, notification, update check, telemetry, tip or onboarding | T10, T12 | a bell is logged and marks a hidden row; the code holds no network call or notification | Pass |
|
||||
| Every setting has a control in the settings window | T16–T18, T30, T33 | 17 of the 24 fields have a row; `show_sessions`, `show_panel` and `panel` are the main window's toggles and switch, `sessions_width` and `panel_width` its columns' borders, and `window_width` and `window_height` its size, which §6 keeps in the file | Pass |
|
||||
| Silent: no sound, bell, notification but a session's, update check, telemetry, tip or onboarding | T10, T12, T40 | a bell is logged and marks a hidden row; the code holds no network call, and its one notification is a session's, through gpui's, which sets no sound on macOS and a silent toast on Windows; on Linux the notification daemon decides | Pass |
|
||||
| Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass |
|
||||
| Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass |
|
||||
| The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial |
|
||||
@@ -24,7 +24,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
| `alacritty_terminal` 0.26 | T04 | `=0.26.0` | Pass |
|
||||
| Logs through `log` to stderr, `RUST_LOG` sets the level, no log setting | T01 | `src/main.rs`; no such field in `Settings` | Pass |
|
||||
| CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test | T01, T23 | `.github/workflows/ci.yml`; run 926 on `65bb4d3`, see the CI notes | Pass |
|
||||
| Private repo; no credential in any file or commit | T01 | T22's review scanned the tree and its 398 reachable objects: 0 hits; T23's scans for the Gitea token and the runners' SSH details: 0 hits | Pass |
|
||||
| The repo, private until v0.1.1 and public since; no credential in any file or commit | T01, T26 | T22's review scanned the tree and its 398 reachable objects: 0 hits; T23's scans for the Gitea token and the runners' SSH details: 0 hits; before the owner made it public, a scan of all 91 commits' contents and messages found no token, SSH password, gateway, port, SSH user or private key | Pass |
|
||||
|
||||
## 3. Settings model and storage
|
||||
|
||||
@@ -43,22 +43,32 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
| Background, foreground, cursor, cursor text, selection, 16 ANSI from upstream | T03 | `themes::tests` | Pass |
|
||||
| A theme maps onto Ely's `Palette` through `Theme::set_palette` | T03, T19 | `the_chrome_wears_the_theme` | Pass |
|
||||
| One theme, or a second for dark mode | T17 | T17 review | Pass |
|
||||
| On Omarchy 4, an Omarchy theme wears the current theme and follows each switch; the default there; another pick stops following | T27 | `omarchys_ghostty_conf_reads_without_its_glass`, `omarchys_file_is_worn_again_once_it_reads`; on X11, as a user, with Omarchy v4.0.4's own renderer staging and swapping the folder as `omarchy-theme-set` does: with no theme saved the window wore Omarchy's, and through all 22 of its themes the terminal's ground matched each background within 1.5 s; Omarchy's glass lines changed nothing; a file without palette 7 was logged and the last theme stayed; a file swapped in unreadable was worn once made readable, its stamp unchanged; a folder unreadable for 2 s kept the theme; each failure was logged once while it lasted; a picked Catppuccin Mocha stayed through a switch, and with it worn a bad Omarchy file was logged and the start went on; with Omarchy's folder unreadable the start stopped, a theme saved or not, and with settings wearing it and no Omarchy too, each naming the file and the error; the first release build, on the owner's Omarchy 4.0.4 under Hyprland 0.56.2, wore the live theme, sepia-night, which is none of Omarchy's 22, and the release build from `aae7fba`, under Xvfb on Debian 13 with that theme's folder, painted its background | Pass |
|
||||
|
||||
## 5. Terminal engine
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| PTY through alacritty's event loop; OSC 7 scanned on the reader | T04 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| PTY through alacritty's event loop; OSC 7 and OSC 133 scanned on the reader | T04, T34 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass |
|
||||
| Prompt marks, OSC 133, from the shell integration and any program; each session keeps its last command's status | T34 | `term::marks` tests: each letter and option as Ghostty reads them; `term::tap` and `term::pty` tests: marks split across reads reach the session in order; `term::shell` tests: zsh, bash, fish and PowerShell send C, D;1, A and B around a failing command, and the user's prompt still shows the failure, a zsh theme's that sets PS1 in its own hook too, whose first prompt is marked before any command, and `$pipestatus` stays; bash's lone subshell ends with D;7, `$_` stays, a command runs under `extdebug` after a failure, a key binding marks nothing, the user's DEBUG trap runs, bash 3.2 sends no C, and bash says at start why it marks nothing while `promptvars` is off; PowerShell reads 1 for a cmdlet's failure under `-ErrorAction Ignore` after a success, a native code twice, a native failure's code under `$PSNativeCommandUseErrorActionPreference`, on a line that mixes a program and a cmdlet the cmdlet's 1, and through a call by variable the program's code; on macOS fish 4.9 marks itself and bash 3.2 marks no C, in the Linux container fish 4.0 gets B and bash 5.2 marks C through PS0, CI's Ubuntu runs fish 3.7, which the script marks whole; by hand in a pty, PowerShell under strict mode, and a cmdlet's failure after a native one's reads 1 | Pass |
|
||||
| Jump to the prompt before or after the view's top, as Ghostty jumps | T35 | `term::prompts` tests: a mark becomes its prompt's numbered link, read as the session reads marks, whole when split across reads at every byte, and other sequences, `133;AB` among them, pass as they came; a prompt's cells carry its link through SGR 0 and a resize, its input none; a program's own link and the prompt's keep each other, a program's link inside a prompt carries the prompt's number, a prompt inside a link left open too, a target of 264 bytes in either order; controls after an OSC's closing ESC keep their line; a long escape of controls keeps the pty reading; a prompt over rows starts at its first, blank rows within it too; up and down land as Ghostty's `scrollPrompt`; `term::links`: a prompt's link is no target; `term::pty`: through the event loop the prompt's cell alone carries it; in the app on macOS, Cmd-Up once and twice put PROMPT-3 and PROMPT-2 at the top, and Cmd-Shift-Down and Cmd-Down came back to the screen's bottom; the review's fixtures of a prompt that starts with a link and of a prompt with a blank row jumped to PROMPT-3-FIRST, and its fixture of `133;AB` marks found no prompt; the second review's fixtures of a prompt all in a link and of a linked first row jumped to PROMPT-3-FIRST. The keys off macOS are bound, not run | Pass |
|
||||
| Program status, OSC 7501 revision 0.4: reports checked as the spec checks them, records per terminal, a new prompt and a full reset, the support query | T36 | `term::status` tests: the spec's examples read as it means them; a malformed pair is skipped, the last of a key wins, unknown keys pass; a sequence over any limit, with bad base64, UTF-8 or a control character, or without a known state drops whole, a clear too, and an earlier pair of a key counts though the last wins; the sequence's length counts the controls its body skips; kind and progress count only with their states; records replace whole, clear with those under them, and past 256 the one updated longest ago goes; a new prompt keeps done and error alone. `term::tap`: a full reset found between bodies, and through DEL and high bytes in its escape. `term::pty`: a report, a query and a full reset reach the session in order through the event loop. In the app on macOS, a program that sent the query and then DA1 read the answer with every state and kind, then DA1's | Pass |
|
||||
| Progress, OSC 9;4, as the root record until an OSC 7501 report; notifications, OSC 9, 777 and 99, read | T37 | `term::notify` tests: OSC 9 reads as Ghostty reads it, ConEmu's commands no notifications, 9;4's progress clamped; OSC 777's notify; OSC 99 gathers chunks by id, base64 split before encoding or after and a character across chunks among them, keeps the newlines base64 carries, reads options, close, query and alive, and drops what breaks its rules with what it gathered; its answers name the asker's id. `term::tap`: an OSC 99 body keeps its metadata and a full payload. `term::status`: 9;4 maps to the root record until a report, and again after a full reset. `term::pty`: OSC 9, 777, chunked 99 and 9;4 reach the session in order, and a full encoded chunk with the lines base64 carries reaches it as one notification. In the app on macOS, a program read OSC 99's query and alive answers before DA1's, and four notifications reached the workspace's log | Pass |
|
||||
| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS, Linux and Windows | T29, T32 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel; on Windows, `term::herdr` tests on the CI runner: the folder under `APPDATA` and `USERPROFILE`, the `herdr.exe` nearest under the shell, this test's name, arguments and variables read through the live process list and `sysinfo`, a round trip over a named pipe; no real herdr has run on Windows yet | Partial |
|
||||
| Inside herdr, herdr's agent states are the session's records | T38 | `term::herdr` tests: each agent in a state herdr knows is its pane's record, its app, title and message from herdr's fields, unknown and new states passed by, and a snapshot without agents lists none. `term::status`: herdr's agents replace their own records alone, a program's `herdr/build` kept through an empty snapshot, a full one and herdr's leaving, since a program's id ends at a colon; the same agents change nothing, and the next snapshot puts them back after a full reset | Pass |
|
||||
| A row shows its session's status, hover each record; a hidden session that starts waiting marks its row | T39 | `term::status` tests: blocked, then error, working, done and idle show, the record reported last in its state speaks; an app comes from the nearest ancestor that names one; typing sees done and error, herdr's aside; each change of the state a row shows is noted, a wait after work in one output among them. `sessions` tests: hover lists each record's state, app, title and message, ten at most, then a failed command's status; a program's text shows on one line without direction controls or invisible formatting, joiners kept, cut at 120 characters. In the app on macOS, `shot` showed each mark: the ring at 40%, the still loader, a shield over three records whose hover listed each, the root's app inherited, a check, a cross, a hollow circle, an alert after `133;D;1`, a hidden session's question mark with its title strong, and a done record gone after a typed key; after review, a hidden session that waited, worked and waited again in one output set its title strong, an open hover followed a record replaced under it and work from 20% to 80%, ten long records scrolled to the last within the window, and a selected row at 0% in Ely Dark showed its loader; after round 2, herdr's records replaced in the same state showed at once in an open hover, and a hidden session that waited and worked again in one output set its title strong | Pass |
|
||||
| A row's mark moves: work ripples from the centre, progress fills the outer ring clockwise from the top, waiting stays lit and dips, a settled state draws its shape and holds; the window draws at the marks' steps only while one moves | T42 | `sessions::orb` tests: the ripple peaks at the centre, then every edge, then every corner, once every 15 steps; at 50% the first four ring dots are lit, the next pulses and the rest stay dim, and at 100% the centre pulses; waiting dips on the 1st, 2nd, 5th and 6th of 20 steps, every dot alike; a done check draws from its left end and holds after 5 steps, idle is a ring; work and waiting never stop. `sessions` test: the mark takes the most pressing look, a failed command as an error. In the app on macOS, at 70% glass in Ely Dark, the recorder's stills showed each look in one column: the ripple, 40% filled, waiting lit, a check, two crosses and a ring; two stills 0.4 s apart showed the ripple at two steps | Pass |
|
||||
| System notifications on macOS, Windows and Linux: a session that starts waiting, finishes or fails, and OSC 9, 777 and 99, while the window is inactive or the session hidden, one a session every 3 s; a press shows the window and session | T40 | `term::reports` tests: a session posts once in 3 s, its own under one tag; a program's notifications go by their id and live until pressed, closed or expired, an expiry taking only its own post and none a later post holds; a session keeps its last 64; the text shows as text, and as markup's text on Linux; a session posts unless it is the one shown in an active window; waiting, done and error post, work and rest do not; a tag names the session that posted it, by itself, an id or a count. `term::titles`: OSC 0 and 2 set the title as vte splits it, 15 parts past the number at most, and as alacritty joins it; CSI 22 t and 23 t save it and bring it back; a full reset empties both. `term::tap`: the scanner finds CSI 22 t and 23 t as vte reads them; a title's body holds 65536 bytes, another's 4096. `term::pty`: titles, notifications and full resets reach the session in the output's order, one write and a write a sequence alike, a title of 5000 bytes and one of 17 parts among them. `term::notify`: the query, on Linux without close and `w`, alive, untracked and activation answers. In the app on macOS, outside a bundle, where gpui posts nothing and the log names each post: an OSC 9 posted and an OSC 777 within 3 s was dropped; an OSC 99 with `c=1` and `w=800` posted, its program heard `p=close;untracked`, alive listed it, and once it expired alive listed none; a closed one left alive empty; the query's answer came back; a command's end without its C posted nothing, and a failed one after its C posted; a notice after a title set, or after a full reset, in the same write named the session as split writes do, as did one after a full reset and 2 KB to 32 KB more of output, and a title of 17 parts or of 4100 bytes named the notice as it named the row, one write or two; a notice, alive and DA1 in one write came back as alive naming it, then DA1's answer; a notice set to expire whose session ended expired after it; a hidden session that waited and worked again in one output posted its wait; and a notice sent while the window was active and the session shown posted nothing, the next, once the window lost focus, posted. No system showed one yet: on macOS gpui posts only from the app bundle, which asks the owner's leave first, and Windows and Linux did not run it; a press needs the system's notification center, so the owner posts and presses one on each | Partial |
|
||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
|
||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
|
||||
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||
| Cursor shapes, blink by setting and DECSCUSR, hollow unfocused | T06 | T06 review | Pass |
|
||||
| xterm keys, cursor mode, Option as Alt, kitty protocol, keypad | T08 | `term::keys` tests | Pass |
|
||||
| xterm keys, cursor mode, Option as Alt, kitty protocol, keypad; a shifted symbol as its own key with Shift on macOS and Windows; win32-input-mode on Windows | T08, T44 | `term::keys` tests, `kitty_reports_the_key_shift_turned_into_a_symbol` red at `CSI 123;13u` before the fix; on macOS herdr 0.9.5, its `[keys]` `previous_workspace = "cmd+shift+ctrl+["` and `next_workspace = "cmd+shift+ctrl+]"`, run by `shot` in a fresh HOME: before, ⌘⇧⌃] sent `CSI 125;13u` and herdr stayed on its workspace; after, `CSI 93:125;14u` moved it to the next and ⌘⇧⌃[, `CSI 91:123;14u`, back; `term::win32` and `term::tap` tests on Windows | Pass |
|
||||
| IME: marked text at the cursor, commits to the program | T08 | T08 review | Pass |
|
||||
| Selection by character, word, line; Shift extends; mouse reporting; wheel | T09 | `term::mouse`, `term::tap` tests | Pass |
|
||||
| Copy, paste, bracketed paste, copy on select, OSC 52 | T09 | T09 review | Pass |
|
||||
| OSC 8 and plain links on Cmd-press, Ctrl-press off macOS | T09 | `term::links` tests | Pass |
|
||||
| OSC 8 and plain links on Cmd-press, Ctrl-press off macOS; inside herdr, herdr opens them whole | T09, T45 | `term::links` tests; on macOS with real herdr 0.9.5 and a stand-in `open` on its PATH, Cmd-press on a URL herdr wraps, on its wrapped tail, on an OSC 8 link and on a URL over three rows opened the whole URL and the OSC 8 target, where before the app opened `.../ELYTermin` and nothing; Cmd-hover underlined the long URL across its rows; outside herdr the app opened the URL itself; an OSC 8 link whose label holds `file:///etc/hosts` and whose target is a web page opened the page through herdr, which keeps the target from the terminal while it takes the mouse; Shift-Cmd-press on a plain `file:///etc/hosts` inside herdr the app opened itself, caught by an `NSWorkspace` stand-in | Pass |
|
||||
| Focus reporting, title, scrollback limit, find | T10 | `term::find` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| A bell makes no sound; the row marks it | T10, T12 | T12 review | Pass |
|
||||
|
||||
@@ -68,10 +78,13 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| `ColumnShell` with three columns; blur when the setting is on | T11, T19 | `shot` names a `BlurredView` under the window | Pass |
|
||||
| Columns toggle and keep their state | T11, T23 | a click on each title bar button hid its column and saved it, and a restart opened the window with both hidden (147 columns against 87); Cmd-B with the app active; Ctrl-Shift-B on a real X11 window | Pass |
|
||||
| The title shows the session's title, nothing else | T11 | log: "the title is", per session shown | Pass |
|
||||
| The window opens at the size it was left at; maximized or full screen, the size before stays; a kept size past the screen shrinks to it | T30 | `a_value_out_of_range_names_its_field`; on X11 under xfwm4: the first start opened at 1200 by 760 and wrote nothing; a resize to 900 by 600 was kept with one write; maximized by xfwm4's Alt+F10 the window spanned the 1400 by 900 screen while the file kept 900 by 600, and restored it was 900 by 600 again; maximized, minimized and restored, the file kept 900 by 600; resized to 950 by 650 and minimized at once, the file kept 900 by 600 while minimized and 950 by 650 once restored; the next start opened at 950 by 650 and wrote nothing; `WM_NORMAL_HINTS` names a 100 by 100 minimum, and a resize to 900 by 50 held at 900 by 100, kept so; a kept 3000 by 2000 opened at 1400 by 900, logged; on macOS, through gpui's own window: a kept 900 by 600 opened at 900 by 600, where a centered edge on a half point had grown it to 601, and a move wrote nothing; a resize to 923 by 617, covered by an opaque window within 150 ms and uncovered, was kept once shown, and the next start opened at 923 by 617; zoomed, minimized, restored, full screen and back, the file kept 923 by 617; a 100 by 100 window was kept; a kept 5000 by 4000 opened at the visible 1920 by 1113, logged; on Windows the owner dragged v0.1.2's window to a new size, closed it, and it opened again at that size; a mouse drag on macOS not yet shown | Pass |
|
||||
| The sessions and right columns resize by a drag on their border toward the terminal, 160 to 960 pixels, kept in settings | T33 | Ely `5c2822c` and `005faef`: `shell::tests::border`: a border toward the filling column drags its width, before it by its right and after it by its left; a drag stops at each limit; a drag in a header moves no window in Windows', macOS' and Linux's style (the test window panics on a move); a drag sets off from the width drawn, which holds Windows' caption buttons inside the window; a short shell squeezes both columns, and a drag moves each border the pointer's way from the width kept, a drag with no sideways move changing none (Ely `0b4848b`): on macOS 900 and 900 kept in a 1200 window drew 572 each, and a drag of 20 from the left border saved 920 and moved it right, where review round 1 saw it jump to 435; on X11 900 and 900 in a 1380 window, a drag of 40 saved 940 and moved both borders right, and a drag with no sideways move kept them; a drag out and back to where it set off gives the width it had (Ely `da3fdca`): on macOS each border dragged 50 out and back kept 240 and 240 and came back to its pixel, where review round 2 saw 290 kept, and on X11 both settled at 240 with nothing written; a resizing column with no border toward a filling one fails; `a_value_out_of_range_names_its_field` (`panel_width` 1000); on macOS under `shot`, the sessions border dragged in the header and the right border in the body moved 50 points each, saved 290 and 290 once, and the next start opened at them; on X11 under xfwm4: a drag in the body set 300, one on the right border in the header set 300 and left the window at 0,0, drags stopped at 960 and 160, the next start opened at 160, and both borders paint the theme's border color, #37384A, as before; the line had painted half a pixel on macOS and none on X11 until Ely `005faef`; no real drag on Windows yet | Partial |
|
||||
| The title shows the session's title, nothing else | T11, T40 | log: "the title is", per session shown; since T40 the title comes from the tap in the output's order, and the shell tests read each shell's title from it | Pass |
|
||||
| The pointer over the terminal is the arrow | T26 | the terminal sets no pointer, so gpui's arrow shows; on X11 with the pointer over the grid, gpui asked for no I-beam ("text"), which it had asked for, and failed to load under Xvfb, before | Pass |
|
||||
| The title bar is transparent | T11, T19 | T19 review | Pass |
|
||||
| Every title bar control takes its press, Ely's window buttons on Windows and Linux too | T26 | Ely `cdc5ce8`: `window_controls_occlude_what_lies_under` (each style; before, the box under Windows' caption buttons took the press), `a_wobbling_press_on_a_slot_moves_no_window` (before, the window moved), `a_wobbling_press_on_a_column_title_moves_the_window`; on the owner's PC, window messages asked the app for Windows' hit test along the title bar: v0.1.0 answered caption from end to end, T26's build answers client over the toggles and +, caption over the titles, min, max and close over Ely's buttons, and a press on close closed the app; the owner has not yet clicked it | Partial |
|
||||
| Every title bar control takes its press, Ely's window buttons on Windows and Linux too | T26 | Ely `cdc5ce8`: `window_controls_occlude_what_lies_under` (each style; before, the box under Windows' caption buttons took the press), `a_wobbling_press_on_a_slot_moves_no_window` (before, the window moved), `a_wobbling_press_on_a_column_title_moves_the_window`; on the owner's PC, window messages asked the app for Windows' hit test along the title bar: v0.1.0 answered caption from end to end, T26's build answers client over the toggles and +, caption over the titles, min, max and close over Ely's buttons, and a press on close closed the app; the owner, given v0.1.1's build and these controls to click, reported it works on Windows | Pass |
|
||||
| Ely peeks over the window's top edge from behind it; her face follows the sessions; a press pets her | T46, T48, T49 | `pet::look` tests: the most pressing mark sets her mood; she moves in every mood, weeks into one too; she blinks on her own, twice at times, her eyes opening as an act begins; calm glances, then dozes with zs; a finish cheers her with a hop; work runs her eyes along the lines and taps her fingers; a wait widens her eyes and hops her twice a round; an error shakes her and runs a drop down; a press winks with a rising heart; she rises from behind the edge; a change of act eases her from her last pose, her brows easing and the marks she leaves fading; `pet` tests: a mood that changes inside a wink, an old finish cleared mid-glance and a second press mid-hop each ease her from where she was, where round 1 of T49's review found her drop 12 points; `pet::rig` tests: every layer of the icon's paths parses and stays in its box, and every mark has its layer; on macOS `shot`'s snaps, 40 in 10 s while calm, caught her breathing, blinking and glancing, and two a second apart in each mood caught her reading along, rocking her mark, fretting with her drop, cheering, and easing back to calm; `pet::x11` test under Xvfb: her window stands on the pane, just above the window manager's frame, and unmaps and maps with the main window; on macOS `shot` found her the main window's child, 112 by 124 points on its top edge 140 in, a capture of the screen showed her over the edge, and OSC 7501 reports gave her surprise, worry, eyes down and a cheer; a press on her window left the main window key and every key typed before and after it reached the program; switched off and on during an error she came back worried; on a 100-point window she stood at its left edge, and moved, resized and minimized she followed it; on Windows the `pet::windows` test, red before T48, finds the framed popup Windows makes, 1898 by 1481, bare in her own box, 168 by 185 at 150%, on the pane's top edge, and no size until her window takes the place posted to it; `pet::windows` tests, red before T48's second round: with the main window at 1, 1.5 or 2 and hers at 1, 1.25 or 1.5, her height is her own and her bottom the pane's top, where it fell 62 pixels into the terminal, and at a new DPI gpui takes the rect Windows suggests, then her box comes back on her corner, or Windows' rect stands where that box would lie on a display of another DPI; two displays side by side at 144 and 96 DPI, simulated, settle her after one change, where a corner kept always, red before T48's third round, changed her DPI back and forth; and two places piled up before either ran drain on the last corner in her DPI's size, where a keep whose DPI had passed, red before T48's fourth change, set it back and forth, and where a place sized when it was posted left her 112 by 123 at 144 DPI, red before its fifth; on a Windows 11 VM at 150%, with the main window 49 pixels from the screen's top, 0.1.4 opened her 2326 by 1654 with a caption, drawn at its bottom over the terminal, and a press inside the terminal went to her; T48's build opened her 168 by 185 without one, on the edge, the press went to the terminal, one beside her box to the program behind, and one on her to her; with the window mid-screen she showed whole on its edge; Wayland's popup is gpui's own | Pass |
|
||||
|
||||
## 7. Sessions panel
|
||||
|
||||
@@ -107,10 +120,12 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| Cmd-, opens it; categories left, rows right, each scrolling on its own; changes apply at once and save | T16, T26 | T16 review; on X11, eight wheel notches over the Terminal page carried it down to its Edit and View shortcuts while the category column and both headers stayed | Pass |
|
||||
| On Windows and Linux, a gear in the title bar opens it too | T26 | on X11 under Xvfb with xfwm4, xdotool's click on the gear, just inside the right toggle, opened the 900×640 Settings window ("settings: the window opens"), after a click on + opened a session; sent through `App::dispatch_action`, the same click had opened nothing; the owner has not yet clicked it on Windows | Partial |
|
||||
| On Windows and Linux, a gear in the title bar opens it too | T26 | on X11 under Xvfb with xfwm4, xdotool's click on the gear, just inside the right toggle, opened the 900×640 Settings window ("settings: the window opens"), after a click on + opened a session; sent through `App::dispatch_action`, the same click had opened nothing; the owner, given v0.1.1's build and the gear to click, reported it works on Windows | Pass |
|
||||
| Terminal: Shell, Start in | T16, T26 | `settings::tests`; the General page is gone, and a capture on X11 shows its two rows leading Terminal | Pass |
|
||||
| Appearance: theme grids, dark theme, font, size, line height, ligatures, cursor and preview, opacity, blur, padding | T17 | T17 review | Pass |
|
||||
| On Omarchy, the Omarchy theme first in the grid | T27 | on X11 with Omarchy's theme folder, the Omarchy card leads the grid, chosen, over a preview in its colors | Pass |
|
||||
| Terminal: scrollback, Option as Alt (macOS), copy on select | T18 | T18 review | Pass |
|
||||
| Terminal: Ely, a switch | T46 | on macOS the switch closed her window and saved `"ely": false`, a start with it showed no Ely, and the switch brought her back | Pass |
|
||||
| Terminal: every shortcut, with a search | T26 | read from the keymap, the bindings that apply in the main window's terminal; on X11 the list showed this platform's keys (Ctrl+Shift+T, Alt+1 and on), and typing "paste" left Edit's Paste, Ctrl+Shift+V | Pass |
|
||||
|
||||
## 11. Background blur
|
||||
@@ -118,8 +133,8 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| macOS: `Blurred` (NSVisualEffectView) | T19, T23 | `shot` reads `BlurredView, blur 30` off the view's backdrop layer; the owner saw the desktop blur | Pass |
|
||||
| Windows: `Blurred` (acrylic) | T19 | the same gpui call; CI builds and tests on Windows, but no one has seen the window | Partial |
|
||||
| Wayland: `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur` | T20 | `blur::wayland` tests on a `wayland-server` compositor; no real compositor | Partial |
|
||||
| Windows: `Blurred` (acrylic) | T19 | the same gpui call; on 2026-10-15 the owner saw the window blur on Windows | Pass |
|
||||
| Wayland: `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur` | T20, T28 | `blur::wayland` tests on a `wayland-server` compositor; on the owner's Omarchy 4.0.4, Hyprland 0.56.2 took `ext_background_effect_v1` from a T27 release build: the log named it and the pane's region, and the wallpaper behind the window blurred while Hyprland's blur was on and showed sharp while it was off; Plasma and GNOME not run | Partial |
|
||||
| X11: `_KDE_NET_WM_BLUR_BEHIND_REGION`, removed when blur turns off | T21 | `blur::x11` test on Xvfb; the property on a real xfwm4 window; KWin not run | Partial |
|
||||
| Neither protocol: transparency without blur; the log names the path | T20, T21 | the log lines in `src/blur` | Pass |
|
||||
|
||||
@@ -129,7 +144,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| The macOS menu bar | T22 | `shot` lists every item with its key | Pass |
|
||||
| The same shortcuts off macOS, Ctrl-Shift where they collide | T22 | xdotool on a real X11 window: select all, size, toggle, find, quit | Pass |
|
||||
| On Windows Ctrl-V pastes too, as in Windows Terminal | T26 | the binding is Windows' alone; on a real X11 window, with a command on the clipboard through xclip, Ctrl-Shift-V pasted its 33 characters and the shell ran it, and Ctrl-V pasted nothing, as Linux keeps it for programs; the owner has not yet pressed Ctrl-V on Windows | Partial |
|
||||
| On Windows Ctrl-V pastes too, as in Windows Terminal | T26 | the binding is Windows' alone; on a real X11 window, with a command on the clipboard through xclip, Ctrl-Shift-V pasted its 33 characters and the shell ran it, and Ctrl-V pasted nothing, as Linux keeps it for programs; the owner, given v0.1.1's build and Ctrl-V to press, reported it works on Windows | Pass |
|
||||
|
||||
## 13. Acceptance
|
||||
|
||||
@@ -137,7 +152,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|
|
||||
| Each task ticked with a passing review | each task is ticked only after its review, as `tasks/reviews.md` records; T04 and T10 reached the three-round cap without a passing round, their last fixes standing on red-green tests | Partial |
|
||||
| `scripts/check.sh` passes | macOS: 135 tests, clippy, fmt, 500 lines; Linux through `scripts/linux.sh`: 138 tests, clippy, on aarch64 in Colima and on x86_64 in WSL's Docker | Pass |
|
||||
| CI green on macOS, Ubuntu and Windows | run 934 on `0075667`: all three jobs pass; see the CI notes below | Pass |
|
||||
| CI green on macOS, Ubuntu and Windows | run 955 on `43ea5b1`, v0.1.1's commit: all three jobs pass; see the CI notes below | Pass |
|
||||
|
||||
## 14. Icon and the macOS app
|
||||
|
||||
@@ -145,16 +160,16 @@ What no check reached: no one has looked at the window on Windows, which CI buil
|
||||
|---|---|---|---|
|
||||
| The icon: Ely, a girl with long pink hair, holding a terminal | T24 | `assets/icons`, drawn at 16 to 1024 px and legible at 32; the owner saw it | Pass |
|
||||
| macOS: the rounded square; Windows and Linux: the figure alone, flat | T24 | `elyterminal-macos.svg` on Apple's grid; `elyterminal.svg`, which the Linux tarball carries and the Windows `.ico` renders | Pass |
|
||||
| `scripts/mac-app.sh` builds the app, signed ad hoc, and its disk image | T24 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal` | Pass |
|
||||
| `scripts/mac-app.sh` builds the app, signed ad hoc, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31, T41, T43, T47 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; for v0.1.3 the Release workflow (run 1157) ran it again, and its image and app, version 0.1.3, check the same way, as does the image rebuilt for T43 (run 1181); for v0.1.4 the Release workflow (run 1216) ran it again, and its image and app, version 0.1.4, for macOS 11.0 or later, check the same way; without the key the script stops before building | Pass |
|
||||
|
||||
## 15. Release
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25 | `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||
| The Windows executable carries the icon | T25 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same | Pass |
|
||||
| Each build comes from its script | T25 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL | Pass |
|
||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41, T43, T47 | `v0.1.4` at `2d9180e`: the zip from its script on the owner's Windows VM, the tarball from its script on the owner's PC under Omarchy, the disk image from the Release workflow (run 1216), signed and notarized; published once CI run 1215 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, each binary holds Ely's log line, and the tag names `2d9180e`. `v0.1.3` rebuilt for T43, at `eb96109`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1181), signed and notarized, each in place of the first build of its name, once CI run 1180 on that commit passed on macOS, Ubuntu and Windows; the tag moved from `7569f00` to `eb96109`, and the release's target with it; the notes give the new SHA-256 and say the builds were replaced; each file downloaded back anonymously matches its SHA-256, each binary holds the moving marks' log line and none the old ring's element id, and the tag's source archive holds `src/sessions/orb.rs`. The first `v0.1.3` builds, at `7569f00`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1157), signed and notarized; published once CI run 1156 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag named `7569f00`. `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||
| The Windows executable carries the icon | T25, T31, T41, T47 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's, v0.1.3's and v0.1.4's executables the same | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41, T47 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it; v0.1.3's holds the same three files, its desktop entry names `com.zacharyzhang.elyterminal`, and its binary asks for glibc 2.35 at most; v0.1.4's the same | Pass |
|
||||
| Each build comes from its script | T25, T27, T31, T41, T43, T47 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's and v0.1.3's three the same way, and v0.1.3's again for T43, the disk image's on `macos-signing`; v0.1.4's zip on the owner's Windows VM, its tarball on the owner's PC under Omarchy, its disk image on `macos-signing` | Pass |
|
||||
|
||||
## CI notes
|
||||
|
||||
@@ -179,3 +194,10 @@ On 2026-10-15 the owner ran the app on macOS and found three faults, now fixed:
|
||||
- The desktop showed through sharp: on macOS 27 gpui's `Selection` material builds no blur; the window's view now takes `underWindowBackground`, radius 30.
|
||||
- The debug build lagged; the release build does not.
|
||||
- No button hid the side columns; each now has one at its end of the title bar, its state saved.
|
||||
|
||||
On 2026-10-16 the owner ran it on Omarchy 4.0.4 under Hyprland:
|
||||
|
||||
- No window blurred: Omarchy turns Hyprland's own blur off, and Hyprland then blurs nothing; T28 says how to turn it on.
|
||||
- The v0.1.1 Linux tarball built from `aae7fba`, installed by hand, wore the Omarchy theme.
|
||||
|
||||
On 2026-10-16 the owner ran v0.1.2 on Windows: dragged to a new size, closed and opened again, the window opened at that size.
|
||||
@@ -125,3 +125,128 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
||||
|
||||
- Round 1, 2b8ae5d and 0075667: FAIL. The v0.1.0 release was not yet on Gitea, so the task line's release stood open.
|
||||
- Round 2, 59e41ea: PASS. v0.1.0 at 0075667 carries the disk image, the Windows zip and the Linux tarball; the reviewer downloaded each and found its SHA-256, signature, icon, manifest and glibc as recorded. CI run 934 on 0075667 passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T26
|
||||
|
||||
- Round 1, 6f7d3a9 and 0f87676 with Ely cdc5ce8: FAIL. v0.1.1 was still a draft, so the task line's release stood open.
|
||||
- Round 2, c316d27, 90a4625, 7bfb462, 43ea5b1 and 194c7bb: PASS. The owner's later asks went in first: a gear for Settings on Windows and Linux, Ctrl-V paste on Windows, the arrow pointer, the settings window without General, with the shortcut list and with columns that scroll apart, and the release's disk image signed with Developer ID and notarized on the macos-signing runner. v0.1.1 at 43ea5b1 is published and the repository public; the reviewer downloaded each file anonymously, checked its hash, signature and notarization, and drove the app on X11 and macOS. CI run 955 on 43ea5b1 passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T27
|
||||
|
||||
- Round 1, 340c7a9 and d8ebade: FAIL. A failed read marked the file's stamp as seen, so a theme file made readable again was never worn; `is_file`, `exists` and `metadata().ok()` took a permission error for absence, so a fresh install wore Catppuccin Mocha over an unreadable Omarchy folder, and an outage while following logged nothing. Fixed in aae7fba: the watcher compares the file's text, logs a failure once while it lasts and tries again; only a missing file means no Omarchy, and a file that cannot be read stops the start. The v0.1.1 Linux tarball was rebuilt from aae7fba on the owner's PC, which now runs Omarchy, and took the place of the one from 340c7a9 (1faf8ca).
|
||||
- Round 2, aae7fba and 1faf8ca: PASS. The reviewer reproduced both faults on the parent build and saw them gone, ran all 22 of Omarchy's themes through the window, and matched the new tarball's download to its SHA-256. CI run 976 on aae7fba passed on macOS and Ubuntu; its Windows job waits for the PC to run Windows again.
|
||||
|
||||
## T28
|
||||
|
||||
- Round 1, 741e48b with the v0.1.1 release notes: PASS. The reviewer read the new lines against Hyprland 0.56.2's `shouldBlur` and background-effect protocol and Omarchy 4.0.4's default `looknfeel.lua`, and the owner's captures with Hyprland's blur on and off against the acceptance row, which stays Partial for Wayland.
|
||||
|
||||
## T29
|
||||
|
||||
- Round 1, 3e30f95: FAIL. A shell that ran `exec herdr` kept its process group, so the session never looked at its foreground again and the panel stayed where it was; a relative `HERDR_SOCKET_PATH` was looked up from ELYTerminal's own folder, not the client's, so no socket answered. Fixed in 2af45cb: the foreground is its group and the executable its leader runs, a program that did not read is tried again, and a relative socket lies in the client's folder (`/proc/PID/cwd`, `proc_pidinfo` on macOS); on Linux an executable an update replaced still names itself herdr.
|
||||
- Round 2, 2af45cb: PASS. The reviewer ran real herdr 0.9.5 under the native view: a direct start, a delayed exec, a relative socket, both at once, and a folder with a space and an é each gave the folder events herdr's own snapshot named. CI run 988 on 3e30f95 and run 990 on 2af45cb passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T30
|
||||
|
||||
- Round 1, 1bf420c: FAIL. On X11 a maximized window, minimized, wrote the screen's size, since gpui's X11 window calls a maximized window unmaximized while it is hidden; and the window could be dragged under the 100 by 100 the settings keep, so a smaller size was refused and lost. Fixed in 2f58a06: a hidden window keeps the size from before, and the window is no smaller than 100 by 100.
|
||||
- Round 2, 2f58a06: FAIL. A size the window took just before something covered it was skipped while it was hidden and never read again once it showed. Fixed in 9729d24: a window that shows again is read again; and the window opens centered at whole points, since macOS grew a window whose centered edge fell on a half point by a point, so a kept 900 by 600 had opened at 900 by 601.
|
||||
- Round 3, 9729d24: PASS. The reviewer replayed the cover on macOS through gpui and AppKit, against the parent too, and saw the size kept once shown and the restart open at it; the window opened at its kept size and a move wrote nothing; on X11 a maximized, minimized and restored window and a resize minimized at once kept the right size, and the 100 by 100 minimum held. CI runs 993 on 1bf420c and 995 on 2f58a06 passed on macOS, Ubuntu and Windows; run 996 on 9729d24 passed on Ubuntu and Windows.
|
||||
|
||||
## T31
|
||||
|
||||
- Round 1, 4364802 and 2a1060c, with release v0.1.2: PASS. The reviewer compared `Cargo.lock` with its parent, where only elyterminal moved to 0.1.2; matched the 112 files of the source the builds came from to the tag byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's notarization and its app's signature, the zip's icon, manifest and imports, and the tarball's glibc 2.35; and ran the downloaded macOS app with herdr 0.9.5: the folder followed herdr's focus, and the window reopened at its kept 923 by 617. CI run 1000 on 4364802 passed on macOS, Ubuntu and Windows before the release was published; Release run 1001 built the notarized disk image.
|
||||
|
||||
## T32
|
||||
|
||||
- Round 1, d7dc1af: FAIL. On Windows the search took the herdr nearest under the shell, so a server the client started under itself, or one started beside it, was read as the session's herdr; its arguments named no client, nothing was tried again, and the panel stayed where it was. Fixed in 3b5bf46: the herdr processes at or under the shell are listed, none under another herdr, whose server and panes are its own; when that list changes their arguments are read, nearest first, and a server or a command beside the client is passed by.
|
||||
- Round 2, 3b5bf46: PASS. The reviewer ran the committed search and pick against both trees, a nested server, a stale parent loop, an unread process that reads later, a client's start, end and return, and a remote client, eight probes; undid each half of the fix in a copy and saw its regression test fail; and on macOS saw a herdr answer that came 800 ms after the shell's report dropped. CI run 1027 on d7dc1af passed on macOS, Ubuntu and Windows, run 1046 on 3b5bf46 on Ubuntu and Windows, the Windows runner running 120 and then 121 tests; the Linux suite and the real-herdr X11 run passed on both. No real herdr has run on Windows yet.
|
||||
|
||||
## T33
|
||||
|
||||
- Round 1, 6336c05 and da55942, with Ely 5c2822c and 005faef: FAIL. In a window too narrow for both kept widths, a drag set off from the squeezed width drawn, and the width it reported was squeezed again: the border jumped back against the pointer, and a drag with no sideways move saved the squeezed width. Fixed in 2173c74 through Ely 0b4848b: a drag sets off from the wider of the width drawn and the one kept, and one that has not moved sideways changes nothing.
|
||||
- Round 2, 2173c74, with Ely 0b4848b: FAIL. A drag that went out and came back where it set off kept the last width it had reported, since its move back to the press was taken for no move. Fixed in 3ea8b61 through Ely da3fdca: a drag back where it set off gives the width it had, and the width a drag sets off from lives with its press, so each press starts afresh.
|
||||
- Round 3, 3ea8b61, with Ely da3fdca: PASS. The reviewer replayed drags out and back on both borders on macOS, at 240 and squeezed at 900 and 900, presses one after another, a second drag with no sideways move, a drag past a limit and back, and a column hidden and shown again, and saw each width and border come back; Ely's round-trip test failed on 0b4848b and passed on da3fdca. Before the review, testing found the handle's line painted half a pixel on macOS and none on X11, since gpui paints a border no wider than half its box; Ely 005faef draws it filled. CI runs 1058 on da55942 and 1065 on 2173c74 passed on macOS, Ubuntu and Windows; run 1067 on 3ea8b61 passed on Ubuntu and Windows, its macOS job queued behind Ely's. No real drag on Windows yet.
|
||||
|
||||
## T34
|
||||
|
||||
- Round 1, c53b540: FAIL. PowerShell called a cmdlet's failure under `-ErrorAction Ignore` a success and read 1 for a native exit under `$PSNativeCommandUseErrorActionPreference`; bash 3.2 sent no C or D for a line that is only a subshell, and none at all while the user held a DEBUG trap; zsh left the first prompt unmarked under a theme that sets PS1 in its own hook. Testing before the review had found PowerShell's prompt broken under strict mode. Fixed in 0a22200: PowerShell reads a native code by the new object each run stores; bash 3.2 runs the user's trap after its own and keeps `$_`, which its trap had overwritten, and a lone subshell's D comes from the command number; zsh wraps the first prompt again through sched, which would reset `$pipestatus` on later prompts.
|
||||
- Round 2, 0a22200: FAIL. Under `extdebug` the bash 3.2 trap's status skipped the next command; a lone subshell still sent no C on bash 3.2; PowerShell gave a cmdlet's failure the code of a program run earlier on the line. A probe then found the trap running for key bindings as for typed commands, with nothing in it to tell them apart. Fixed in d4bc6e6: bash 3.2 drops its DEBUG trap and sends no C, its D from the command number; PowerShell reads the line's syntax tree, since a hook on every command lookup slowed a loop by half; each check waits for the prompt before its line, so a repeated status is the line's own.
|
||||
- Round 3, d4bc6e6: FAIL. The reviewer accepted bash 3.2 without C. PowerShell read 1 for a call through a variable, and bash without `promptvars` marked no command and said nothing. Fixed in 2c25116, after the last round, each with a check that fails on d4bc6e6: a line keeps a program's code unless it surely ends in PowerShell, and bash without `promptvars`, zsh without `prompt_percent` and PowerShell without PSReadLine say at start what they cannot mark. CI runs 1071 on c53b540, 1074 on 0a22200, 1076 on d4bc6e6 and 1077 on 2c25116 passed on macOS, Ubuntu and Windows; the Linux suite ran fish 4.0 and bash 5.2, CI's Ubuntu fish 3.7.
|
||||
|
||||
## T35
|
||||
|
||||
- Round 1, 15e83f5: FAIL. A prompt that opens a link of its own lost its mark after that link, and a mark inside a link left open took over the program's target; a blank row inside a prompt split it in two; the rewrite made `133;AB`, which the session refuses, a prompt. Fixed in 310c219: an OSC that may be a mark is held until it ends and read as the session reads it, and each prompt's link carries the prompt's number.
|
||||
- Round 2, 310c219: FAIL. A prompt all inside a program's link carried no number, nor a first row that was; an ESC followed by 1 MiB of controls held them all, and the tap stopped reading. Fixed in e2bb615: a program's link inside a prompt keeps its target and carries the prompt's number in its id, a prompt inside a link left open opens it again with the number, and controls inside an escape go on before the ESC.
|
||||
- Round 3, e2bb615: FAIL. A target over 256 bytes went unnumbered, and the end of such a prompt left output numbered; controls after an OSC's closing ESC went on before it, so the parser, still inside the OSC, dropped a line break. Fixed in 11ec0ef, after the last round, each with a check that fails on e2bb615: targets are kept whole as the scanner keeps them, and the tap reads about 2 KB at a time to hold the growth, as fast through a pty as larger reads; an ESC that ends an OSC the parser has goes on at once. The reviewer's 33 round 3 probes then ran against 11ec0ef: 32 passed, and the one left counts 200 reads, which carry 2 KB each now, not a stall, as its pty twin showed READY. CI runs 1080 on 15e83f5, 1082 on 310c219, 1083 on e2bb615 and 1085 on 11ec0ef passed on macOS, Ubuntu and Windows. The keys off macOS are bound, not run.
|
||||
|
||||
## T36
|
||||
|
||||
- Round 1, 42b272e: FAIL. A clear dropped the records before its other pairs were checked, so a clear with a newline in its msg emptied them; only the last of a repeated key was checked, so an earlier app over 32 bytes passed; the 4096-byte bound counted the body after the scanner skipped its controls, so 5018 bytes passed; the scanner left an escape at DEL or a high byte, which vte passes by, so `ESC DEL c` reset the screen and kept the records. Fixed in 4a269cb: every pair of a known key is checked, a clear's too, before the last of each wins; the scanner counts a sequence from OSC through ST with its skipped controls; the scanners and the prompt rewrite pass DEL and high bytes by in an escape.
|
||||
- Round 2, 4a269cb: PASS. The reviewer's probes failed on 42b272e and passed on 4a269cb at every read split; in the app on macOS the clear with a newline, the repeated long app and the 5018-byte report were refused, a full reset emptied the records, and a program read the query's answer before DA1's. CI runs 1089 on 42b272e and 1090 on 4a269cb passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T37
|
||||
|
||||
- Round 1, ef33a0d: FAIL. OSC 99's base64 was decoded and checked as UTF-8 chunk by chunk, so a payload split inside a group or inside a character dropped; decoded text was refused for a newline, which only plain text may not hold; the scanner bounded the whole body at 4096 bytes, so a full 4096-byte encoded payload with its metadata dropped and its next chunk showed alone. Fixed in 628ed6b: base64 is read group by group, so chunks split before encoding or after join, and the text is checked as UTF-8 once whole; plain text alone refuses controls; a chunk that breaks a rule drops what its notification gathered; the scanner keeps an OSC 99 body to 5124 bytes, room for 1024 bytes of metadata and a full payload.
|
||||
- Round 2, 628ed6b: PASS. In the app on macOS the round 1 probes gave Hello, the split emoji, both lines of the body and 3072 `a`s with END; boundary probes passed 1,627 chunkings after encoding and 433 before, and the scanner 5,140 read splits; the round 1 probes failed again on ef33a0d and with the scanner's old bound. CI runs 1093 on ef33a0d and 1095 on 628ed6b passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T38
|
||||
|
||||
- Round 1, a175b18: FAIL. herdr's records took ids under `herdr/`, which a program may name through OSC 7501, so a program's `herdr/build` went at herdr's first snapshot, an empty one too, and at its leaving. Fixed in 7d56063: herdr's records take `herdr:` before the pane, a colon no OSC 7501 id holds, since a program's id ends at one; the test that keeps a program's `herdr/build` and `herdr` through an empty snapshot, a full one and herdr's leaving fails with the old prefix.
|
||||
- Round 2, 7d56063: PASS. The reviewer's round 1 probes failed on a175b18 and passed on 7d56063; probes with a socket and JSON replies of the reviewer's own, under real GPUI scheduling and the project's pty, passed the states and fields, clear and reset, errors, a reply after the shell's folder, and herdr's leaving; no herdr server of the owner's was asked. CI runs 1127 on 72acbe4, 1128 on a175b18 and 1139 on 7d56063 passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T39
|
||||
|
||||
- Round 1, 85bcbf4: FAIL. A hidden session that waited, worked and waited again in one output lost its mark, since the workspace heard the records once an output; an open hover kept the text it opened with; ten long records ran past the window and could not be read; at 0% the ring vanished on a selected row in Ely Dark, its track the row's ground. Fixed in 53b8978: the view notes each change of the state its row shows and tells the workspace of each; hover reads the records and the last command's status, now the view's, at each frame, stays under the pointer and scrolls within the window; the ring holds Ely's loader.
|
||||
- Round 2, 53b8978: FAIL. herdr's records replaced in the same state left an open hover as it was, since nothing drew the view again; a hidden session that waited and worked again in one output still lost its mark, since the workspace checked the state at the output's end. Fixed in 3aaeb36: a change of herdr's records draws the view again; a hidden session's turn to waiting marks its row though it went on within the same output.
|
||||
- Round 3, 3aaeb36 at efda50f: PASS. In the app on macOS the reviewer's repros of all three rounds passed: herdr's new records showed in an open hover through a mock herdr of the reviewer's own, a hidden session that waited and worked, or waited and finished, in one output kept its mark until shown, and the hover, long list, scrolling and Ely Dark 0% repros held. CI runs 1142 on b504bfb, 1144 on 85bcbf4, 1146 on 53b8978 and 1149 on efda50f, which holds 3aaeb36, passed on macOS, Ubuntu and Windows.
|
||||
|
||||
## T40
|
||||
|
||||
- Pre-review, 4e029f1, a first version kept in a clone and never pushed: three Claude agents read it against kitty's spec, gpui's platform code, and the PRD and rules. They found the alive answer behind a later DA1, since the workspace answered it after the output; close and expiry claimed on Linux, where gpui closes nothing; an expiry lost with its session; a body read as markup on Linux; a press lost with its record; bash 3.2's redrawn D posting again; and no test of the posting conditions. 31de5e4 rewrote it so each session keeps its notices in its terminal view, where answers keep their order; 52576bb fixed the rest; efda50f posts at each turn of the row, as T39's round 2 set the waiting mark.
|
||||
- Round 1, 31de5e4 to 52576bb: FAIL. The first prompt, sent at 31de5e4, was lost while Codex started, so the round ran at 52576bb. A notification took the session's title from before a title set or a full reset in the same output, since the tap reads a notice before alacritty's parser reads the title. Fixed in 22752a2: the tap follows OSC 0 and 2, the title stack and full resets, and sends each title in order.
|
||||
- Round 2, 22752a2: FAIL. alacritty's later title events still overwrote the tap's, so a full reset, 2 KB more output and a notice named the old title; the tap kept 17 parts of a title where vte keeps 15, and dropped a title over 4096 bytes that vte keeps. Fixed in 140c2a5: the title comes from the tap alone, so the row, the window and a notification name the session alike; the tap splits a title as vte does and keeps 65536 bytes of one.
|
||||
- Round 3, 140c2a5: PASS. In the app on macOS, outside the bundle, where the log names each post, the reviewer's probes of all three rounds passed: a title set in one write or split, after a reset with up to 32 KB more output, of 17 parts and of 4100 bytes; 21 title cases matched the pinned vte and alacritty; and the lifecycle, rate limit, untracked, alive before DA1, expiry, close, in-view and press probes held. No system has shown a notification yet; the owner posts and presses one on each. CI runs 1148 on 31de5e4, 1149 on efda50f and 1150 on 52576bb passed on macOS, Ubuntu and Windows; 140c2a5's code is CI'd on T41's version commit, which holds it.
|
||||
|
||||
## T41
|
||||
|
||||
- Round 1, 7569f00 with release v0.1.3: PASS. The reviewer compared `Cargo.lock` with its parent, where of 844 packages only elyterminal moved, to 0.1.3; matched the 127 files of the tag's source archive to 7569f00 byte for byte; downloaded the three files anonymously against their SHA-256s; checked the disk image's stapled notarization and its app's Developer ID signature, hardened runtime and version 0.1.3, the zip's icon and manifest against their sources byte for byte and its imports, no C runtime DLL among them, and the tarball's three files and glibc 2.35; and matched the notes to T32 to T40. The app was not run, since its first notification asks the owner's leave. CI run 1156 on 7569f00 passed on macOS, Ubuntu and Windows before the release was published; Release run 1157 built the notarized disk image.
|
||||
|
||||
## T42
|
||||
|
||||
- No review: the owner waived it for this change.
|
||||
|
||||
## T43
|
||||
|
||||
- No review: the owner waived it for this change, its release included.
|
||||
|
||||
## T44
|
||||
|
||||
- Round 1, fc10c4d: FAIL. Under kitty's events flag alone, Option-Shift-[ as Alt sent ESC [ for ESC {, since the legacy bytes a key falls back to took the restored key; a key that is down was known by gpui's name, which Shift changes, so a release after Shift came up went nowhere, which on Windows keeps herdr's lease on the key. Fixed in 6f8bfae: legacy bytes keep gpui's character; a key that is down is known by AppKit's key code or Windows' virtual key; shot posts a press or a release alone.
|
||||
- Round 2, 6f8bfae: FAIL. On Windows a repeat that types after its modifiers came up overwrote the record its press made, so its release went as no record. Fixed in 3f47134: a repeat goes as a record where its press did, through one function, records, tested on every system.
|
||||
- Round 3, 3f47134: PASS. The reviewer's replay of the Windows handlers with herdr 0.9.5's lease code passed both release orders after a typing repeat, and a mutant that drops the fix failed; on macOS real herdr switched one, two, three, two, one with Shift, Ctrl or every modifier let go first; kitty flags 2 and 7 and plain text and an IME commit sent the bytes they should; check.sh, 226 Linux tests and the Windows cross-check passed.
|
||||
|
||||
## T45
|
||||
|
||||
- Round 1, ae3de24: FAIL. Inside herdr a plain file:// link the app had opened before went to herdr, whose client opens web links only, so nothing opened. Changed in af66a70: a link the app found of another kind it opened itself.
|
||||
- Round 2, af66a70: FAIL. herdr keeps its panes' OSC 8 targets from the terminal while it takes the mouse, so an OSC 8 link labelled with a file:// URL and aimed at a web page opened the label's file. Fixed in c30bd51: while herdr takes the mouse every press is herdr's again, its web-only rule with it, as its client and docs say; Shift with the press, herdr's named way past it, lets the terminal open what it sees.
|
||||
- Round 3, c30bd51: PASS. With real herdr 0.9.5 the label's prefix and its file:// part both opened the web target through herdr; Shift-Cmd-press opened a visible file:///etc/hosts in the app; with herdr's mouse capture off the app opened OSC 8 web and file targets and plain file URLs itself; wrapped, tail, OSC 8 and long web links opened whole through herdr, and links outside herdr in the app; check.sh passed.
|
||||
|
||||
## T46
|
||||
|
||||
- Round 1, 9aeb3f3: FAIL. A press on Ely made her panel AppKit's key window, so keys typed after it went nowhere; switched back on during an error she opened calm, since her mood waited for the next change; on a 100-point window her fixed 140-point offset left her beside it. Fixed in 6df7534: on macOS her panel becomes key only if needed, and on Windows her window answers a press's activation with none; she opens with the sessions' mood; she stands 140 points in or less, so she holds the pane's edge at any width, and on Wayland she opens again when a new width moves her.
|
||||
- Round 2, 6df7534: PASS. A native AppKit press on her kept the main window key and the program read abc; switched off and on during an error she came back worried; on a 100-point window she stood at its left edge, and 140 points in once it grew; minimized, both hid, and restored, both showed; check.sh, 8 Linux pet tests under Xvfb and the Windows cross-check passed, and on the Windows VM cargo test passed 200 tests at 6df7534. Native Windows interaction and a real Wayland desktop went unexercised.
|
||||
|
||||
## T47
|
||||
|
||||
- Round 1, 2d9180e and cdafdc2 with release v0.1.4: PASS. The reviewer compared Cargo.lock with its parent, where of 844 packages only elyterminal moved, to 0.1.4; found the release public, its tag and target at 2d9180e; downloaded the three files anonymously against the SHA-256 in the notes, and matched the notes to notes.md and to T44 to T46; read CI run 1215's three jobs, 228 tests on macOS, 234 on Ubuntu and 200 on Windows, done before the release was published, and Release run 1216's notarization; checked the disk image's stapled ticket and Gatekeeper, its app's Developer ID signature, hardened runtime, version 0.1.4, arm64 and macOS 11.0, the zip's icon against its source byte for byte, its manifest and its imports, no C runtime DLL among them, and the tarball's three files, their modes and glibc 2.35; and ran the downloaded app with a HOME of its own: it loaded its settings, started its shell, showed Ely, followed working, waiting, error and done, and quit with its last shell. check.sh passed.
|
||||
|
||||
## T48
|
||||
|
||||
- Round 1, c1cdddf: FAIL. With two displays of different DPI, her height came from the main window's DPI while her size came from her own, so her box fell 62 pixels into the terminal or stood above it. Fixed in 8ddf470: her height is her own DPI's and her bottom the pane's top; at a new DPI she kept her bottom left corner.
|
||||
- Round 2, 8ddf470: FAIL. Between two displays side by side, a corner kept always set her DPI back and forth. Fixed in f7e5563: her corner follows once the move is done, posted, and only where her box would lie mostly on a display of that DPI; else Windows' rect stands.
|
||||
- Round 3, f7e5563: FAIL. With two places piled up, a keep posted at a DPI no longer hers moved her to a display of it, and the keeps renewed each other. Fixed in 47826ee: a keep moves her only while its DPI is still hers. The three reviews spent, the owner allowed a fourth. Preparing it, e4b0107 fixed a place sized when it was posted, which left her the old DPI's size on the new display: a place now carries her corner, and her window sizes her when it lands.
|
||||
- Round 4, 47826ee and e4b0107: PASS. The reviewer's replays drained round 3's queue, left no stale size, and across 1,000 crossings of side-by-side and stacked display borders at 96 to 192 DPI, 8,432 messages, each place brought at most one DPI change and each keep none; check.sh and the Windows cross-check passed. Native multi-display runs remain out of reach.
|
||||
|
||||
## T49
|
||||
|
||||
- Round 1, 9d9144f: FAIL. Only a change of act started her ease, so a mood that changed inside a wink dropped her 12 points mid-hop, an old finish cleared mid-glance snapped her eyes back, and a second press mid-hop dropped her too. Fixed in a9be8ab: a change of mood and a press each ease her from the pose she was in, as a change of act does; her brows ease with her.
|
||||
- Round 2, a9be8ab: PASS. In the reviewer's replays a mood that changed inside a wink kept her lift at 40.3 and faded the wait's mark over 0.3 s, an old finish cleared mid-glance eased her look and tilt back, and a second press mid-hop kept her lift; 160 mixes of moods, times and winks passed; macOS shot replays of all three showed the ease; check.sh passed with 237 tests. Windows ran at 9d9144f, 212 tests, and drew her; the VM was offline for the fix, which changes no Windows code.
|
||||
Reference in new issue
Block a user