Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a9941bfd2
|
||
|
|
9e185eab58
|
||
|
|
55250f49f5
|
||
|
|
17e91aafef
|
||
|
|
3759cf8825
|
||
|
|
15153b4aae
|
||
|
|
0119776095
|
||
|
|
a153e89d84
|
||
|
|
a6c044f009
|
||
|
|
b7377e424e
|
||
|
|
0eb8fc5c4e
|
||
|
|
dbb19caaf8
|
||
|
|
8b647982ef
|
||
|
|
e6c0c14d1b
|
||
|
|
15ef5d52d0
|
||
|
|
7032285d13
|
||
|
|
5d109790c8
|
||
|
|
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
|
No files matched your search
@@ -9,15 +9,17 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||||
- `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.
|
- `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 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 and waits on herdr's pipe.
|
- 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.
|
- `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
|
## Commands
|
||||||
|
|
||||||
- `cargo run` opens the app. `RUST_LOG=elyterminal=debug cargo run` logs more; `trace` logs each paint.
|
- `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:
|
- `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);
|
- 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;
|
- 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;
|
- `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;
|
- `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`.
|
- 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`.
|
||||||
@@ -31,10 +33,11 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
## Layout
|
## 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.
|
- `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, 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/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/updates.rs`: checks for a newer release through `curl` and Gitea's API, at start and once a day while the settings ask, and when Settings › Terminal asks.
|
||||||
|
- `src/pet.rs`: Ely, peeking over the main window's top edge from a window of her own while the settings want her, or up from its bottom edge where no room lies above it; 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/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; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
|
- `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 and the mouse 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: `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 and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 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. `scrollbar` is the thumb over the history that a drag moves through it. `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/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/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/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them).
|
||||||
@@ -62,7 +65,7 @@ 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: 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: 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: 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: 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: 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: 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.
|
||||||
@@ -78,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: 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: 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: 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-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 Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair.
|
||||||
- 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach.
|
- 2026-10-15: 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.
|
||||||
@@ -103,3 +106,11 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
|||||||
- 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: 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'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-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, the 5 minutes counted from her mood's start or the last press, so a press wakes her, which the owner asked for after 0.1.4, where only a new mood did; 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.
|
||||||
|
- 2026-10-19: Ely stands inside the main window, her bottom on its pane's bottom edge, as far in from the left, while her box would not fit above it, which the owner asked for after 0.1.4: on a window at the screen's top or full screen her titled macOS panel, which AppKit keeps under the menu bar, stuck between the menu bar and the terminal, and on Windows and X11 she stood off the screen. The rule lives in `pet.rs`: she stands on the top edge while the room above the pane, up to the top of the work area of the main window's display, holds her box, 123.4 points; each platform measures the room and places her: macOS from the main window's `frame` and its `screen`'s `visibleFrame`, which leaves out the menu bar, both in AppKit's points; Windows from the client rect and the work area (`rcWork`) of the monitor `MonitorFromWindow` names, at the main window's scale, since above it or inside it she stands on that monitor; X11 from the pane's place on the root, up to the top of the RandR monitor holding most of it, as Windows and macOS pick a window's display, or of `_NET_WORKAREA`, its first desktop's, where that lies lower: the work area spans every monitor, so on a lower monitor its top is another's. A Wayland client knows no place of its own, so there she stands on the bottom edge while the window is full screen, maximized or tiled at its top, which gpui reports only for a window it draws itself: under server decorations, as Sway's tiling draws them, she stays on top. gpui moves a popup's anchor never, hears a change of state alone as no change of bounds, and gives an unseen popup no frames, so twice a second she checks where the window's size and state put her anchor, counted from the pane as gpui counts it, and opens there again when it moved. Each place logs the edge she moves to. On WSLg, X11 and Weston's Wayland both put her inside a window as tall as the screen or maximized, and over one with room; headless Sway and a two-monitor Xorg did too.
|
||||||
|
- 2026-10-19: Ely's window shows nothing but her, which the owner asked for after a user's 0.1.4 drew a hairline along the top of her box on macOS. gpui opens a popup as a titled panel, style mask 0x8081, titled, full-size content and nonactivating, and macOS draws a titled window's frame, there as a light edge over the clear room above her head. As it is glued her panel turns borderless, nonactivating alone, 0x80, and the log names both masks; a mask that does not take fails the glue, which closes her window, as any failed glue now does, where her window stayed open, unplaced, while the log said she did not show. macOS 27.2 draws nothing of that frame, so the line never showed here, the app active or not; there a real click on her borderless panel still winks her, the front app stays in front, and keys typed after it reach the terminal. On Windows her bare popup, and on Linux X11's override-redirect window and Wayland's xdg_popup, draw no frame, border or shadow.
|
||||||
|
- 2026-10-19: a drag moves Ely along the edge she stands on, which the owner asked for after 0.1.5. On macOS, Windows and X11 her glue moves her window after the pointer's place on the screen, `NSEvent`'s `mouseLocation`, `GetCursorPos` and `query_pointer`, since her window moves under the pointer, so a place in it would lag; her window keeps the pointer while the button is held, inside it or not, as AppKit, gpui's `SetCapture` and X's implicit grab give it. A Wayland popup cannot move, so there she moves once let go: the settings keep the place, and the twice-a-second check opens her popup there. She moves by whole points inside the pane, and `ely_left` keeps where she stands, 140 by default, where she stood before; a narrower pane holds her at its right end, and the log names where she is let go. Under Xorg's dummy screen xdotool dragged her 200 pixels and her window followed them; under Sway nested in Xorg, whose X input gives its seat a pointer, xdotool's drag reopened her popup 200 points on. Sway's own `seat cursor` commands send no motion while their press is held, and Sway draws no popup opened before its window settled, so a probe there reopens her before it drags.
|
||||||
|
- 2026-10-19: Settings › Terminal checks for updates, which the owner asked for after 0.1.5: under Ely, a switch, on by default, checks at start and once a day, and Check now checks at once. The check runs `curl`, as git runs through its command line, so no HTTP or TLS crate joins the build; macOS and Windows 10 ship it, and a missing curl shows as a failed check. It asks Gitea's API for the latest published release, compares its tag's three numbers with the running version, and the row says what it found; a newer one brings Download, which opens the release's page in the browser, since replacing a running, signed app is a job of its own. An automatic check that finds one posts a system notification, at most once a day, whose press opens the page. Checks run off the main thread, one at a time, each start and finding logged; a failure shows its error in the row. Under Xorg's dummy screen the start's check found 0.1.5 the latest, Check now and the switch checked again, and a build named 0.1.4 showed 0.1.5 out with Download, which opened its page.
|
||||||
|
- 2026-10-19: the terminal shows a scrollbar, which a user asked for in issue #2 after 0.1.5: a program that tracks the mouse takes the wheel, so the wheel alone could not reach the history. A thumb rides the view's right edge, over the padding, in Ely's scrollbar sizes and colors, its thickness, its least length and the foreground at 22 and 42 percent; its length is the screen's share of the history, and its place where the view stands. A drag moves the view through the history, whatever mouse mode the program asked for; the bar covers the grid's hitbox, so the terminal sees none of its presses, and a release with no press of its own reports nothing. Ely's `Scrollbar` reads a gpui `ScrollHandle`, and the terminal's place is alacritty's display offset, so the bar is the terminal's own, drawn as Ely draws hers and dragged through gpui's drag as hers is; gpui sets a drag's anchor at its first move past the threshold, so a pointer that jumps far at once loses that jump. The alternate screen holds no history, so it shows none. Under Xorg's dummy screen, 500 lines in, a drag to the top showed the first prompt and lines 1 to 39, and with mouse tracking on, which sent the wheel to the program, the bar still scrolled.
|
||||||
Generated
+1
-1
@@ -1747,7 +1747,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "elyterminal"
|
name = "elyterminal"
|
||||||
version = "0.1.3"
|
version = "0.1.6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alacritty_terminal",
|
"alacritty_terminal",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "elyterminal"
|
name = "elyterminal"
|
||||||
version = "0.1.3"
|
version = "0.1.6"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -41,7 +41,7 @@ raw-window-handle = "0.6"
|
|||||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||||
wayland-client = "0.31"
|
wayland-client = "0.31"
|
||||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||||
x11rb = "0.13"
|
x11rb = { version = "0.13", features = ["randr"] }
|
||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||||
wayland-protocols = { version = "0.32", features = ["server", "staging"] }
|
wayland-protocols = { version = "0.32", features = ["server", "staging"] }
|
||||||
@@ -59,8 +59,9 @@ foreign-types = "0.5"
|
|||||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
raw-window-handle = "0.6"
|
||||||
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes"] }
|
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]
|
[target.'cfg(windows)'.dev-dependencies]
|
||||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
||||||
|
|||||||
@@ -54,12 +54,13 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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`).
|
- Focus reporting, title (OSC 0/2), scrollback limit per setting, find in scrollback (Ely's `FindWidget`).
|
||||||
|
- A scrollbar over the history, along the terminal's right edge: a drag on its thumb scrolls, whatever the program does with the wheel.
|
||||||
- Bell: no sound. The session row marks it until the session is shown.
|
- Bell: no sound. The session row marks it until the session is shown.
|
||||||
|
|
||||||
## 6. Main window and layout
|
## 6. Main window and layout
|
||||||
@@ -70,6 +71,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
|||||||
- 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 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 title shows the active session's title, and no other text.
|
||||||
- The pointer over the terminal is the arrow.
|
- 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; where no room lies above the window, at the screen's top or full screen, she stands inside it at its bottom left. 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. Nothing but her shows: no frame, line or shadow around her. A press pets her, and wakes her from a doze. A drag moves her along the edge she stands on, and she stays where she is let go. 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.
|
- 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.
|
- Every control in the title bar takes its own press on every platform, the window buttons Ely draws on Windows and Linux too.
|
||||||
|
|
||||||
@@ -116,6 +118,9 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
|||||||
- Scrollback lines.
|
- Scrollback lines.
|
||||||
- Option as Alt: off, left, right, both (macOS only).
|
- Option as Alt: off, left, right, both (macOS only).
|
||||||
- Copy on select.
|
- Copy on select.
|
||||||
|
- Ely: a switch.
|
||||||
|
- Check for updates automatically: a switch, on by default, which checks at start and once a day.
|
||||||
|
- Updates: what the last check found, and Check now; a newer release brings Download, which opens its page, and one an automatic check finds posts a notification.
|
||||||
- Shortcuts: every shortcut, as this platform's keys, with a search.
|
- Shortcuts: every shortcut, as this platform's keys, with a search.
|
||||||
|
|
||||||
## 11. Background blur
|
## 11. Background blur
|
||||||
|
|||||||
@@ -41,8 +41,10 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
|||||||
|
|
||||||
## What it does
|
## 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.
|
- 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.
|
||||||
|
- A scrollbar along the terminal's right edge scrolls its history, even while a program takes the wheel.
|
||||||
- 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.
|
- 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, or up from its bottom left when the window leaves her no room above it, 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, waking her from a doze. Drag her along the edge to move her. 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.
|
- 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`.
|
- 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.
|
- 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.
|
||||||
@@ -83,6 +85,8 @@ The settings window saves each change at once to `settings.json` in the platform
|
|||||||
|
|
||||||
A file that does not parse, or holds a value out of range, stops the start with an error naming the file and the field. The app never overwrites it.
|
A file that does not parse, or holds a value out of range, stops the start with an error naming the file and the field. The app never overwrites it.
|
||||||
|
|
||||||
|
Settings › Terminal checks for a newer release at start and once a day, or at once with Check now; a newer one shows a Download button that opens its page. The check runs `curl`, which macOS and Windows ship.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
`scripts/check.sh` runs fmt, clippy, the tests and the 500-line rule. `AGENTS.md` lists the rest: the layout, the decisions and the tools.
|
`scripts/check.sh` runs fmt, clippy, the tests and the 500-line rule. `AGENTS.md` lists the rest: the layout, the decisions and the tools.
|
||||||
|
|||||||
@@ -109,4 +109,43 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
|||||||
|
|
||||||
## 23. Release
|
## 23. Release
|
||||||
|
|
||||||
- [ ] 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.
|
- [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
|
||||||
|
|
||||||
|
- [x] 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.
|
||||||
|
|
||||||
|
## 28. Ely's place, sleep and frame
|
||||||
|
|
||||||
|
- [x] T51 Ely stands inside the main window at its bottom left while no room lies above it, which the owner asked for after 0.1.4, where on a window at the screen's top or full screen she stuck between the menu bar and the terminal, on macOS, Windows and Linux: her bottom stands on the pane's top edge while her box fits under the top of the work area of the main window's display, else on the pane's bottom edge, as far in from the left; on Wayland, which tells a client nothing of its place, on the bottom edge while the window is full screen, maximized or tiled at its top as gpui reports it, which it does for a window it draws itself.
|
||||||
|
- [x] T52 A press wakes Ely from her doze, which the owner asked for: she dozes after five calm minutes since the later of her mood's start and the last press, so a press wakes her for five more.
|
||||||
|
- [x] T53 Ely's window shows nothing but her, which the owner asked for after a line showed along its top on macOS: there her window is a borderless panel, without the titled frame gpui's popup carries, whose edge macOS draws; on Windows and Linux her window shows no frame, border or shadow.
|
||||||
|
|
||||||
|
## 29. Release
|
||||||
|
|
||||||
|
- [x] T54 Release v0.1.5 with T51 to T53: version 0.1.5; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
|
||||||
|
|
||||||
|
## 30. Ely's drag, updates and the scrollbar
|
||||||
|
|
||||||
|
- [x] T55 A drag moves Ely along the edge she stands on, which the owner asked for after 0.1.5: she follows the pointer sideways inside the pane, on macOS, Windows and X11 as it moves, on Wayland, whose popup keeps its place, once it lets go; where she stands, in points from the pane's left edge, the settings keep as `ely_left`.
|
||||||
|
- [x] T56 Settings › Terminal checks for updates, which the owner asked for after 0.1.5: under Ely, a switch checks ELYTerminal's latest release at start and once a day, on by default, and a button checks at once; the row says what the check found, and a newer release brings a button that opens its page; a newer release an automatic check finds posts a system notification, whose press opens that page.
|
||||||
|
- [x] T58 The terminal shows a scrollbar over its history, which a user asked for in issue #2 after 0.1.5, since a program that takes the mouse takes the wheel too: a thumb along its right edge, in Ely's sizes and colors, stands where the view stands in the history, and a drag on it moves the view through it; none shows without history, as on the alternate screen, and a press on it never reaches the terminal.
|
||||||
|
|
||||||
|
## 31. Release
|
||||||
|
|
||||||
|
- [ ] T57 Release v0.1.6 with T55, T56 and T58: version 0.1.6; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed.
|
||||||
@@ -21,6 +21,40 @@ pub fn number(window: &gpui::Window) -> anyhow::Result<u32> {
|
|||||||
u32::try_from(number).context("window number out of range")
|
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.
|
/// Shows the window as key, the app left inactive.
|
||||||
pub fn front(number: u32) -> anyhow::Result<()> {
|
pub fn front(number: u32) -> anyhow::Result<()> {
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|||||||
@@ -38,10 +38,25 @@ fn main() {
|
|||||||
number.expect("the window lives").expect("a window number")
|
number.expect("the window lives").expect("a window number")
|
||||||
};
|
};
|
||||||
let mut aim = main;
|
let mut aim = main;
|
||||||
|
let mut snaps = 0;
|
||||||
capture::front(number(aim, cx)).expect("the window on screen");
|
capture::front(number(aim, cx)).expect("the window on screen");
|
||||||
cx.background_executor().timer(Duration::from_secs(1)).await;
|
cx.background_executor().timer(Duration::from_secs(1)).await;
|
||||||
for spec in &keys {
|
for spec in &keys {
|
||||||
match spec.as_str() {
|
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" => {
|
"to:main" => {
|
||||||
aim = main;
|
aim = main;
|
||||||
capture::front(number(aim, cx)).expect("the window on screen");
|
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 = aim.update(cx, |_, window, _| window.window_title());
|
||||||
let title = title.expect("the window lives");
|
let title = title.expect("the window lives");
|
||||||
capture::snapshot(number(aim, cx), std::path::Path::new(&out)).expect("a picture");
|
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");
|
let views = capture::views(number(aim, cx)).expect("the window's views");
|
||||||
log::info!("shot: wrote {out}, titled {title:?}, over {views:?}");
|
log::info!("shot: wrote {out}, titled {title:?}, over {views:?}");
|
||||||
for line in capture::menus() {
|
for line in capture::menus() {
|
||||||
|
|||||||
+14
-3
@@ -12,7 +12,7 @@ use objc::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// US layout key codes and what each key types.
|
/// US layout key codes and what each key types.
|
||||||
const KEYS: [(&str, u16, &str); 32] = [
|
const KEYS: [(&str, u16, &str); 34] = [
|
||||||
(",", 43, ","),
|
(",", 43, ","),
|
||||||
("a", 0, "a"),
|
("a", 0, "a"),
|
||||||
("b", 11, "b"),
|
("b", 11, "b"),
|
||||||
@@ -32,8 +32,10 @@ const KEYS: [(&str, u16, &str); 32] = [
|
|||||||
("=", 24, "="),
|
("=", 24, "="),
|
||||||
("minus", 27, "-"),
|
("minus", 27, "-"),
|
||||||
("m", 46, "m"),
|
("m", 46, "m"),
|
||||||
|
("n", 45, "n"),
|
||||||
("q", 12, "q"),
|
("q", 12, "q"),
|
||||||
("[", 33, "["),
|
("[", 33, "["),
|
||||||
|
("]", 30, "]"),
|
||||||
("space", 49, " "),
|
("space", 49, " "),
|
||||||
("tab", 48, "\t"),
|
("tab", 48, "\t"),
|
||||||
("enter", 36, "\r"),
|
("enter", 36, "\r"),
|
||||||
@@ -217,7 +219,11 @@ fn mouse(name: &str, flags: u64, number: u32) -> anyhow::Result<()> {
|
|||||||
/// Posts one spec on window `number`.
|
/// Posts one spec on window `number`.
|
||||||
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
||||||
if let Some((call, text)) = spec.split_once(':') {
|
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 {
|
if let "activate" | "deactivate" = spec {
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -231,6 +237,11 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
|||||||
log::info!("shot: {spec}");
|
log::info!("shot: {spec}");
|
||||||
return Ok(());
|
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 mut parts: Vec<&str> = spec.split('-').collect();
|
||||||
let name = parts.pop().context("an empty key")?;
|
let name = parts.pop().context("an empty key")?;
|
||||||
let mut flags: u64 = 0;
|
let mut flags: u64 = 0;
|
||||||
@@ -275,7 +286,7 @@ pub fn post(spec: &str, number: u32) -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
_ => ignoring.clone(),
|
_ => ignoring.clone(),
|
||||||
};
|
};
|
||||||
for kind in [10u64, 11] {
|
for &kind in kinds {
|
||||||
unsafe {
|
unsafe {
|
||||||
let event: *mut Object = msg_send![
|
let event: *mut Object = msg_send![
|
||||||
class!(NSEvent),
|
class!(NSEvent),
|
||||||
|
|||||||
+7
-2
@@ -13,7 +13,7 @@ use crate::{
|
|||||||
blur, menus,
|
blur, menus,
|
||||||
settings::{self, SettingsStore},
|
settings::{self, SettingsStore},
|
||||||
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
||||||
themes,
|
themes, updates,
|
||||||
workspace::{self, TITLE, Workspace},
|
workspace::{self, TITLE, Workspace},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,15 +45,19 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
SettingsStore::init(cx)?;
|
SettingsStore::init(cx)?;
|
||||||
|
updates::start(cx);
|
||||||
ShellIntegration::init(cx)?;
|
ShellIntegration::init(cx)?;
|
||||||
input::bind_keys(cx);
|
input::bind_keys(cx);
|
||||||
workspace::bind_keys(cx);
|
workspace::bind_keys(cx);
|
||||||
settings::window::bind_keys(cx);
|
settings::window::bind_keys(cx);
|
||||||
menus::init(cx);
|
menus::init(cx);
|
||||||
cx.set_app_identity(APP_ID, TITLE);
|
cx.set_app_identity(APP_ID, TITLE);
|
||||||
// A press on a notification goes to the window whose session posted it.
|
// A press on a notification goes to the window whose session posted it, or to a newer release's page.
|
||||||
cx.on_system_notification_response(|response, cx| {
|
cx.on_system_notification_response(|response, cx| {
|
||||||
let tag = response.tag;
|
let tag = response.tag;
|
||||||
|
if updates::pressed(&tag, cx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let pressed = cx
|
let pressed = cx
|
||||||
.windows()
|
.windows()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -136,6 +140,7 @@ pub fn open(launch: Launch, cx: &mut App) -> anyhow::Result<WindowHandle<Workspa
|
|||||||
Workspace::new(pty, events, launch, window, cx)
|
Workspace::new(pty, events, launch, window, cx)
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
crate::pet::follow(window, cx);
|
||||||
log::info!(
|
log::info!(
|
||||||
"app: main window open at {} by {}, blur: {background:?} on {}",
|
"app: main window open at {} by {}, blur: {background:?} on {}",
|
||||||
f32::from(kept.width),
|
f32::from(kept.width),
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,4 @@
|
|||||||
#[cfg(target_os = "linux")]
|
use gpui::{Bounds, Decorations, Pixels, Size, Window, WindowBackgroundAppearance, point};
|
||||||
use gpui::{Bounds, Decorations, Pixels, Size, point};
|
|
||||||
use gpui::{Window, WindowBackgroundAppearance};
|
|
||||||
|
|
||||||
use crate::settings::Settings;
|
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.
|
/// 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")]
|
pub(crate) fn pane(
|
||||||
fn pane(size: Size<Pixels>, inset: Option<Pixels>, decorations: Decorations) -> Bounds<Pixels> {
|
size: Size<Pixels>,
|
||||||
|
inset: Option<Pixels>,
|
||||||
|
decorations: Decorations,
|
||||||
|
) -> Bounds<Pixels> {
|
||||||
let mut pane = Bounds::new(point(Pixels::ZERO, Pixels::ZERO), size);
|
let mut pane = Bounds::new(point(Pixels::ZERO, Pixels::ZERO), size);
|
||||||
let (Decorations::Client { tiling }, Some(inset)) = (decorations, inset) else {
|
let (Decorations::Client { tiling }, Some(inset)) = (decorations, inset) else {
|
||||||
return pane;
|
return pane;
|
||||||
|
|||||||
+1
-36
@@ -1,8 +1,3 @@
|
|||||||
use std::{
|
|
||||||
io::{BufRead, BufReader},
|
|
||||||
process::{Child, Command, Stdio},
|
|
||||||
};
|
|
||||||
|
|
||||||
use x11rb::{
|
use x11rb::{
|
||||||
COPY_DEPTH_FROM_PARENT,
|
COPY_DEPTH_FROM_PARENT,
|
||||||
connection::Connection as _,
|
connection::Connection as _,
|
||||||
@@ -10,37 +5,7 @@ use x11rb::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::testx11::Xvfb;
|
||||||
/// 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_window_names_its_pane_while_blurred() {
|
fn the_window_names_its_pane_while_blurred() {
|
||||||
|
|||||||
+2
-2
@@ -25,9 +25,9 @@ fn line<A: AsRef<OsStr>>(args: &[A]) -> String {
|
|||||||
args.join(" ")
|
args.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps Windows from opening a console window for git.
|
/// Keeps Windows from opening a console window for git and curl.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
|
||||||
/// Runs git in `folder`: English messages, literal paths, no lock the user's own git could meet; `input` feeds stdin.
|
/// Runs git in `folder`: English messages, literal paths, no lock the user's own git could meet; `input` feeds stdin.
|
||||||
fn git<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
|
fn git<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ pub mod blur;
|
|||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod git;
|
pub mod git;
|
||||||
pub mod menus;
|
pub mod menus;
|
||||||
|
pub mod pet;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod term;
|
pub mod term;
|
||||||
#[cfg(all(test, unix))]
|
#[cfg(all(test, unix))]
|
||||||
mod testlog;
|
mod testlog;
|
||||||
|
#[cfg(all(test, target_os = "linux"))]
|
||||||
|
mod testx11;
|
||||||
pub mod themes;
|
pub mod themes;
|
||||||
|
pub mod updates;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
+494
@@ -0,0 +1,494 @@
|
|||||||
|
//! Ely, the icon's girl, peeking over the main window's top edge from a window of her own, or up from its bottom edge where no room lies above.
|
||||||
|
|
||||||
|
mod look;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos;
|
||||||
|
mod rig;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod windows;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
mod x11;
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
cell::RefCell,
|
||||||
|
rc::Rc,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext as _, Bounds, Context, Decorations, DispatchPhase, InteractiveElement,
|
||||||
|
IntoElement, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
|
||||||
|
Point, 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 often she checks her Wayland anchor against the main window.
|
||||||
|
const CHECK: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
|
/// The pane edge her bottom stands on: the top, or the bottom, inside the window.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum Edge {
|
||||||
|
Top,
|
||||||
|
Bottom,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window she shows in, which the settings open and close.
|
||||||
|
type Shown = Rc<RefCell<Option<WindowHandle<Pet>>>>;
|
||||||
|
|
||||||
|
/// 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)>,
|
||||||
|
/// The edge she stands on, and on Wayland the anchor her popup opened at, from the pane's top left, which never moves.
|
||||||
|
edge: Edge,
|
||||||
|
anchor: Option<Point<Pixels>>,
|
||||||
|
/// How far in from the pane's left edge she stands, and a drag under way: the pointer's x where it began and where she stood then.
|
||||||
|
at: f32,
|
||||||
|
drag: Option<(f32, f32)>,
|
||||||
|
main: WindowHandle<Workspace>,
|
||||||
|
/// 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: Shown = 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();
|
||||||
|
// gpui hears a change of state alone as no change of bounds, and sends an unseen popup no frames.
|
||||||
|
if main
|
||||||
|
.update(cx, |_, window, _| wayland(window))
|
||||||
|
.is_ok_and(|wayland| wayland)
|
||||||
|
{
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
while cx.update(|cx| reanchor(main, &shown, cx)).is_ok() {
|
||||||
|
cx.background_executor().timer(CHECK).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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: `at`, or less on a narrow pane, so she holds its edge.
|
||||||
|
fn left(width: Pixels, at: f32) -> f32 {
|
||||||
|
at.min(f32::from(width) - WIDTH).max(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edge she stands on with `room` points free above the pane, up to the top of the work area of the main window's display.
|
||||||
|
fn edge(room: f32) -> Edge {
|
||||||
|
if room >= HEAD + ROOM {
|
||||||
|
Edge::Top
|
||||||
|
} else {
|
||||||
|
Edge::Bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The edge she stands on over a Wayland window, whose place no client knows: the bottom while it is full screen, maximized or tiled at its top.
|
||||||
|
fn wayland_edge(window: &Window) -> Edge {
|
||||||
|
let tiled = matches!(window.window_decorations(), Decorations::Client { tiling } if tiling.top);
|
||||||
|
if tiled || window.is_fullscreen() || window.is_maximized() {
|
||||||
|
Edge::Bottom
|
||||||
|
} else {
|
||||||
|
Edge::Top
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where she stands over a Wayland window: the edge, and her anchor counted from the pane's top left, as gpui's popups count.
|
||||||
|
fn wayland_spot(window: &Window, at: f32) -> (Edge, Point<Pixels>) {
|
||||||
|
let pane = pane(window);
|
||||||
|
let edge = wayland_edge(window);
|
||||||
|
let y = match edge {
|
||||||
|
Edge::Top => px(0.),
|
||||||
|
Edge::Bottom => pane.size.height - px(1.),
|
||||||
|
};
|
||||||
|
(edge, point(px(left(pane.size.width, at)), y))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens her again over a Wayland `main` once its size or state moves the anchor her popup opened at, which never moves.
|
||||||
|
fn reanchor(main: WindowHandle<Workspace>, shown: &Shown, cx: &mut App) -> anyhow::Result<()> {
|
||||||
|
let at = SettingsStore::get(cx).ely_left;
|
||||||
|
let spot = main.update(cx, |_, window, _| wayland_spot(window, at))?;
|
||||||
|
let Some(pet) = *shown.borrow() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if pet
|
||||||
|
.read(cx)
|
||||||
|
.is_ok_and(|pet| (pet.edge, pet.anchor) == (spot.0, Some(spot.1)))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
close(
|
||||||
|
pet,
|
||||||
|
"to stand where the window's size or state, or a drag, puts her",
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
*shown.borrow_mut() = open_pet(main, cx);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens her window on `main`'s pane: a popup Wayland's compositor places, else a window kept there.
|
||||||
|
fn show(main: WindowHandle<Workspace>, cx: &mut App) -> anyhow::Result<WindowHandle<Pet>> {
|
||||||
|
let kept = SettingsStore::get(cx).ely_left;
|
||||||
|
let (bounds, pane, wayland, (edge, spot)) = main.update(cx, |_, window, _| {
|
||||||
|
(
|
||||||
|
window.bounds(),
|
||||||
|
pane(window),
|
||||||
|
wayland(window),
|
||||||
|
wayland_spot(window, kept),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let at = left(pane.size.width, kept);
|
||||||
|
let room = size(px(WIDTH), px(HEAD + ROOM));
|
||||||
|
let (edge, kind) = if wayland {
|
||||||
|
let anchor = match edge {
|
||||||
|
Edge::Top => popup::PopupAnchor::TopLeft,
|
||||||
|
Edge::Bottom => popup::PopupAnchor::BottomLeft,
|
||||||
|
};
|
||||||
|
let kind = WindowKind::AnchoredPopup(popup::PopupOptions {
|
||||||
|
parent: main.into(),
|
||||||
|
anchor_rect: Bounds::new(pane.origin + spot, size(px(1.), px(1.))),
|
||||||
|
anchor,
|
||||||
|
gravity: popup::PopupGravity::TopRight,
|
||||||
|
constraint_adjustment: popup::PopupConstraintAdjustment::empty(),
|
||||||
|
offset: point(px(0.), px(0.)),
|
||||||
|
grab: false,
|
||||||
|
});
|
||||||
|
(edge, kind)
|
||||||
|
} else {
|
||||||
|
(Edge::Top, 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,
|
||||||
|
edge,
|
||||||
|
anchor: wayland.then_some(spot),
|
||||||
|
at: kept,
|
||||||
|
drag: None,
|
||||||
|
main,
|
||||||
|
glue: None,
|
||||||
|
_watch: vec![feel],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
let (way, edge) = if wayland {
|
||||||
|
("a Wayland popup", edge)
|
||||||
|
} else {
|
||||||
|
let glued = glue(main, pet, cx);
|
||||||
|
let edge = glued.inspect_err(|_| close(pet, "as her window cannot be kept there", cx))?;
|
||||||
|
("a window kept there", edge)
|
||||||
|
};
|
||||||
|
let edge = format!("{edge:?}").to_lowercase();
|
||||||
|
log::info!(
|
||||||
|
"pet: Ely peeks over the main window, {way}, on the pane's {edge} edge, feeling {start:?}"
|
||||||
|
);
|
||||||
|
Ok(pet)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps her window on `main`'s pane as it moves, comes forward and hides, and gives the edge she first stands on.
|
||||||
|
fn glue(
|
||||||
|
main: WindowHandle<Workspace>,
|
||||||
|
pet: WindowHandle<Pet>,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> anyhow::Result<Edge> {
|
||||||
|
main.update(cx, |_, main, cx| {
|
||||||
|
pet.update(cx, |pet, window, cx| {
|
||||||
|
let glue = platform::Glue::new(window, main)?;
|
||||||
|
pet.edge = glue.place(main, pet.at)?;
|
||||||
|
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(pet.edge)
|
||||||
|
})?
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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(&mut self, main: &Window) {
|
||||||
|
let Some(glue) = &self.glue else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match glue.place(main, self.at) {
|
||||||
|
Ok(edge) if edge != self.edge => {
|
||||||
|
let name = format!("{edge:?}").to_lowercase();
|
||||||
|
log::info!("pet: Ely moves to the pane's {name} edge");
|
||||||
|
self.edge = edge;
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) => log::error!("{error:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes hold of her for a drag along her edge, the pointer `local` in her window.
|
||||||
|
fn grab(&mut self, local: Pixels, scale: f32) {
|
||||||
|
match self.pointer(local, scale) {
|
||||||
|
Ok(x) => self.drag = Some((x, self.at)),
|
||||||
|
Err(error) => log::error!("{error:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves her along her edge after the pointer, inside the pane.
|
||||||
|
fn slide(&mut self, local: Pixels, scale: f32, cx: &mut App) {
|
||||||
|
let Some((from, at)) = self.drag else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let x = match self.pointer(local, scale) {
|
||||||
|
Ok(x) => x,
|
||||||
|
Err(error) => {
|
||||||
|
self.drag = None;
|
||||||
|
return log::error!("{error:#}");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let main = self.main;
|
||||||
|
let slid = main.update(cx, |_, main, _| {
|
||||||
|
let room = (f32::from(pane(main).size.width) - WIDTH).max(0.0);
|
||||||
|
// Whole points, so the kept place carries no float noise.
|
||||||
|
self.at = (at + x - from).round().clamp(0.0, room.floor());
|
||||||
|
self.place(main);
|
||||||
|
});
|
||||||
|
if let Err(error) = slid {
|
||||||
|
log::debug!("pet: the main window does not read: {error:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets go of her, the settings keeping where the drag left her, where a Wayland popup then opens again.
|
||||||
|
fn release(&mut self, cx: &mut App) {
|
||||||
|
if self.drag.take().is_none() || SettingsStore::get(cx).ely_left == self.at {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let at = self.at;
|
||||||
|
match SettingsStore::update(cx, |settings| settings.ely_left = at) {
|
||||||
|
Ok(()) => log::info!("pet: Ely stands {at} points in from the pane's left edge"),
|
||||||
|
Err(error) => log::error!("pet: where Ely stands is not kept: {error:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer's x in points: on the screen where her glue moves her window, else in her window, which a drag leaves in place.
|
||||||
|
fn pointer(&self, local: Pixels, scale: f32) -> anyhow::Result<f32> {
|
||||||
|
match &self.glue {
|
||||||
|
Some(glue) => glue.pointer(scale),
|
||||||
|
None => Ok(local.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn shown(&mut 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 this = cx.entity();
|
||||||
|
let drawn = canvas(
|
||||||
|
|_, _, _| {},
|
||||||
|
move |bounds, _, window, cx| {
|
||||||
|
let head = point(bounds.origin.x, bounds.origin.y + px(ROOM));
|
||||||
|
rig.paint(head, &pose, window, cx);
|
||||||
|
// Her window keeps the pointer while the button is held, inside it or not.
|
||||||
|
let held = this.clone();
|
||||||
|
window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
|
||||||
|
if phase == DispatchPhase::Bubble && event.dragging() {
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
held.update(cx, |pet, cx| pet.slide(event.position.x, scale, cx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
|
||||||
|
if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
|
||||||
|
this.update(cx, |pet, cx| pet.release(cx));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
div().size_full().child(drawn.size_full()).on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
cx.listener(|pet, event: &MouseDownEvent, window, cx| {
|
||||||
|
pet.press(Instant::now());
|
||||||
|
pet.grab(event.position.x, window.scale_factor());
|
||||||
|
cx.notify();
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
//! 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, from her mood's start or the last press, 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, which wakes her from a doze for `DOZE` more.
|
||||||
|
pub fn pose(mood: Mood, held: f64, shown: f64, petted: Option<f64>) -> Pose {
|
||||||
|
let stirred = petted.map_or(held, |since| held.min(since));
|
||||||
|
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 stirred >= DOZE => dozing(stirred - 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,226 @@
|
|||||||
|
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 a_press_wakes_her_from_her_doze_for_five_minutes_more() {
|
||||||
|
assert_eq!(at(Mood::Calm, 400.0).act, Act::Doze, "asleep, unpressed");
|
||||||
|
let pressed = |since: f64| pose(Mood::Calm, 400.0 + since, 410.0 + since, Some(since));
|
||||||
|
assert_eq!(pressed(0.5).act, Act::Wink);
|
||||||
|
assert_eq!(
|
||||||
|
(pressed(2.0).act, pressed(2.0).eyes),
|
||||||
|
(Act::Calm, Eyes::Open),
|
||||||
|
"awake after the wink"
|
||||||
|
);
|
||||||
|
assert_eq!(pressed(299.0).act, Act::Calm, "awake five minutes");
|
||||||
|
assert_eq!(pressed(301.0).act, Act::Doze, "then asleep again");
|
||||||
|
assert!(pressed(300.3).open[0] < 1.0, "her eyes fall shut again");
|
||||||
|
let finished = pose(Mood::Done, 400.0, 410.0, Some(2.0));
|
||||||
|
assert_eq!(finished.act, Act::Calm, "a finish long past wakes alike");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,106 @@
|
|||||||
|
//! Ely's window on macOS: a borderless panel, the main window's child at its level, so it moves with it and keeps its layer and Space.
|
||||||
|
|
||||||
|
use anyhow::bail;
|
||||||
|
use gpui::Window;
|
||||||
|
use objc::{
|
||||||
|
class, msg_send,
|
||||||
|
runtime::{NO, Object, YES},
|
||||||
|
sel, sel_impl,
|
||||||
|
};
|
||||||
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
|
||||||
|
use super::{Edge, edge, left};
|
||||||
|
|
||||||
|
/// AppKit's `NSWindowAbove`.
|
||||||
|
const ABOVE: isize = 1;
|
||||||
|
/// AppKit's `NSWindowStyleMaskNonactivatingPanel` alone: a panel without the titled frame gpui's popup carries.
|
||||||
|
const BORDERLESS_PANEL: usize = 1 << 7;
|
||||||
|
|
||||||
|
#[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 a borderless panel, 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 {
|
||||||
|
// A titled window gets a frame macOS draws and keeps under the menu bar.
|
||||||
|
let titled: usize = msg_send![pet, styleMask];
|
||||||
|
let _: () = msg_send![pet, setStyleMask: BORDERLESS_PANEL];
|
||||||
|
let mask: usize = msg_send![pet, styleMask];
|
||||||
|
anyhow::ensure!(
|
||||||
|
mask == BORDERLESS_PANEL,
|
||||||
|
"pet: her panel keeps style mask {mask:#x}"
|
||||||
|
);
|
||||||
|
log::info!(
|
||||||
|
"pet: her panel drops its titled frame, style mask {titled:#x} to {mask:#x}"
|
||||||
|
);
|
||||||
|
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 while her box fits under the top of its screen's visible frame, else on its bottom edge, `at` in as far as its width allows, and gives the edge.
|
||||||
|
pub fn place(&self, _: &Window, at: f32) -> anyhow::Result<Edge> {
|
||||||
|
unsafe {
|
||||||
|
let frame: Rect = msg_send![self.main, frame];
|
||||||
|
let screen: *mut Object = msg_send![self.main, screen];
|
||||||
|
anyhow::ensure!(!screen.is_null(), "pet: the main window is on no screen");
|
||||||
|
let visible: Rect = msg_send![screen, visibleFrame];
|
||||||
|
let top = frame.origin.y + frame.height;
|
||||||
|
let edge = edge((visible.origin.y + visible.height - top) as f32);
|
||||||
|
let at = Point {
|
||||||
|
x: frame.origin.x + f64::from(left(gpui::px(frame.width as f32), at)),
|
||||||
|
y: match edge {
|
||||||
|
Edge::Top => top,
|
||||||
|
Edge::Bottom => frame.origin.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let _: () = msg_send![self.pet, setFrameOrigin: at];
|
||||||
|
Ok(edge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer's x on the screen, in points.
|
||||||
|
pub fn pointer(&self, _: f32) -> anyhow::Result<f32> {
|
||||||
|
let at: Point = unsafe { msg_send![class!(NSEvent), mouseLocation] };
|
||||||
|
Ok(at.x as f32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,117 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use gpui::WindowId;
|
||||||
|
|
||||||
|
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,
|
||||||
|
edge: Edge::Top,
|
||||||
|
anchor: None,
|
||||||
|
at: 140.0,
|
||||||
|
drag: None,
|
||||||
|
main: WindowHandle::new(WindowId::from(1)),
|
||||||
|
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 a_press_on_her_while_she_dozes_wakes_her() {
|
||||||
|
let start = Instant::now();
|
||||||
|
let at = |secs: u64| start + Duration::from_secs(secs);
|
||||||
|
let mut pet = ely(Mood::Calm, start);
|
||||||
|
assert_eq!(pet.pose(at(400)).act, Act::Doze);
|
||||||
|
pet.press(at(400));
|
||||||
|
assert_eq!(pet.pose(at(402)).act, Act::Calm, "awake");
|
||||||
|
assert_eq!(
|
||||||
|
pet.pose(at(701)).act,
|
||||||
|
Act::Doze,
|
||||||
|
"five calm minutes on, asleep again"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn she_stands_on_the_top_edge_while_her_box_fits_above_it() {
|
||||||
|
assert_eq!(edge(HEAD + ROOM), Edge::Top, "just room for her");
|
||||||
|
assert_eq!(edge(400.0), Edge::Top);
|
||||||
|
assert_eq!(edge(HEAD + ROOM - 0.5), Edge::Bottom, "half a point short");
|
||||||
|
assert_eq!(edge(0.0), Edge::Bottom, "a window at the work area's top");
|
||||||
|
assert_eq!(edge(-30.0), Edge::Bottom, "a window above it");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn she_holds_the_edge_of_any_pane_where_a_drag_left_her() {
|
||||||
|
assert_eq!(left(px(1200.), 140.0), 140.0);
|
||||||
|
assert_eq!(left(px(1200.), 900.0), 900.0, "dragged right");
|
||||||
|
assert_eq!(left(px(1200.), 0.0), 0.0, "dragged to its left end");
|
||||||
|
assert_eq!(left(px(600.), 900.0), 600.0 - WIDTH, "a narrower pane");
|
||||||
|
assert_eq!(left(px(200.), 140.0), 200.0 - WIDTH);
|
||||||
|
assert_eq!(
|
||||||
|
left(px(100.), 140.0),
|
||||||
|
0.0,
|
||||||
|
"narrower than she is, she stands at its left"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
//! 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 onto an edge of its pane 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, POINT, RECT, WPARAM},
|
||||||
|
Graphics::Gdi::{
|
||||||
|
GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, MONITORINFO, MonitorFromRect, MonitorFromWindow,
|
||||||
|
},
|
||||||
|
UI::{
|
||||||
|
HiDpi::{GetDpiForMonitor, GetDpiForWindow, MDT_EFFECTIVE_DPI},
|
||||||
|
WindowsAndMessaging::{
|
||||||
|
CallWindowProcW, GWL_STYLE, GWLP_HWNDPARENT, GWLP_WNDPROC, GetCursorPos, 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::{Edge, HEAD, ROOM, WIDTH, edge, 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 while her box fits under the top of its display's work area, else on its bottom edge, `at` in as far as its width allows, and gives the edge.
|
||||||
|
pub fn place(&self, main: &Window, at: f32) -> anyhow::Result<Edge> {
|
||||||
|
self.put(main.scale_factor(), pane(main), at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer's x on the screen, in points at `scale`.
|
||||||
|
pub fn pointer(&self, scale: f32) -> anyhow::Result<f32> {
|
||||||
|
let mut at = POINT::default();
|
||||||
|
let read = unsafe { GetCursorPos(&mut at) };
|
||||||
|
anyhow::ensure!(read != 0, "pet: the pointer's place does not read");
|
||||||
|
Ok(at.x as f32 / scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Posts her bottom left corner on an edge of `pane` at `scale`, `at` points in, 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>, at: f32) -> anyhow::Result<Edge> {
|
||||||
|
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 mut display = MONITORINFO {
|
||||||
|
cbSize: size_of::<MONITORINFO>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let monitor = unsafe { MonitorFromWindow(self.main, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
let read = unsafe { GetMonitorInfoW(monitor, &mut display) };
|
||||||
|
anyhow::ensure!(read != 0, "pet: the main window's display does not read");
|
||||||
|
let (_, top) = stand(main.rcClient, scale, pane, Edge::Top, at);
|
||||||
|
let edge = edge((top - display.rcWork.top) as f32 / scale);
|
||||||
|
let corner = stand(main.rcClient, scale, pane, edge, at);
|
||||||
|
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:?}, the pane's {edge:?} edge");
|
||||||
|
Ok(edge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Her bottom left corner on `edge` of `pane`, `at` points in, at `scale` over the main window's `client` rect, in pixels.
|
||||||
|
fn stand(client: RECT, scale: f32, pane: Bounds<Pixels>, edge: Edge, at: f32) -> (i32, i32) {
|
||||||
|
let y = match edge {
|
||||||
|
Edge::Top => pane.origin.y,
|
||||||
|
Edge::Bottom => pane.bottom(),
|
||||||
|
};
|
||||||
|
let x = f32::from(pane.origin.x) + left(pane.size.width, at);
|
||||||
|
(
|
||||||
|
client.left + (x * scale).round() as i32,
|
||||||
|
client.top + (f32::from(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,350 @@
|
|||||||
|
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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Moves `window` to `(x, y)`, 800 by 600.
|
||||||
|
fn move_main(window: HWND, (x, y): (i32, i32)) {
|
||||||
|
let flags = SWP_NOZORDER | SWP_NOACTIVATE;
|
||||||
|
let moved = unsafe { SetWindowPos(window, std::ptr::null_mut(), x, y, 800, 600, flags) };
|
||||||
|
assert_ne!(moved, 0, "the main window moves");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The work area of the display under most of `window`.
|
||||||
|
fn work_area(window: HWND) -> RECT {
|
||||||
|
let mut display = MONITORINFO {
|
||||||
|
cbSize: size_of::<MONITORINFO>() as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let monitor = unsafe { MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST) };
|
||||||
|
assert_ne!(
|
||||||
|
unsafe { GetMonitorInfoW(monitor, &mut display) },
|
||||||
|
0,
|
||||||
|
"its display"
|
||||||
|
);
|
||||||
|
display.rcWork
|
||||||
|
}
|
||||||
|
|
||||||
|
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_either_edge_of_the_pane_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)] {
|
||||||
|
for (edge, y) in [(Edge::Top, 8.0), (Edge::Bottom, 608.0)] {
|
||||||
|
let her = boxed(stand(client, scale, pane, edge, 140.0), 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.), 140.0)) * scale).round() as i32,
|
||||||
|
20 + (y * 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, on the {edge:?} edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_an_edge_of_the_pane() {
|
||||||
|
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 work = work_area(main);
|
||||||
|
move_main(main, (work.left + 200, work.top + 400));
|
||||||
|
let pane = Bounds::new(point(px(0.), px(0.)), size(px(380.), px(280.)));
|
||||||
|
let roomy = glue.put(1.0, pane, 140.0).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);
|
||||||
|
// At the work area's top the main window leaves her no room above it.
|
||||||
|
move_main(main, (work.left + 200, work.top));
|
||||||
|
let cramped = glue.put(1.0, pane, 140.0).expect("placed again");
|
||||||
|
deliver(pet);
|
||||||
|
let (inside, low) = (info(pet).rcWindow, info(main).rcClient);
|
||||||
|
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.), 140.0).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"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(roomy, cramped, inside.left, inside.bottom),
|
||||||
|
(Edge::Top, Edge::Bottom, low.left + 140, low.top + 280),
|
||||||
|
"with no room above, inside on the pane's bottom edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
+347
@@ -0,0 +1,347 @@
|
|||||||
|
//! Ely's window on X11: gpui's popup, which no window manager places, put on an edge of the main window's pane and just above it.
|
||||||
|
|
||||||
|
use anyhow::{Context as _, bail};
|
||||||
|
use gpui::{Bounds, Pixels, Window, px};
|
||||||
|
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||||
|
use x11rb::{
|
||||||
|
protocol::{
|
||||||
|
randr::ConnectionExt as _,
|
||||||
|
xproto::{Atom, AtomEnum, ConfigureWindowAux, ConnectionExt as _, StackMode},
|
||||||
|
},
|
||||||
|
rust_connection::RustConnection,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Edge, HEAD, ROOM, edge, left, pane};
|
||||||
|
|
||||||
|
/// Her X window, the main one, a connection of her own to their server, and the atom of the work area its window manager names.
|
||||||
|
pub struct Glue {
|
||||||
|
connection: RustConnection,
|
||||||
|
pet: u32,
|
||||||
|
main: u32,
|
||||||
|
workarea: Atom,
|
||||||
|
}
|
||||||
|
|
||||||
|
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")?;
|
||||||
|
let workarea = (connection.intern_atom(false, b"_NET_WORKAREA"))?
|
||||||
|
.reply()
|
||||||
|
.context("pet: the work area's atom")?
|
||||||
|
.atom;
|
||||||
|
let glue = Self {
|
||||||
|
connection,
|
||||||
|
pet,
|
||||||
|
main,
|
||||||
|
workarea,
|
||||||
|
};
|
||||||
|
let (root, _) = glue.frame()?;
|
||||||
|
let monitors = glue.monitors(root)?.len();
|
||||||
|
match glue.work_top(root)? {
|
||||||
|
Some(top) => {
|
||||||
|
log::info!("pet: {monitors} monitors; the work area starts {top} pixels down")
|
||||||
|
}
|
||||||
|
None => log::info!(
|
||||||
|
"pet: {monitors} monitors; the window manager names no work area, so each monitor's top bounds her"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
Ok(glue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stands her on the main window's pane, on its top edge while her box fits under the top of the monitor holding most of the pane, or of the work area where that lies lower, else on its bottom edge, `at` in as far as its width allows, stacked just above the main window's frame, and gives the edge.
|
||||||
|
pub fn place(&self, main: &Window, at: f32) -> anyhow::Result<Edge> {
|
||||||
|
self.put(main.scale_factor(), pane(main), at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pointer's x on the screen, in points at `scale`.
|
||||||
|
pub fn pointer(&self, scale: f32) -> anyhow::Result<f32> {
|
||||||
|
let reply = (self.connection)
|
||||||
|
.query_pointer(self.pet)?
|
||||||
|
.reply()
|
||||||
|
.context("pet: the pointer's place")?;
|
||||||
|
Ok(f32::from(reply.root_x) / scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Places her at `scale` on an edge of `pane`, in the main window's points.
|
||||||
|
fn put(&self, scale: f32, pane: Bounds<Pixels>, at: f32) -> anyhow::Result<Edge> {
|
||||||
|
let (root, frame) = self.frame()?;
|
||||||
|
let origin = (self.connection)
|
||||||
|
.translate_coordinates(self.main, root, 0, 0)?
|
||||||
|
.reply()
|
||||||
|
.context("pet: the main window's place")?;
|
||||||
|
let down = |y: Pixels| i32::from(origin.dst_y) + (f32::from(y) * scale).round() as i32;
|
||||||
|
let across = |x: Pixels| i32::from(origin.dst_x) + (f32::from(x) * scale).round() as i32;
|
||||||
|
let (west, top) = (across(pane.origin.x), down(pane.origin.y));
|
||||||
|
let rect = [
|
||||||
|
west,
|
||||||
|
top,
|
||||||
|
across(pane.right()) - west,
|
||||||
|
down(pane.bottom()) - top,
|
||||||
|
];
|
||||||
|
let usable = usable(&self.monitors(root)?, self.work_top(root)?, rect);
|
||||||
|
let edge = edge(usable.map_or(0.0, |usable| (top - usable) as f32 / scale));
|
||||||
|
let bottom = down(match edge {
|
||||||
|
Edge::Top => pane.origin.y,
|
||||||
|
Edge::Bottom => pane.bottom(),
|
||||||
|
});
|
||||||
|
let aux = ConfigureWindowAux::new()
|
||||||
|
.x(across(pane.origin.x + px(left(pane.size.width, at))))
|
||||||
|
.y(bottom - ((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")?;
|
||||||
|
Ok(edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Each monitor's x, y, width and height on the root, as RandR names them.
|
||||||
|
fn monitors(&self, root: u32) -> anyhow::Result<Vec<[i32; 4]>> {
|
||||||
|
let reply = (self.connection)
|
||||||
|
.randr_get_monitors(root, true)?
|
||||||
|
.reply()
|
||||||
|
.context("pet: the monitors")?;
|
||||||
|
let monitors = reply.monitors.iter();
|
||||||
|
Ok(monitors
|
||||||
|
.map(|m| [m.x.into(), m.y.into(), m.width.into(), m.height.into()])
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The top of the work area the window manager names in `_NET_WORKAREA`, its first desktop's, which managers keep alike; none where it names none.
|
||||||
|
fn work_top(&self, root: u32) -> anyhow::Result<Option<i32>> {
|
||||||
|
let reply = (self.connection)
|
||||||
|
.get_property(false, root, self.workarea, AtomEnum::CARDINAL, 0, 4)?
|
||||||
|
.reply()
|
||||||
|
.context("pet: the work area")?;
|
||||||
|
Ok(reply
|
||||||
|
.value32()
|
||||||
|
.and_then(|mut area| area.nth(1))
|
||||||
|
.map(|top| top as i32))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 top of the room over a pane at `[x, y, width, height]`: the top of the monitor holding most of it, as Windows and macOS pick a window's display, or the work area's top `area` where that lies lower; none off every monitor.
|
||||||
|
fn usable(monitors: &[[i32; 4]], area: Option<i32>, pane: [i32; 4]) -> Option<i32> {
|
||||||
|
let shared = |monitor: &&[i32; 4]| {
|
||||||
|
let overlap = |i: usize| {
|
||||||
|
let end = (monitor[i] + monitor[i + 2]).min(pane[i] + pane[i + 2]);
|
||||||
|
i64::from((end - monitor[i].max(pane[i])).max(0))
|
||||||
|
};
|
||||||
|
overlap(0) * overlap(1)
|
||||||
|
};
|
||||||
|
let [_, top, _, _] = (monitors.iter())
|
||||||
|
.filter(|monitor| shared(monitor) > 0)
|
||||||
|
.max_by_key(shared)?;
|
||||||
|
Some(area.map_or(*top, |area| area.max(*top)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, PropMode, WindowClass},
|
||||||
|
wrapper::ConnectionExt as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
use gpui::{point, px, size};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::testx11::Xvfb;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_room_above_runs_to_the_top_of_the_monitor_holding_most_of_the_pane_or_of_the_work_area()
|
||||||
|
{
|
||||||
|
let stacked = [[0, 0, 1280, 800], [0, 800, 1280, 800]];
|
||||||
|
let low = [100, 800, 900, 600];
|
||||||
|
assert_eq!(
|
||||||
|
usable(&stacked, None, low),
|
||||||
|
Some(800),
|
||||||
|
"the lower one's top"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usable(&stacked, Some(0), low),
|
||||||
|
Some(800),
|
||||||
|
"a work area over both monitors leaves the lower one's top"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usable(&stacked, Some(32), [100, 300, 900, 400]),
|
||||||
|
Some(32),
|
||||||
|
"a panel atop the upper one"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usable(&stacked, None, [100, 500, 900, 400]),
|
||||||
|
Some(0),
|
||||||
|
"across both, the upper one holds most"
|
||||||
|
);
|
||||||
|
let staggered = [[0, 0, 1280, 1024], [1280, 400, 1280, 800]];
|
||||||
|
assert_eq!(
|
||||||
|
usable(&staggered, Some(0), [1100, 400, 1000, 600]),
|
||||||
|
Some(400),
|
||||||
|
"the right one holds most, though she stands over the left"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
usable(&stacked, None, [2000, 300, 900, 400]),
|
||||||
|
None,
|
||||||
|
"off every monitor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn she_stands_on_an_edge_of_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, 400, 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 workarea = (hers.intern_atom(false, b"_NET_WORKAREA"))
|
||||||
|
.expect("the request")
|
||||||
|
.reply()
|
||||||
|
.expect("the atom")
|
||||||
|
.atom;
|
||||||
|
let glue = Glue {
|
||||||
|
connection: hers,
|
||||||
|
pet,
|
||||||
|
main,
|
||||||
|
workarea,
|
||||||
|
};
|
||||||
|
let pane = Bounds::new(point(px(10.), px(10.)), size(px(380.), px(280.)));
|
||||||
|
let roomy = glue.put(2.0, pane, 140.0).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 tall = ((HEAD + ROOM) * 2.0).round() as i32;
|
||||||
|
let stands = (
|
||||||
|
100 + ((10.0 + left(px(380.), 140.0)) * 2.0).round() as i32,
|
||||||
|
440 - tall,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
(roomy, i32::from(at.x), i32::from(at.y)),
|
||||||
|
(Edge::Top, stands.0, stands.1)
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
// A panel reserves the screen's top 300 pixels, so the pane's top lies 70 points under it.
|
||||||
|
let area = [0, 300, 1024, 724];
|
||||||
|
(check.change_property32(PropMode::REPLACE, root, workarea, AtomEnum::CARDINAL, &area))
|
||||||
|
.expect("the request")
|
||||||
|
.check()
|
||||||
|
.expect("a work area");
|
||||||
|
let cramped = glue.put(2.0, pane, 140.0).expect("she is placed again");
|
||||||
|
let low = check
|
||||||
|
.get_geometry(pet)
|
||||||
|
.expect("the request")
|
||||||
|
.reply()
|
||||||
|
.expect("her place");
|
||||||
|
let inside = 420 + ((10.0 + 280.0) * 2.0_f32).round() as i32 - tall;
|
||||||
|
assert_eq!(
|
||||||
|
(cramped, i32::from(low.x), i32::from(low.y)),
|
||||||
|
(Edge::Bottom, stands.0, inside),
|
||||||
|
"with no room above, inside on the pane's bottom edge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-3
@@ -39,11 +39,16 @@ pub struct Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
/// Notes the look its mark takes `now`; true while the mark moves.
|
/// The look its mark takes from its terminal now.
|
||||||
pub fn note(&mut self, now: Instant, cx: &App) -> bool {
|
pub fn looks(&self, cx: &App) -> Option<Look> {
|
||||||
let view = self.terminal.read(cx);
|
let view = self.terminal.read(cx);
|
||||||
let failed = view.ended().is_some_and(|code| code != 0);
|
let failed = view.ended().is_some_and(|code| code != 0);
|
||||||
let shows = look(view.records().shown().map(|(_, record)| record), failed);
|
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 {
|
if self.look.map(|(shown, _)| shown) != shows {
|
||||||
self.look = shows.map(|shows| (shows, now));
|
self.look = shows.map(|shows| (shows, now));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ pub struct Settings {
|
|||||||
pub scrollback: u32,
|
pub scrollback: u32,
|
||||||
pub option_as_alt: OptionAsAlt,
|
pub option_as_alt: OptionAsAlt,
|
||||||
pub copy_on_select: bool,
|
pub copy_on_select: bool,
|
||||||
|
/// Ely peeks over the main window's top edge.
|
||||||
|
pub ely: bool,
|
||||||
|
/// How far in from the pane's left edge she stands, in points, as a drag left her.
|
||||||
|
pub ely_left: f32,
|
||||||
|
/// A check for a newer release at start and once a day.
|
||||||
|
pub check_updates: bool,
|
||||||
pub show_sessions: bool,
|
pub show_sessions: bool,
|
||||||
pub show_panel: bool,
|
pub show_panel: bool,
|
||||||
pub panel: PanelTab,
|
pub panel: PanelTab,
|
||||||
@@ -103,6 +109,9 @@ impl Default for Settings {
|
|||||||
scrollback: 10_000,
|
scrollback: 10_000,
|
||||||
option_as_alt: OptionAsAlt::Off,
|
option_as_alt: OptionAsAlt::Off,
|
||||||
copy_on_select: false,
|
copy_on_select: false,
|
||||||
|
ely: true,
|
||||||
|
ely_left: 140.0,
|
||||||
|
check_updates: true,
|
||||||
show_sessions: true,
|
show_sessions: true,
|
||||||
show_panel: true,
|
show_panel: true,
|
||||||
panel: PanelTab::Git,
|
panel: PanelTab::Git,
|
||||||
@@ -121,6 +130,7 @@ pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
|||||||
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||||
pub const COLUMN: RangeInclusive<f32> = 160.0..=960.0;
|
pub const COLUMN: RangeInclusive<f32> = 160.0..=960.0;
|
||||||
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
||||||
|
pub const ELY_LEFT: RangeInclusive<f32> = 0.0..=16384.0;
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
/// Every value inside its range, every theme built in; the error names the field.
|
/// Every value inside its range, every theme built in; the error names the field.
|
||||||
@@ -135,6 +145,7 @@ impl Settings {
|
|||||||
within("panel_width", self.panel_width, &COLUMN)?;
|
within("panel_width", self.panel_width, &COLUMN)?;
|
||||||
within("window_width", self.window_width, &WINDOW)?;
|
within("window_width", self.window_width, &WINDOW)?;
|
||||||
within("window_height", self.window_height, &WINDOW)?;
|
within("window_height", self.window_height, &WINDOW)?;
|
||||||
|
within("ely_left", self.ely_left, &ELY_LEFT)?;
|
||||||
for (field, id) in [
|
for (field, id) in [
|
||||||
("theme", Some(&self.theme)),
|
("theme", Some(&self.theme)),
|
||||||
("dark_theme", self.dark_theme.as_ref()),
|
("dark_theme", self.dark_theme.as_ref()),
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{Settings, SettingsStore};
|
use super::{Settings, SettingsStore};
|
||||||
use crate::{app::APP_ID, menus, term::BLINK};
|
use crate::{app::APP_ID, menus, term::BLINK, updates::Updates};
|
||||||
|
|
||||||
mod appearance;
|
mod appearance;
|
||||||
mod terminal;
|
mod terminal;
|
||||||
@@ -100,6 +100,7 @@ pub struct SettingsWindow {
|
|||||||
lit: bool,
|
lit: bool,
|
||||||
_typed: Subscription,
|
_typed: Subscription,
|
||||||
_settings: Subscription,
|
_settings: Subscription,
|
||||||
|
_updates: Subscription,
|
||||||
_blink: Task<()>,
|
_blink: Task<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ impl SettingsWindow {
|
|||||||
lit: true,
|
lit: true,
|
||||||
_typed: typed,
|
_typed: typed,
|
||||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||||
|
_updates: cx.observe_global::<Updates>(|_, cx| cx.notify()),
|
||||||
_blink: Self::blink(cx),
|
_blink: Self::blink(cx),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use ely_gpui_component::{
|
use ely_gpui_component::{
|
||||||
buttons::SegmentedControl,
|
buttons::{Button, SegmentedControl},
|
||||||
feedback::InlineMessage,
|
feedback::InlineMessage,
|
||||||
forms::{Copy, NumberInput, Paste, PathInput, SelectAll},
|
forms::{Copy, NumberInput, Paste, PathInput, SelectAll, Switch},
|
||||||
primitives::Severity,
|
primitives::Severity,
|
||||||
settings::{KeyboardShortcutsList, SettingsRow, SettingsSection, Shortcut},
|
settings::{KeyboardShortcutsList, SettingsRow, SettingsSection, Shortcut},
|
||||||
theme::{ActiveTheme, ControlSize},
|
theme::{ActiveTheme, ControlSize},
|
||||||
@@ -15,6 +15,7 @@ use crate::{
|
|||||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||||
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||||
|
updates::{self, Updates, VERSION},
|
||||||
workspace::{
|
workspace::{
|
||||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||||
TogglePanel, ToggleSessions,
|
TogglePanel, ToggleSessions,
|
||||||
@@ -42,7 +43,7 @@ const PLACES: [&str; 8] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
impl SettingsWindow {
|
impl SettingsWindow {
|
||||||
/// Shell, start in, scrollback, Option as Alt on macOS and copy on select, then the shortcuts.
|
/// Shell, start in, scrollback, Option as Alt on macOS, copy on select, Ely and updates, then the shortcuts.
|
||||||
pub(super) fn terminal(&self, cx: &Context<Self>) -> Div {
|
pub(super) fn terminal(&self, cx: &Context<Self>) -> Div {
|
||||||
let settings = SettingsStore::get(cx);
|
let settings = SettingsStore::get(cx);
|
||||||
let this = cx.entity();
|
let this = cx.entity();
|
||||||
@@ -99,13 +100,77 @@ impl SettingsWindow {
|
|||||||
settings.copy_on_select,
|
settings.copy_on_select,
|
||||||
|settings, on| settings.copy_on_select = on,
|
|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),
|
||||||
|
)
|
||||||
|
.row(
|
||||||
|
SettingsRow::new("Check for updates automatically")
|
||||||
|
.description("At start and once a day")
|
||||||
|
.control(auto_check(&this, settings.check_updates)),
|
||||||
|
)
|
||||||
|
.row(updates_row(cx));
|
||||||
let keys = SettingsSection::new("Shortcuts")
|
let keys = SettingsSection::new("Shortcuts")
|
||||||
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
||||||
div().flex().flex_col().gap_6().child(section).child(keys)
|
div().flex().flex_col().gap_6().child(section).child(keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The switch for automatic checks; turning it on checks at once.
|
||||||
|
fn auto_check(this: &Entity<SettingsWindow>, on: bool) -> Switch {
|
||||||
|
let this = this.clone();
|
||||||
|
Switch::new("check_updates", on).on_change(move |on, _, cx| {
|
||||||
|
let change = format!("check_updates is {on}");
|
||||||
|
if set(&this, change, cx, |settings| settings.check_updates = on) && on {
|
||||||
|
updates::check(false, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the last check found, a button that checks now, and one that opens a newer release's page.
|
||||||
|
fn updates_row(cx: &App) -> SettingsRow {
|
||||||
|
let found = cx.global::<Updates>();
|
||||||
|
let status: SharedString = match found {
|
||||||
|
Updates::Unchecked => format!("ELYTerminal {VERSION}").into(),
|
||||||
|
Updates::Checking => "Checking for a newer release…".into(),
|
||||||
|
Updates::Latest => format!("ELYTerminal {VERSION} is the latest").into(),
|
||||||
|
Updates::Newer { version, .. } => {
|
||||||
|
format!("ELYTerminal {version} is out; this is {VERSION}").into()
|
||||||
|
}
|
||||||
|
Updates::Failed(why) => format!("The check failed: {why}").into(),
|
||||||
|
};
|
||||||
|
let page = match found {
|
||||||
|
Updates::Newer { page, .. } => Some(page.clone()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let check = Button::new("check-updates", "Check now")
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.loading(*found == Updates::Checking)
|
||||||
|
.on_click(|_, _, cx| updates::check(false, cx));
|
||||||
|
let open = page.map(|page| {
|
||||||
|
Button::new("open-update", "Download")
|
||||||
|
.primary()
|
||||||
|
.size(ControlSize::Sm)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
log::info!("updates: Settings opens {page}");
|
||||||
|
cx.open_url(&page);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let control = div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.children(open)
|
||||||
|
.child(check);
|
||||||
|
SettingsRow::new("Updates")
|
||||||
|
.description(status)
|
||||||
|
.control(control)
|
||||||
|
}
|
||||||
|
|
||||||
/// Where new sessions start, as the settings keep it.
|
/// Where new sessions start, as the settings keep it.
|
||||||
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
||||||
let start = match value.as_ref() {
|
let start = match value.as_ref() {
|
||||||
|
|||||||
+9
-1
@@ -76,7 +76,11 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
|||||||
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
"terminal {pid}: herdr left the foreground, its agents' records with it"
|
||||||
);
|
);
|
||||||
states.clear();
|
states.clear();
|
||||||
if view.update(cx, |view, cx| view.agents(&[], cx)).is_err() {
|
let left = view.update(cx, |view, cx| {
|
||||||
|
view.herdr = false;
|
||||||
|
view.agents(&[], cx);
|
||||||
|
});
|
||||||
|
if left.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,6 +105,10 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
|
|||||||
socket.display()
|
socket.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let runs = socket.is_some();
|
||||||
|
if view.update(cx, |view, _| view.herdr = runs).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let Some(path) = socket.clone() else {
|
let Some(path) = socket.clone() else {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+111
-17
@@ -92,37 +92,44 @@ impl TerminalView {
|
|||||||
if event.prefer_character_input {
|
if event.prefer_character_input {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.held.insert(event.keystroke.key.clone());
|
let facts = self.facts(&event.keystroke);
|
||||||
let facts = self.facts();
|
|
||||||
let action = if event.is_held || facts.repeat {
|
let action = if event.is_held || facts.repeat {
|
||||||
Action::Repeat
|
Action::Repeat
|
||||||
} else {
|
} else {
|
||||||
Action::Press
|
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();
|
cx.stop_propagation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A release goes out only for a press this terminal saw: none for one a binding took.
|
/// 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>) {
|
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;
|
return;
|
||||||
}
|
};
|
||||||
let facts = self.facts();
|
if self
|
||||||
if self.send(&event.keystroke, Action::Release, facts, cx) {
|
.send(&event.keystroke, Action::Release, facts, record, cx)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
cx.stop_propagation();
|
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(
|
fn send(
|
||||||
&mut self,
|
&mut self,
|
||||||
keystroke: &Keystroke,
|
keystroke: &Keystroke,
|
||||||
action: Action,
|
action: Action,
|
||||||
facts: Facts,
|
facts: Facts,
|
||||||
|
pressed: bool,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> bool {
|
) -> Option<bool> {
|
||||||
let modifiers = keystroke.modifiers;
|
let modifiers = keystroke.modifiers;
|
||||||
let alt = modifiers.alt && Self::alt_is_meta(facts, cx);
|
let alt = modifiers.alt && Self::alt_is_meta(facts, cx);
|
||||||
let text = match &keystroke.key_char {
|
let text = match &keystroke.key_char {
|
||||||
@@ -138,6 +145,7 @@ impl TerminalView {
|
|||||||
alt,
|
alt,
|
||||||
platform: modifiers.platform,
|
platform: modifiers.platform,
|
||||||
action,
|
action,
|
||||||
|
unshifted: facts.unshifted,
|
||||||
};
|
};
|
||||||
let mode = *self.pty.term.lock().mode();
|
let mode = *self.pty.term.lock().mode();
|
||||||
let kitty = Kitty {
|
let kitty = Kitty {
|
||||||
@@ -147,29 +155,62 @@ impl TerminalView {
|
|||||||
all: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC),
|
all: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC),
|
||||||
text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT),
|
text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT),
|
||||||
};
|
};
|
||||||
let Some(bytes) = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty) else {
|
#[cfg(windows)]
|
||||||
return false;
|
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);
|
log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid);
|
||||||
self.input(bytes, cx);
|
self.input(bytes, cx);
|
||||||
true
|
Some(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// AppKit's facts of the key; logged and none when unreadable.
|
/// AppKit's facts of the key; logged and none when unreadable.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn facts(&self) -> Facts {
|
fn facts(&self, keystroke: &Keystroke) -> Facts {
|
||||||
super::mac::key_facts().unwrap_or_else(|error| {
|
super::mac::key_facts(&keystroke.key, keystroke.modifiers.shift).unwrap_or_else(|error| {
|
||||||
log::error!("terminal {}: {error:#}", self.pty.pid);
|
log::error!("terminal {}: {error:#}", self.pty.pid);
|
||||||
Facts {
|
Facts {
|
||||||
left_option: false,
|
left_option: false,
|
||||||
right_option: false,
|
right_option: false,
|
||||||
repeat: false,
|
repeat: false,
|
||||||
|
unshifted: None,
|
||||||
|
code: None,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
/// Windows' layout names the key gpui named by what Shift made of it.
|
||||||
fn facts(&self) -> Facts {
|
#[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()
|
Facts::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +240,33 @@ type Facts = super::mac::KeyFacts;
|
|||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
struct Facts {
|
struct Facts {
|
||||||
repeat: bool,
|
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 {
|
impl EntityInputHandler for TerminalView {
|
||||||
@@ -287,3 +355,29 @@ impl EntityInputHandler for TerminalView {
|
|||||||
None
|
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.
|
/// Cmd, the Windows key or Super: kitty's super.
|
||||||
pub platform: bool,
|
pub platform: bool,
|
||||||
pub action: Action,
|
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.
|
/// 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.
|
/// 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);
|
let control = key.text.chars().any(char::is_control);
|
||||||
!key.text.is_empty() && !control && !key.ctrl && !key.alt && !key.platform
|
!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 {
|
if typing && !kitty.all && key.action != Action::Release {
|
||||||
return None;
|
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
|
let shifted = key
|
||||||
.text
|
.text
|
||||||
.chars()
|
.chars()
|
||||||
.next()
|
.next()
|
||||||
.filter(|&typed| typing && typed != ch && !typed.is_control())
|
.filter(|&typed| typing && typed != own && !typed.is_control())
|
||||||
.or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase()))
|
.or(made)
|
||||||
.filter(|_| key.shift && kitty.alternates);
|
.or_else(|| (shift && own.is_ascii_lowercase()).then(|| own.to_ascii_uppercase()))
|
||||||
|
.filter(|_| shift && kitty.alternates);
|
||||||
let event = event(key, kitty);
|
let event = event(key, kitty);
|
||||||
let text = typing && kitty.text && key.action != Action::Release;
|
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 shifted.is_none() && event.is_none() && !text {
|
||||||
if mods == 1 && !kitty.all {
|
if mods == 1 && !kitty.all {
|
||||||
return None;
|
return None;
|
||||||
@@ -193,7 +201,7 @@ fn typed(key: &Key, ch: char, kitty: Kitty) -> Option<Vec<u8>> {
|
|||||||
return Some(bytes);
|
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 {
|
if let Some(shifted) = shifted {
|
||||||
out.push_str(&format!(":{}", shifted as u32));
|
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"),
|
alt: parts.contains(&"alt"),
|
||||||
platform: parts.contains(&"cmd"),
|
platform: parts.contains(&"cmd"),
|
||||||
action: Action::Press,
|
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]
|
#[test]
|
||||||
fn super_is_kittys_eighth_bit() {
|
fn super_is_kittys_eighth_bit() {
|
||||||
let events = Kitty {
|
let events = Kitty {
|
||||||
|
|||||||
+110
-9
@@ -1,3 +1,6 @@
|
|||||||
|
use std::ffi::c_void;
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
use block::ConcreteBlock;
|
use block::ConcreteBlock;
|
||||||
use objc::{
|
use objc::{
|
||||||
class, msg_send,
|
class, msg_send,
|
||||||
@@ -5,13 +8,43 @@ use objc::{
|
|||||||
sel, sel_impl,
|
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.
|
/// AppKit's event types for a key going down and coming up.
|
||||||
const KEY_DOWN: u64 = 10;
|
const KEY_DOWN: u64 = 10;
|
||||||
const KEY_UP: u64 = 11;
|
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;
|
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.
|
/// The device bits of the left and right Option keys in an event's modifier flags.
|
||||||
const LEFT_OPTION: u64 = 0x20;
|
const LEFT_OPTION: u64 = 0x20;
|
||||||
const RIGHT_OPTION: u64 = 0x40;
|
const RIGHT_OPTION: u64 = 0x40;
|
||||||
@@ -23,11 +56,15 @@ pub struct KeyFacts {
|
|||||||
pub right_option: bool,
|
pub right_option: bool,
|
||||||
/// Held down; gpui loses this for keys AppKit routes through commands.
|
/// Held down; gpui loses this for keys AppKit routes through commands.
|
||||||
pub repeat: bool,
|
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.
|
/// The facts of the key event AppKit is handling now, which gpui named `name`, with `shift` or not.
|
||||||
pub fn key_facts() -> anyhow::Result<KeyFacts> {
|
pub fn key_facts(name: &str, shift: bool) -> anyhow::Result<KeyFacts> {
|
||||||
unsafe {
|
let (flags, repeat, code) = unsafe {
|
||||||
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
let app: *mut Object = msg_send![class!(NSApplication), sharedApplication];
|
||||||
let event: *mut Object = msg_send![app, currentEvent];
|
let event: *mut Object = msg_send![app, currentEvent];
|
||||||
anyhow::ensure!(!event.is_null(), "macOS: no key event to read");
|
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 flags: u64 = msg_send![event, modifierFlags];
|
||||||
let repeat: BOOL = msg_send![event, isARepeat];
|
let repeat: BOOL = msg_send![event, isARepeat];
|
||||||
Ok(KeyFacts {
|
let code: u16 = msg_send![event, keyCode];
|
||||||
left_option: flags & LEFT_OPTION != 0,
|
(flags, repeat != NO, code)
|
||||||
right_option: flags & RIGHT_OPTION != 0,
|
};
|
||||||
repeat: repeat != NO,
|
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.
|
/// Turns off the accent menu a held key opens, so held keys repeat as in other terminals.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod pointer;
|
|||||||
mod prompts;
|
mod prompts;
|
||||||
mod pty;
|
mod pty;
|
||||||
mod reports;
|
mod reports;
|
||||||
|
mod scrollbar;
|
||||||
pub mod shell;
|
pub mod shell;
|
||||||
mod status;
|
mod status;
|
||||||
mod tap;
|
mod tap;
|
||||||
@@ -23,6 +24,8 @@ mod titles;
|
|||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
mod unix;
|
mod unix;
|
||||||
mod view;
|
mod view;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win32;
|
||||||
pub mod zoom;
|
pub mod zoom;
|
||||||
|
|
||||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||||
|
|||||||
+13
-3
@@ -80,13 +80,16 @@ impl TerminalView {
|
|||||||
modifiers: &Modifiers,
|
modifiers: &Modifiers,
|
||||||
tracking: Tracking,
|
tracking: Tracking,
|
||||||
) -> bool {
|
) -> 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 {
|
let report = Report {
|
||||||
button,
|
button,
|
||||||
gesture,
|
gesture,
|
||||||
cell,
|
cell,
|
||||||
shift: modifiers.shift,
|
shift: modifiers.shift,
|
||||||
alt: modifiers.alt,
|
alt: modifiers.alt,
|
||||||
ctrl: modifiers.control,
|
ctrl: modifiers.control || link,
|
||||||
};
|
};
|
||||||
let format = self.mouse.format;
|
let format = self.mouse.format;
|
||||||
let Some(bytes) = mouse::encode(&report, MouseMode { tracking, format }) else {
|
let Some(bytes) = mouse::encode(&report, MouseMode { tracking, format }) else {
|
||||||
@@ -111,7 +114,15 @@ impl TerminalView {
|
|||||||
} else {
|
} else {
|
||||||
event.modifiers.control
|
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);
|
let link = link_at(&self.pty.term.lock(), point);
|
||||||
if let Some(link) = link {
|
if let Some(link) = link {
|
||||||
log::info!("terminal {}: opens {link}", self.pty.pid);
|
log::info!("terminal {}: opens {link}", self.pty.pid);
|
||||||
@@ -119,7 +130,6 @@ impl TerminalView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let tracking = self.tracking(&event.modifiers);
|
|
||||||
if tracking != Tracking::Off {
|
if tracking != Tracking::Off {
|
||||||
let Some(pressed) = button(event.button) else {
|
let Some(pressed) = button(event.button) else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub enum PtyEvent {
|
|||||||
Title(String),
|
Title(String),
|
||||||
/// A full reset, `ESC c`.
|
/// A full reset, `ESC c`.
|
||||||
Reset,
|
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.
|
/// Hands the grid's events to the session; `pid` names the program in logs.
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! The terminal's scrollbar: a thumb over the history, in Ely's sizes and colors, that a drag moves through it, whatever the wheel is bound to.
|
||||||
|
|
||||||
|
use std::{cell::Cell, rc::Rc};
|
||||||
|
|
||||||
|
use alacritty_terminal::{
|
||||||
|
grid::{Dimensions, Scroll},
|
||||||
|
term::Term,
|
||||||
|
};
|
||||||
|
use ely_gpui_component::theme::ActiveTheme;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, AppContext as _, Context, DragMoveEvent, EmptyView, EntityId, InteractiveElement,
|
||||||
|
IntoElement, ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, div, relative,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::view::TerminalView;
|
||||||
|
|
||||||
|
/// A drag of the thumb: its view, how far down the history the view stood as it began, and the drag's first point.
|
||||||
|
pub(super) struct ThumbDrag {
|
||||||
|
owner: EntityId,
|
||||||
|
from: f32,
|
||||||
|
anchor: Rc<Cell<Option<Pixels>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the view stands in the history: the lines above the screen, the screen's lines, and how far up the view is.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub(super) struct Span {
|
||||||
|
history: usize,
|
||||||
|
screen: usize,
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Span {
|
||||||
|
pub(super) fn of<T>(term: &Term<T>) -> Self {
|
||||||
|
Self {
|
||||||
|
history: term.grid().history_size(),
|
||||||
|
screen: term.screen_lines(),
|
||||||
|
offset: term.grid().display_offset(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thumb's share of the track.
|
||||||
|
fn share(self) -> f32 {
|
||||||
|
self.screen as f32 / (self.history + self.screen) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far down the history the view stands: 0 at its top, 1 at the bottom.
|
||||||
|
fn ratio(self) -> f32 {
|
||||||
|
1.0 - self.offset as f32 / self.history as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far up the view stands with the thumb `ratio` down the track.
|
||||||
|
fn offset_at(self, ratio: f32) -> usize {
|
||||||
|
((1.0 - ratio.clamp(0.0, 1.0)) * self.history as f32).round() as usize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bar along the view's right edge, over the padding there; none without history, as on the alternate screen.
|
||||||
|
pub(super) fn bar(span: Span, window: &Window, cx: &Context<TerminalView>) -> Option<AnyElement> {
|
||||||
|
if span.history == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let theme = cx.theme();
|
||||||
|
let rem = window.rem_size();
|
||||||
|
let thickness = theme.scrollbar_thickness().to_pixels(rem);
|
||||||
|
let min_thumb = theme.scrollbar_min_thumb().to_pixels(rem);
|
||||||
|
let (idle, busy) = (theme.colors.fg.opacity(0.22), theme.colors.fg.opacity(0.42));
|
||||||
|
let ratio = span.ratio();
|
||||||
|
// The room above and below the thumb shares what it leaves as the view stands.
|
||||||
|
let (mut above, mut below) = (div(), div());
|
||||||
|
above.style().flex_grow = Some(ratio);
|
||||||
|
below.style().flex_grow = Some(1.0 - ratio);
|
||||||
|
let drag = ThumbDrag {
|
||||||
|
owner: cx.entity_id(),
|
||||||
|
from: ratio,
|
||||||
|
anchor: Rc::default(),
|
||||||
|
};
|
||||||
|
let thumb = div()
|
||||||
|
.id("thumb")
|
||||||
|
.w(thickness)
|
||||||
|
.h(relative(span.share()))
|
||||||
|
.min_h(min_thumb)
|
||||||
|
.flex_shrink_0()
|
||||||
|
.rounded_full()
|
||||||
|
.bg(idle)
|
||||||
|
.hover(|style| style.bg(busy))
|
||||||
|
.on_drag(drag, |_, _, _, cx| cx.new(|_| EmptyView));
|
||||||
|
let bar = div()
|
||||||
|
.id("scrollbar")
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.bottom_0()
|
||||||
|
.right_0()
|
||||||
|
.w(thickness * 2.0)
|
||||||
|
.pl(thickness * 0.5)
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.occlude()
|
||||||
|
.on_drag_move(cx.listener(TerminalView::drag_thumb))
|
||||||
|
.child(above)
|
||||||
|
.child(thumb)
|
||||||
|
.child(below);
|
||||||
|
Some(bar.into_any_element())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalView {
|
||||||
|
/// Scrolls the history after the thumb, along its track's bounds.
|
||||||
|
fn drag_thumb(
|
||||||
|
&mut self,
|
||||||
|
event: &DragMoveEvent<ThumbDrag>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let drag = event.drag(cx);
|
||||||
|
if drag.owner != cx.entity_id() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let min_thumb = cx
|
||||||
|
.theme()
|
||||||
|
.scrollbar_min_thumb()
|
||||||
|
.to_pixels(window.rem_size());
|
||||||
|
let mut term = self.pty.term.lock();
|
||||||
|
let span = Span::of(&term);
|
||||||
|
let height = event.bounds.size.height;
|
||||||
|
let travel = height - (height * span.share()).max(min_thumb).min(height);
|
||||||
|
if span.history == 0 || travel <= Pixels::ZERO {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let along = event.event.position.y - event.bounds.origin.y;
|
||||||
|
let anchor = drag.anchor.get().unwrap_or(along);
|
||||||
|
drag.anchor.set(Some(anchor));
|
||||||
|
let offset = span.offset_at(drag.from + (along - anchor) / travel);
|
||||||
|
let delta = offset as i32 - span.offset as i32;
|
||||||
|
if delta != 0 {
|
||||||
|
term.scroll_display(Scroll::Delta(delta));
|
||||||
|
drop(term);
|
||||||
|
log::trace!(
|
||||||
|
"terminal {}: the scrollbar shows {offset} lines up",
|
||||||
|
self.pty.pid
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_thumb_stands_where_the_view_stands_in_the_history() {
|
||||||
|
let span = |offset| Span {
|
||||||
|
history: 300,
|
||||||
|
screen: 100,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
assert_eq!(span(0).share(), 0.25, "a screen of four");
|
||||||
|
assert_eq!(span(0).ratio(), 1.0, "at the bottom");
|
||||||
|
assert_eq!(span(300).ratio(), 0.0, "at the top");
|
||||||
|
assert_eq!(span(150).ratio(), 0.5);
|
||||||
|
assert_eq!(
|
||||||
|
span(0).offset_at(0.0),
|
||||||
|
300,
|
||||||
|
"the thumb at the top shows the oldest lines"
|
||||||
|
);
|
||||||
|
assert_eq!(span(0).offset_at(1.0), 0);
|
||||||
|
assert_eq!(span(0).offset_at(0.5), 150);
|
||||||
|
assert_eq!(span(0).offset_at(-0.2), 300, "past the track's ends");
|
||||||
|
assert_eq!(span(0).offset_at(1.3), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-87
@@ -9,7 +9,6 @@ use polling::{Event, PollMode, Poller};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
marks,
|
marks,
|
||||||
mouse::MouseMode,
|
|
||||||
notify::{self, Kitty},
|
notify::{self, Kitty},
|
||||||
prompts::{self, Prompts},
|
prompts::{self, Prompts},
|
||||||
pty::PtyEvent,
|
pty::PtyEvent,
|
||||||
@@ -24,7 +23,7 @@ pub(super) const MOST: usize = 4096;
|
|||||||
pub(crate) struct Tapped<P> {
|
pub(crate) struct Tapped<P> {
|
||||||
pub inner: P,
|
pub inner: P,
|
||||||
scanner: Osc,
|
scanner: Osc,
|
||||||
mouse: MouseModes,
|
modes: Modes,
|
||||||
prompts: Prompts,
|
prompts: Prompts,
|
||||||
kitty: Kitty,
|
kitty: Kitty,
|
||||||
titles: Titles,
|
titles: Titles,
|
||||||
@@ -38,7 +37,7 @@ impl<P> Tapped<P> {
|
|||||||
Self {
|
Self {
|
||||||
inner,
|
inner,
|
||||||
scanner: Osc::default(),
|
scanner: Osc::default(),
|
||||||
mouse: MouseModes::default(),
|
modes: Modes::default(),
|
||||||
prompts: Prompts::default(),
|
prompts: Prompts::default(),
|
||||||
kitty: Kitty::default(),
|
kitty: Kitty::default(),
|
||||||
titles: Titles::default(),
|
titles: Titles::default(),
|
||||||
@@ -66,12 +65,24 @@ impl<P: EventedReadWrite> io::Read for Tapped<P> {
|
|||||||
self.scanner.feed(read, &mut |found| {
|
self.scanner.feed(read, &mut |found| {
|
||||||
report(found, events, pid, last, kitty, titles)
|
report(found, events, pid, last, kitty, titles)
|
||||||
});
|
});
|
||||||
if let Some(mode) = self.mouse.feed(read) {
|
#[cfg(windows)]
|
||||||
|
let win32 = self.modes.win32;
|
||||||
|
if let Some(mode) = self.modes.feed(read) {
|
||||||
log::info!("pty {pid}: mouse {mode:?}");
|
log::info!("pty {pid}: mouse {mode:?}");
|
||||||
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() {
|
||||||
log::debug!("pty {pid}: a mouse mode after its session closed");
|
log::debug!("pty {pid}: a mouse mode after its session closed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#[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]);
|
let out = self.prompts.pass(&buf[..count]);
|
||||||
buf[..out.len()].copy_from_slice(out);
|
buf[..out.len()].copy_from_slice(out);
|
||||||
Ok(out.len())
|
Ok(out.len())
|
||||||
@@ -338,89 +349,6 @@ impl Osc {
|
|||||||
/// The most values, subparameters too, one sequence holds before vte drops it.
|
/// The most values, subparameters too, one sequence holds before vte drops it.
|
||||||
const PARAMS: usize = 32;
|
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.
|
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
|
||||||
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
|
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
|
||||||
let Some(rest) = body.strip_prefix(b"file://") else {
|
let Some(rest) = body.strip_prefix(b"file://") else {
|
||||||
@@ -480,5 +408,8 @@ fn decode(bytes: &[u8]) -> Option<String> {
|
|||||||
String::from_utf8(out).ok()
|
String::from_utf8(out).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod modes;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-3
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::term::mouse::MouseMode;
|
||||||
|
|
||||||
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
|
/// What the scanner finds in `chunks`, each one read: a body, or `RESET`.
|
||||||
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
fn scan(chunks: &[&[u8]]) -> Vec<String> {
|
||||||
@@ -86,7 +87,7 @@ fn an_osc_99_body_holds_its_metadata_and_a_full_payload() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
||||||
use crate::term::mouse::{Format, Tracking};
|
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_eq!(modes.feed(b"a\x1b[?"), None);
|
||||||
assert!(modes.feed(b"1006;9h").is_some());
|
assert!(modes.feed(b"1006;9h").is_some());
|
||||||
assert_eq!(modes.mode.format, Format::Sgr);
|
assert_eq!(modes.mode.format, Format::Sgr);
|
||||||
@@ -106,7 +107,7 @@ fn mouse_modes_follow_private_modes_in_order_across_reads() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn mouse_modes_read_through_controls_as_the_parser_does() {
|
fn mouse_modes_read_through_controls_as_the_parser_does() {
|
||||||
use crate::term::mouse::{Format, Tracking};
|
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"\x1b[?1000\x00h\x1b[?1006");
|
||||||
modes.feed(b"\x00h");
|
modes.feed(b"\x00h");
|
||||||
let on = MouseMode {
|
let on = MouseMode {
|
||||||
@@ -220,7 +221,7 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() {
|
|||||||
.chain([values(31), values(32)])
|
.chain([values(31), values(32)])
|
||||||
{
|
{
|
||||||
let case = case.as_slice();
|
let case = case.as_slice();
|
||||||
let mut modes = MouseModes::default();
|
let mut modes = Modes::default();
|
||||||
for end in 1..=case.len() {
|
for end in 1..=case.len() {
|
||||||
modes.feed(&case[end - 1..end]);
|
modes.feed(&case[end - 1..end]);
|
||||||
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
|
assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}");
|
||||||
@@ -278,3 +279,15 @@ fn a_title_s_body_holds_65536_bytes_and_another_s_4096() {
|
|||||||
"another stays at 4096"
|
"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");
|
||||||
|
}
|
||||||
+26
-9
@@ -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::{
|
use alacritty_terminal::{
|
||||||
event::Event,
|
event::Event,
|
||||||
@@ -21,13 +21,14 @@ use super::{
|
|||||||
find::Finder,
|
find::Finder,
|
||||||
frame::frame,
|
frame::frame,
|
||||||
ink::Ink,
|
ink::Ink,
|
||||||
input::{self, Find, NextPrompt, PreviousPrompt},
|
input::{self, Find, Held, NextPrompt, PreviousPrompt},
|
||||||
marks::Mark,
|
marks::Mark,
|
||||||
mouse::MouseMode,
|
mouse::MouseMode,
|
||||||
paint::{self, Look},
|
paint::{self, Look},
|
||||||
pointer::Pointer,
|
pointer::Pointer,
|
||||||
pty::{Events, Pty, PtyEvent, config, fit},
|
pty::{Events, Pty, PtyEvent, config, fit},
|
||||||
reports::Notices,
|
reports::Notices,
|
||||||
|
scrollbar::{self, Span},
|
||||||
status::{Records, State},
|
status::{Records, State},
|
||||||
zoom,
|
zoom,
|
||||||
};
|
};
|
||||||
@@ -67,8 +68,13 @@ pub struct TerminalView {
|
|||||||
pub(super) pointer: Pointer,
|
pub(super) pointer: Pointer,
|
||||||
/// The mouse mode, as the tap follows it.
|
/// The mouse mode, as the tap follows it.
|
||||||
pub(super) mouse: MouseMode,
|
pub(super) mouse: MouseMode,
|
||||||
/// Keys whose press this terminal saw, so their releases may go out.
|
/// Keys go as Windows' records, as ConPTY asks.
|
||||||
pub(super) held: HashSet<String>,
|
#[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) finder: Option<Finder>,
|
||||||
pub(super) size: (usize, usize),
|
pub(super) size: (usize, usize),
|
||||||
/// How many folders the program has reported.
|
/// How many folders the program has reported.
|
||||||
@@ -130,7 +136,10 @@ impl TerminalView {
|
|||||||
caret: Bounds::default(),
|
caret: Bounds::default(),
|
||||||
pointer: Pointer::default(),
|
pointer: Pointer::default(),
|
||||||
mouse: MouseMode::default(),
|
mouse: MouseMode::default(),
|
||||||
held: HashSet::new(),
|
#[cfg(windows)]
|
||||||
|
win32: false,
|
||||||
|
herdr: false,
|
||||||
|
held: HashMap::new(),
|
||||||
finder: None,
|
finder: None,
|
||||||
size,
|
size,
|
||||||
reports: 0,
|
reports: 0,
|
||||||
@@ -260,6 +269,8 @@ impl TerminalView {
|
|||||||
}
|
}
|
||||||
PtyEvent::Term(event) => self.term_event(event, cx),
|
PtyEvent::Term(event) => self.term_event(event, cx),
|
||||||
PtyEvent::Mouse(mode) => self.mouse = mode,
|
PtyEvent::Mouse(mode) => self.mouse = mode,
|
||||||
|
#[cfg(windows)]
|
||||||
|
PtyEvent::Win32(on) => self.win32 = on,
|
||||||
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
|
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
|
||||||
// A new prompt ends what was working, waiting or idle; done and error stay.
|
// A new prompt ends what was working, waiting or idle; done and error stay.
|
||||||
PtyEvent::Mark(Mark::Prompt) => {
|
PtyEvent::Mark(Mark::Prompt) => {
|
||||||
@@ -403,6 +414,7 @@ impl Render for TerminalView {
|
|||||||
});
|
});
|
||||||
let shown = frame(&term, &ink, &lit);
|
let shown = frame(&term, &ink, &lit);
|
||||||
let blinks = term.cursor_style().blinking;
|
let blinks = term.cursor_style().blinking;
|
||||||
|
let span = Span::of(&term);
|
||||||
drop(term);
|
drop(term);
|
||||||
let focused = self.focus.is_focused(window) && window.is_window_active();
|
let focused = self.focus.is_focused(window) && window.is_window_active();
|
||||||
self.blinking = focused && blinks;
|
self.blinking = focused && blinks;
|
||||||
@@ -470,9 +482,14 @@ impl Render for TerminalView {
|
|||||||
root.text_color(ink.fg).child(format!("{error:#}"))
|
root.text_color(ink.fg).child(format!("{error:#}"))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
div().relative().size_full().child(root).children(
|
div()
|
||||||
self.find_box(cx)
|
.relative()
|
||||||
.map(|found| div().absolute().top_2().right_4().child(found)),
|
.size_full()
|
||||||
)
|
.child(root)
|
||||||
|
.children(scrollbar::bar(span, window, cx))
|
||||||
|
.children(
|
||||||
|
self.find_box(cx)
|
||||||
|
.map(|found| div().absolute().top_2().right_4().child(found)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
//! Checks for a newer release through curl and Gitea's API: at start and once a day while the settings ask, and whenever Settings asks at once.
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
process::{Command, Stdio},
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use gpui::{App, Global, SystemNotification};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{settings::SettingsStore, workspace::TITLE};
|
||||||
|
|
||||||
|
/// The latest published release.
|
||||||
|
const LATEST: &str =
|
||||||
|
"https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/ELYTerminal/releases/latest";
|
||||||
|
/// How long an automatic check waits for the next.
|
||||||
|
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
|
||||||
|
/// The tag a newer release's notification carries.
|
||||||
|
const TAG: &str = "elyterminal-update";
|
||||||
|
/// The running version.
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// What the last check found.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Updates {
|
||||||
|
Unchecked,
|
||||||
|
Checking,
|
||||||
|
Latest,
|
||||||
|
Newer { version: String, page: String },
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Global for Updates {}
|
||||||
|
|
||||||
|
/// The fields of a release the check reads.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Release {
|
||||||
|
tag_name: String,
|
||||||
|
html_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks at once and then once a day, each time the settings ask for it.
|
||||||
|
pub fn start(cx: &mut App) {
|
||||||
|
cx.set_global(Updates::Unchecked);
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
loop {
|
||||||
|
cx.update(|cx| {
|
||||||
|
if SettingsStore::get(cx).check_updates {
|
||||||
|
check(true, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cx.background_executor().timer(DAY).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for the latest release off the main thread; when `auto`, a newer one posts a notification.
|
||||||
|
pub fn check(auto: bool, cx: &mut App) {
|
||||||
|
if *cx.global::<Updates>() == Updates::Checking {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!("updates: checking for a release newer than {VERSION}");
|
||||||
|
cx.set_global(Updates::Checking);
|
||||||
|
let fetched = cx.background_executor().spawn(async { latest() });
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let found = fetched.await.and_then(newer);
|
||||||
|
cx.update(|cx| finish(found, auto, cx));
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps what the check found, and tells of a newer release it found by itself.
|
||||||
|
fn finish(found: anyhow::Result<Option<(String, String)>>, auto: bool, cx: &mut App) {
|
||||||
|
let found = match found {
|
||||||
|
Ok(Some((version, page))) => {
|
||||||
|
log::info!("updates: {version} is out, newer than {VERSION}, at {page}");
|
||||||
|
if auto {
|
||||||
|
cx.show_system_notification(SystemNotification {
|
||||||
|
tag: TAG.into(),
|
||||||
|
title: TITLE.into(),
|
||||||
|
body: format!("ELYTerminal {version} is out. Press to open its page.").into(),
|
||||||
|
actions: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Updates::Newer { version, page }
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
log::info!("updates: {VERSION} is the latest");
|
||||||
|
Updates::Latest
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("{error:#}");
|
||||||
|
Updates::Failed(format!("{error:#}"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cx.set_global(found);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the newer release's page when its notification is pressed; tells whether `tag` was that notification's.
|
||||||
|
pub fn pressed(tag: &str, cx: &mut App) -> bool {
|
||||||
|
if tag != TAG {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match cx.global::<Updates>().clone() {
|
||||||
|
Updates::Newer { page, .. } => {
|
||||||
|
log::info!("updates: the notification opens {page}");
|
||||||
|
cx.open_url(&page);
|
||||||
|
}
|
||||||
|
found => {
|
||||||
|
log::info!("updates: the notification was pressed, the last check finding {found:?}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The latest release, which curl fetches from Gitea's API.
|
||||||
|
fn latest() -> anyhow::Result<Release> {
|
||||||
|
let mut command = Command::new("curl");
|
||||||
|
#[cfg(windows)]
|
||||||
|
std::os::windows::process::CommandExt::creation_flags(
|
||||||
|
&mut command,
|
||||||
|
crate::git::CREATE_NO_WINDOW,
|
||||||
|
);
|
||||||
|
let output = command
|
||||||
|
.args(["--fail", "--silent", "--show-error", "--location"])
|
||||||
|
.args(["--max-time", "30", LATEST])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.context("updates: curl did not start")?;
|
||||||
|
anyhow::ensure!(
|
||||||
|
output.status.success(),
|
||||||
|
"updates: curl failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr).trim()
|
||||||
|
);
|
||||||
|
serde_json::from_slice(&output.stdout).context("updates: the latest release does not read")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The release's version and page when it is newer than this one.
|
||||||
|
fn newer(release: Release) -> anyhow::Result<Option<(String, String)>> {
|
||||||
|
let latest = version(&release.tag_name)?;
|
||||||
|
let shown = release.tag_name.trim_start_matches('v').to_string();
|
||||||
|
Ok((latest > version(VERSION)?).then_some((shown, release.html_url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `tag`'s three numbers, from `v0.1.6` or `0.1.6`.
|
||||||
|
fn version(tag: &str) -> anyhow::Result<[u64; 3]> {
|
||||||
|
let numbers: Vec<u64> = (tag.trim_start_matches('v').split('.'))
|
||||||
|
.map(str::parse)
|
||||||
|
.collect::<Result<_, _>>()
|
||||||
|
.with_context(|| format!("updates: {tag:?} is no version"))?;
|
||||||
|
numbers
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("updates: {tag:?} is no version of three numbers"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tag_reads_as_three_numbers() {
|
||||||
|
assert_eq!(version("v0.1.6").expect("a tag"), [0, 1, 6]);
|
||||||
|
assert_eq!(version("1.10.0").expect("no v"), [1, 10, 0]);
|
||||||
|
assert!(version("v0.1").is_err(), "two numbers");
|
||||||
|
assert!(version("v0.1.6-beta").is_err(), "a suffix");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_later_release_is_newer() {
|
||||||
|
let release = |tag: &str| Release {
|
||||||
|
tag_name: tag.into(),
|
||||||
|
html_url: format!("https://example.org/{tag}"),
|
||||||
|
};
|
||||||
|
let [major, minor, patch] = version(VERSION).expect("this version");
|
||||||
|
let later = format!("v{major}.{minor}.{}", patch + 1);
|
||||||
|
let found = newer(release(&later)).expect("reads");
|
||||||
|
let shown = later.trim_start_matches('v').to_string();
|
||||||
|
assert_eq!(found, Some((shown, format!("https://example.org/{later}"))));
|
||||||
|
assert_eq!(
|
||||||
|
newer(release(&format!("v{VERSION}"))).expect("reads"),
|
||||||
|
None,
|
||||||
|
"this one"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
newer(release("v0.0.1")).expect("reads"),
|
||||||
|
None,
|
||||||
|
"an older one"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
newer(release(&format!("v{}.0.0", major + 1)))
|
||||||
|
.expect("reads")
|
||||||
|
.map(|(v, _)| v),
|
||||||
|
Some(format!("{}.0.0", major + 1)),
|
||||||
|
"by its numbers, not as text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gitea_s_release_reads() {
|
||||||
|
let body = r#"{"id":213,"tag_name":"v0.1.5","name":"ELYTerminal 0.1.5","html_url":"https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal/releases/tag/v0.1.5","draft":false,"assets":[]}"#;
|
||||||
|
let release: Release = serde_json::from_str(body).expect("a release");
|
||||||
|
assert_eq!(release.tag_name, "v0.1.5");
|
||||||
|
assert!(release.html_url.ends_with("/releases/tag/v0.1.5"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -263,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.
|
/// The shown session's title, or the app's name while its program names none.
|
||||||
fn title(&self) -> &str {
|
fn title(&self) -> &str {
|
||||||
self.sessions
|
self.sessions
|
||||||
|
|||||||
+11
-7
@@ -64,12 +64,13 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
|||||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 scrollbar over the history: a drag on its thumb scrolls, whatever the program does with the wheel | T58 | `term::scrollbar` test: the thumb's share and place follow the history and the view's offset, and a place past either end of the track holds at it; under Xorg's dummy screen, after `seq 1 500`, the thumb stood at the bottom right, a drag to the top showed the first prompt and lines 1 to 39, and one back down lines 220 to 259; with mouse tracking on, the wheel went to the program as reports and did not scroll, while a drag on the thumb showed lines 245 to 284 | Pass |
|
||||||
| A bell makes no sound; the row marks it | T10, T12 | T12 review | Pass |
|
| A bell makes no sound; the row marks it | T10, T12 | T12 review | Pass |
|
||||||
|
|
||||||
## 6. Main window and layout
|
## 6. Main window and layout
|
||||||
@@ -84,6 +85,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
|||||||
| 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 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 |
|
| 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, given v0.1.1's build and these controls to click, reported it works on Windows | 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, 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, or stands inside at its bottom left where no room lies above it; her face follows the sessions; a press pets her and wakes her from a doze; nothing but her shows; a drag moves her along her edge | T46, T48, T49, T51, T52, T53, T55 | `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; T51: `pet` tests: she stands on the top edge while her box fits above the pane, 123.4 points, else the bottom; on macOS 0.1.4 opened her, over a window under the menu bar, at 1081 to 1205, inside its top, as the owner saw, and T51 at its bottom edge, 92, 140 in, and over a centered window still on its top edge, 1028; the `pet::windows` tests on the owner's PC put her on the top edge 400 pixels down the work area and on the bottom edge at its top, and there the release build stood her on the top edge of a centered window, on the bottom edge of one moved to the work area's top and of a maximized one, 210 pixels in at 150%, and on the top edge again once it came back; the `pet::x11` test under Xvfb puts her on the bottom edge once `_NET_WORKAREA` leaves no room; on WSLg's X11 she stood over a window with room and inside one as tall as the screen; on WSLg's Wayland, Weston, her popup stood on the pane's top edge and, once the window was maximized, opened again on its bottom edge, at its bottom left; under Xorg with two monitors she moved to the bottom edge of a window at the lower one's top, 112x123+240+1277, and of one held mostly by a staggered monitor at its top, 112x123+1240+877; under headless Sway her anchor went (140, 0) on the top edge, then (140, 999), (140, 799) and (140, 999) on the bottom edge as the window went full screen and the output 800 and 1000 high, and full screen at the same size moved her too; T52: the `pet::look` test wakes her from a doze 400 s into calm with a preLine truncated
|
||||||
|
|
||||||
## 7. Sessions panel
|
## 7. Sessions panel
|
||||||
|
|
||||||
@@ -124,6 +126,8 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
|||||||
| Appearance: theme grids, dark theme, font, size, line height, ligatures, cursor and preview, opacity, blur, padding | T17 | T17 review | 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 |
|
| 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: 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: check for updates automatically, a switch on by default, at start and once a day; Updates says what the check found, with Check now, and Download for a newer release; an automatic check that finds one posts a notification that opens its page | T56 | `updates` tests: a tag reads as three numbers, only a later release is newer, by its numbers, and Gitea's release body reads; under Xorg's dummy screen the app's start checked through curl and logged `0.1.5 is the latest`, the two rows stood under Ely, before Shortcuts, Check now checked again, the switch saved `check_updates` off and on, and turning it on checked at once; a build named 0.1.4 logged `0.1.5 is out`, its row read `ELYTerminal 0.1.5 is out; this is 0.1.4` beside Download and Check now, and Download logged that it opened the release's page; the notification's press went unexercised, since the container held no notification daemon | 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 |
|
| 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
|
## 11. Background blur
|
||||||
@@ -158,16 +162,16 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 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 |
|
| 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 |
|
| 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, or for a release with Developer ID and notarized, and its disk image | T24, T26, T31, T41 | 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; without the key the script stops before building | 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, T50, T54 | 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, as does the image rebuilt for T50 (run 1256); without the key the script stops before building; v0.1.5's disk image, from the Release workflow (run 1312) at `0119776`: its stapled ticket validates, Gatekeeper accepts the image and the app inside as notarized Developer ID, the app passes `codesign --verify --strict --deep` and reads 0.1.5, macOS 11.0, arm64, beside a link to Applications | Pass |
|
||||||
|
|
||||||
## 15. Release
|
## 15. Release
|
||||||
|
|
||||||
| PRD line | Task | Evidence | Status |
|
| PRD line | Task | Evidence | Status |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41 | `v0.1.3` 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 names `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 |
|
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41, T43, T47, T50, T54 | `v0.1.5` at `0119776`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1312), signed and notarized; published once CI run 1311 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 T51's `Ely moves to the pane's` and the failed glue's line, the disk image T53's `her panel drops its titled frame` and the tarball the monitors' line; the downloaded app, run with a HOME of its own, logged her panel going borderless, 0x8081 to 0x80, and Ely on the pane's top edge; the tag names `0119776`. `v0.1.4` rebuilt for T50, at `a54f4ff`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1256), signed and notarized, each in place of the first build of its name, once CI run 1253 passed on macOS, Ubuntu and Windows on `a9be8ab`, which `a54f4ff` follows with `TASKS.md` and `tasks/reviews.md` alone; the tag moved from `2d9180e` to `a54f4ff`, and the release's target with it; the notes give the new SHA-256, add T48's fix and T49's motion, and say the builds were replaced; each file downloaded back anonymously matches its SHA-256, each binary holds the rig's log line and none the old faces' string, and the tag's source archive holds `src/pet/rig.rs` and no `src/pet/faces.rs`. The first `v0.1.4` builds, 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 named `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 | 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 and v0.1.3's executables the same | Pass |
|
| The Windows executable carries the icon | T25, T31, T41, T47, T50, T54 | 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; on a Windows 11 VM the first 0.1.4 executable, running, showed it in the taskbar; v0.1.2's, v0.1.3's and v0.1.4's executables the same, T50's rebuild with icon and manifest byte for byte the first's; v0.1.5's executable a GUI program importing no C runtime DLL, its seven icon images, icon group 1 and manifest byte for byte 0.1.4's | Pass |
|
||||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41 | 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 | Pass |
|
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41, T47, T50, T54 | 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, T50's rebuild too; v0.1.5's holds the same three files, its desktop entry and icon byte for byte 0.1.4's, and its binary asks for glibc 2.35 at most | Pass |
|
||||||
| Each build comes from its script | T25, T27, T31, T41 | `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, the disk image's on `macos-signing` | Pass |
|
| Each build comes from its script | T25, T27, T31, T41, T43, T47, T50, T54 | `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`; T50's zip on the owner's PC, its tarball in the PC's WSL, its disk image on `macos-signing`; v0.1.5's three the same way: `scripts/windows-zip.ps1` on the owner's PC, `scripts/linux-tar.sh` in its WSL, `scripts/mac-app.sh` in the Release workflow on `macos-signing` | Pass |
|
||||||
|
|
||||||
## CI notes
|
## CI notes
|
||||||
|
|
||||||
|
|||||||
@@ -213,3 +213,74 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
|||||||
## T42
|
## T42
|
||||||
|
|
||||||
- No review: the owner waived it for this change.
|
- 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.
|
||||||
|
|
||||||
|
## T50
|
||||||
|
|
||||||
|
- Round 1, a54f4ff and 5d10979 with v0.1.4 rebuilt: PASS. The reviewer found T50's code, resources, scripts and workflows those of a9be8ab, where CI run 1253 passed on macOS, Ubuntu and Windows; read Release run 1256 notarize and staple the disk image at a54f4ff and put it on the release; downloaded the three files anonymously against the notes' SHA-256, each unlike the first 0.1.4's; matched the tag's source archive to a54f4ff file by file and the notes to notes.md; checked the disk image's ticket, signatures, Gatekeeper, version 0.1.4 and macOS 11.0, the zip's icon and manifest against the first build byte for byte and its imports, no C runtime DLL among them, and the tarball's files, modes and glibc 2.35; and ran the downloaded app with a HOME of its own, where Ely stood 112 by 124 on the window's top edge and moved between captures. check.sh passed. Windows and Linux windows, live multi-display DPI and Wayland went unexercised.
|
||||||
|
|
||||||
|
## T51
|
||||||
|
|
||||||
|
- Round 1, 15ef5d5: FAIL. On Wayland her bottom anchor kept the height it opened at, and a change of state at the same size never moved her; on X11 the room ran to the root's top, not her monitor's; Sway's tiling went unseen. Fixed in e6c0c14: twice a second she checks where the window's size and state put her anchor, counted from the pane as gpui counts it, and opens there again; X11 reads RandR's monitors; tiling counts where gpui reports it, for a window it draws itself, as T51's line and AGENTS now say.
|
||||||
|
- Round 2, e6c0c14: FAIL. The reviewer's replays under headless Sway moved her anchor (140,0) to (140,999), (140,799) and (140,999), and full screen at the same size moved her; but X11 picked the monitor under her bottom left corner, so over a window held mostly by a staggered monitor she stood over the gap beside it. Fixed in 8b64798: the monitor holding most of the pane, as macOS and Windows pick a window's display.
|
||||||
|
- Round 3, 8b64798: PASS. The staggered replay put her at 112x123+1240+877, her bottom on the pane's bottom; the stacked one at 112x123+240+1277; check.sh passed with 238 tests and Linux with 245. Physical multi-monitor screens went unexercised.
|
||||||
|
|
||||||
|
## T52
|
||||||
|
|
||||||
|
- Round 1, 0eb8fc5: PASS. The reviewer ran the two new tests against the parent's look.rs, where after the wink she stayed Doze with Lines eyes, and against the change, where they passed; its probes covered the 299.999 and 300 s edges, a second press counting again, an old press before a new mood, a finish's count, work, waits and errors, a press with no records at all, the zs fading, and 242 poses over three weeks; check.sh passed with 240 tests and Linux with 247. A native click, frame by frame, and five real minutes went unexercised.
|
||||||
|
|
||||||
|
## T53
|
||||||
|
|
||||||
|
- Round 1, a6c044f: PASS. On macOS 27.2 the reviewer read her panel back as mask 0x80, no shadow, key only if needed, never key, a child of the main window, its frame and content rect alike; her window 112 by 124, inside the bottom edge under the menu bar; the top 32 rows and the outer columns of her picture clear; abc reached cat. A probe that kept setStyleMask: from taking logged 'pet: Ely goes, as her window cannot be kept there' and 'pet: her panel keeps style mask 0x8081', left no child window, and the main window still took abc. check.sh passed with 240 tests. This macOS draws no edge for a titled panel, so the hairline's going awaits the owner's macOS; Windows and Linux rest on the captures supplied.
|
||||||
|
|
||||||
|
## T54
|
||||||
|
|
||||||
|
- Round 1, 0119776 and 15153b4 with v0.1.5 published: PASS. The reviewer found the bump only elyterminal's version; downloaded the three files anonymously, each matching the notes' SHA-256 and target/t54/anon byte for byte, and the tag's source archive the commit's 141 files; checked the disk image's ticket, strict signatures and Gatekeeper, the hardened arm64 app at 0.1.5 and macOS 11.0, the zip's GUI executable with its seven icon images, icon group 1, PerMonitorV2 manifest and no C runtime DLL, and the tarball's modes and glibc 2.35; found T51's and the failed glue's markers in all three binaries, T53's in the app and the monitors' in the tarball; and read CI run 1311 succeed on macOS, Ubuntu and Windows at 0119776 a minute before the release was published, and Release run 1312 on the signing runner. check.sh passed with 240 tests. Windows and Linux windows went unexercised.
|
||||||
|
|
||||||
|
## T55
|
||||||
|
|
||||||
|
- No review: the owner asked for 0.1.6 without one. Checked by scripts/check.sh (240 tests), Linux tests (247) and clippy, Windows gnu clippy, an xdotool drag under Xorg, a drag under Sway nested in Xorg, and the owner's own drags on macOS.
|
||||||
|
|
||||||
|
## T56
|
||||||
|
|
||||||
|
- No review: the owner asked for 0.1.6 without one. Checked by scripts/check.sh (243 tests), Linux tests (250) and clippy, Windows gnu clippy, and under Xorg's dummy screen the start's check, Check now, the switch, and a build named 0.1.4 that found 0.1.5 out and opened its page.
|
||||||
|
|
||||||
|
## T58
|
||||||
|
|
||||||
|
- No review: the owner asked for it at once, in 0.1.6, without one. Checked by scripts/check.sh (244 tests), Linux tests (251) and clippy, Windows gnu clippy, and under Xorg's dummy screen drags of the thumb through 500 lines, with mouse tracking on as well.
|
||||||
Reference in new issue
Block a user