Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3601432c4
|
||
|
|
616bec2615
|
||
|
|
7d06bdec8a
|
||
|
|
c2392ea878
|
||
|
|
5ea25c299b
|
||
|
|
0a30760453
|
||
|
|
1384ef5dbe
|
||
|
|
d51823988e
|
||
|
|
c07b1075f0
|
||
|
|
ca3809b37c
|
||
|
|
3085a940ad
|
||
|
|
76c48dce2e
|
||
|
|
760f054976
|
||
|
|
0aee06dc27
|
||
|
|
690d24f943
|
||
|
|
508a16f16a
|
||
|
|
5d131e51d8
|
||
|
|
885aa4bda0
|
||
|
|
5f8ea89838
|
||
|
|
cb32f00623
|
||
|
|
5cba2ebc72
|
||
|
|
d56c3c824e
|
||
|
|
566056257f
|
||
|
|
ac5e17af76
|
||
|
|
887378ade3
|
||
|
|
545fb46249
|
||
|
|
390f2b6176
|
||
|
|
0fcefefef0
|
||
|
|
28d761572d
|
||
|
|
34be6e3521
|
||
|
|
84364ce03e
|
||
|
|
9fa68b24a7
|
||
|
|
6b9576f998
|
||
|
|
e74ea745fe
|
||
|
|
8dc8fbc51c
|
||
|
|
0c55d41026
|
||
|
|
82cfe91810
|
||
|
|
8ef91855cd
|
||
|
|
a7aecf2085
|
||
|
|
8a9941bfd2
|
||
|
|
9e185eab58
|
||
|
|
55250f49f5
|
||
|
|
17e91aafef
|
||
|
|
3759cf8825
|
||
|
|
15153b4aae
|
||
|
|
0119776095
|
||
|
|
a153e89d84
|
||
|
|
a6c044f009
|
||
|
|
b7377e424e
|
||
|
|
0eb8fc5c4e
|
||
|
|
dbb19caaf8
|
||
|
|
8b647982ef
|
||
|
|
e6c0c14d1b
|
||
|
|
15ef5d52d0
|
||
|
|
7032285d13
|
||
|
|
5d109790c8
|
No files matched your search
+19
-18
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
runs-on: [self-hosted, macos-arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The runner's PATH lacks the host's rustup.
|
||||
@@ -16,31 +16,32 @@ jobs:
|
||||
- run: scripts/check.sh
|
||||
- run: cargo build --locked
|
||||
|
||||
platforms:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
# Debuginfo makes the Linux link outgrow its VM's memory.
|
||||
windows:
|
||||
runs-on: [self-hosted, ely-windows]
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- run: rustup toolchain install --no-self-update
|
||||
- run: cargo test --locked --quiet
|
||||
- run: cargo build --locked
|
||||
|
||||
linux:
|
||||
runs-on: ubuntu-24.04
|
||||
# A clean image: the hosted one's zsh and fish setup breaks the shell tests.
|
||||
container: docker.gitea.com/runner-images:ubuntu-24.04
|
||||
env:
|
||||
CARGO_PROFILE_DEV_DEBUG: 0
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The job container comes without rustup.
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
- run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain none
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: rustup toolchain install --no-self-update
|
||||
# On a host runner its post step deletes the host's rustup.
|
||||
- if: runner.os == 'Linux'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
- if: runner.os != 'Linux'
|
||||
run: cargo test --locked --quiet
|
||||
# The Linux job container runs as root, which ignores the file permissions the tests check.
|
||||
- if: runner.os == 'Linux'
|
||||
run: |
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# The job container runs as root, which ignores the file permissions the tests check.
|
||||
- run: |
|
||||
apt-get update && apt-get install -y --no-install-recommends pkg-config libwayland-dev \
|
||||
libxkbcommon-dev libxkbcommon-x11-dev libxcb1-dev libx11-xcb-dev libfontconfig-dev \
|
||||
libfreetype-dev zsh fish xvfb
|
||||
|
||||
@@ -7,7 +7,9 @@ on:
|
||||
jobs:
|
||||
macos:
|
||||
# The owner's login session, whose keychain holds the Developer ID identity.
|
||||
runs-on: macos-signing
|
||||
runs-on: [self-hosted, macos-signing]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# The runner's PATH lacks the host's rustup.
|
||||
@@ -26,8 +28,7 @@ jobs:
|
||||
scripts/mac-app.sh
|
||||
- name: Put the disk image on the release
|
||||
env:
|
||||
RELEASE_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
version=$(cargo pkgid)
|
||||
scripts/release-asset.sh "target/release/ELYTerminal-${version##*[#@]}.dmg"
|
||||
@@ -5,11 +5,11 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
## Stack
|
||||
|
||||
- Rust 1.95.0, as `rust-toolchain.toml` pins it, edition 2024. One binary crate, `elyterminal`.
|
||||
- Ely (`ely-gpui-component`) from git.zacharyzhang.com, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
||||
- Ely (`ely-gpui-component`) from github.com/ELY-Open-Source, pinned by rev. gpui and gpui_platform from crates.io's `gpui-pre`, the exact version Ely pins, under their crate names through `package`.
|
||||
- `alacritty_terminal` 0.26: grid, parser, pseudo-terminal, event loop.
|
||||
- `serde`, `serde_json`: settings. `dirs`: the config folder. `base64`, the version alacritty pulls in for OSC 52: OSC 7501's text. `log`, `env_logger`: logs to stderr. `unicode-normalization`, on macOS: Git's marks in composed form.
|
||||
- On Linux, `wayland-client`, `wayland-backend` and `wayland-protocols` (staging) ask the compositor for `ext_background_effect_v1` on gpui's own connection; `wayland-server` hosts a compositor in the tests. `x11rb` sets `_KDE_NET_WM_BLUR_BEHIND_REGION`; its test runs Xvfb.
|
||||
- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes, waits on herdr's pipe, reads the keyboard for win32-input-mode, and keeps Ely's window on the main one, which `raw-window-handle` finds.
|
||||
- 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, keeps Ely's window on the main one, which `raw-window-handle` finds, and on Windows 10 reads the build and clears the main window's acrylic while it moves.
|
||||
- `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
|
||||
@@ -22,22 +22,23 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- `snap` writes each child window's picture at that moment, `PNG.childN.snapK.png`, so a child that moves shows its frames;
|
||||
- `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent. macOS may delay or refuse the activation; each `shot: typed` line says whether the app was active;
|
||||
- `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field. AppKit gives an inactive app's key that a menu item holds to the menu, and gpui sends the item's action to the main window, which an inactive app lacks: only an app-wide action runs, such as Quit, Settings or the font's size;
|
||||
- `drop@x,y` drops the files `SHOT_DROP` names, one a line, as gpui's view hands a drop on, a move, a release and the drag's end, 120 ms apart;
|
||||
- mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`.
|
||||
- `scripts/check.sh`: fmt, clippy `-D warnings`, tests, the 500-line rule.
|
||||
- `scripts/linux.sh ARGS` runs `cargo ARGS` on the working tree in a Linux container, the build kept in Docker volumes; `scripts/linux.sh test --locked` runs the tests as a user, not root, as CI does. The tree goes in as a tar stream, every file dated now: Colima's shared folder can show the VM a file as it was before an edit, and cargo judges by date, so a tree older than the last build would reuse that build. It needs a Docker daemon: on macOS, Colima (`colima start`).
|
||||
- `scripts/mac-app.sh` builds `target/release/ELYTerminal.app` from a release build, signed ad hoc, and `ELYTerminal-VERSION.dmg` beside it, the app next to a link to Applications. Given `SIGN_IDENTITY` and the App Store Connect key, `NOTARY_KEY` (its .p8 file), `NOTARY_KEY_ID` and `NOTARY_ISSUER`, it signs with that identity under the hardened runtime, then notarizes and staples the image, and it stops before building when one is missing or no keychain holds the identity. Its icon: `examples/icons.rs` renders `assets/icons/elyterminal-macos.svg` into an iconset, which `iconutil` packs.
|
||||
- `scripts/windows-zip.ps1`, on Windows with Visual Studio's C++ tools, builds `elyterminal.exe` with its icon and zips it as `ELYTerminal-VERSION-windows-ARCH.zip` in `target/release`. After an edit to the figure, `cargo run --example icons -- ico assets/icons/elyterminal.svg assets/icons/elyterminal.ico` renders the icon again.
|
||||
- `scripts/linux-tar.sh` builds the release in Docker on `rust:1.95.0-bookworm` and packs it with `com.zacharyzhang.elyterminal.desktop` and the icon as `ELYTerminal-VERSION-linux-ARCH.tar.gz` in `target/release`.
|
||||
- `scripts/release-asset.sh FILE` puts FILE on the Gitea release of this version, a draft too, in place of a file of its name; `RELEASE_API` and `RELEASE_TOKEN` say where and as whom. The Release workflow, `.github/workflows/release.yml`, dispatched by hand, builds the signed and notarized disk image on the `macos-signing` runner and puts it on the release.
|
||||
- `scripts/release-asset.sh FILE` puts FILE on the GitHub release of this version, a draft too, in place of a file of its name, through `gh`. The Release workflow, `.github/workflows/release.yml`, dispatched by hand, builds the signed and notarized disk image on the `macos-signing` runner and puts it on the release.
|
||||
|
||||
## Layout
|
||||
|
||||
- `build.rs`: on Windows, the icon into the executable as resource 1, through `assets/icons/elyterminal.rc`. `.cargo/config.toml`: Windows builds carry the C runtime. `rust-toolchain.toml`: the toolchain rustup installs.
|
||||
- `src/main.rs`: start. `src/lib.rs`: the modules, public so tests and examples reach them. `src/app.rs`: fonts, settings, integration, then the main window, at the size the settings keep, which it keeps as it settles. `src/menus.rs`: the menu bar, the keys of Quit, the font's size and Minimize, and what they do. `src/blur.rs`: what the window shows of the desktop, from the opacity and blur settings, set as they change; `blur/macos` gives gpui's NSVisualEffectView a material that blurs; `blur/wayland` asks a Wayland compositor for blur behind the window's surface through `ext_background_effect_v1`; `blur/x11` names the window's pane in `_KDE_NET_WM_BLUR_BEHIND_REGION`. `src/workspace.rs`: the window's content, Ely's `ColumnShell` with the sessions, terminal and right columns, their toggles, the right column's Git and Files switch, the title, and the sessions' lifecycle. `src/workspace/widths.rs`: the side columns' widths, which a drag on their border sets and the settings keep once it rests. `src/workspace/events.rs`: what a session's terminal reports: title, folder, bell, the state its row shows, the program's exit; and a press on a notification a session posted. `src/sessions.rs`: a session and its row, with the mark of its status and what hover shows of it; `sessions/orb` draws the mark, 3 by 3 dots on one clock. `src/files.rs`: the Files panel, the shown session's folder as Ely's `Tree` with Git's marks. `src/git.rs`: git through its command line: Files' marks, and the Git panel's status, stage, unstage, discard, commit and log; `src/git/diff.rs` reads git's diff into Ely's stretches. `src/git/panel.rs`: the Git panel's state and actions; `src/git/panel/view.rs`: how it draws.
|
||||
- `src/pet.rs`: Ely, peeking over the main window's top edge from a window of her own while the settings want her; she draws at every frame the display gives her window; `pet/rig` draws her from the icon's paths in layers of one color, each moved by a matrix of its own, as the films rig her; `pet/look` reads her mood from the sessions' marks and her pose at each moment of it, `look/curves` the timings of her moves; and `pet/macos`, `pet/windows` and `pet/x11` keep her window on the main window's edge where the platform does not.
|
||||
- `src/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`. `blur/windows`, on Windows 10, clears a blurred window's acrylic while it moves or sizes. `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/rename.rs`: a session renamed in place. `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/themes`: built-in terminal themes, the chrome palette each maps to, and `apply`, which dresses the app for the system's appearance; `omarchy`, on Linux, wears Omarchy 4's current theme and follows its switches.
|
||||
- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets, the mouse modes and, on Windows, win32-input-mode (`tap/modes`) before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session, and tells the view herdr leads, so herdr opens its links: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side, the repeat flag and the key code of AppKit's current event, and the key's own character from the layout, as gpui reads key names; it keeps held keys repeating, and hands Command key-ups to the focused view. `win32` sends a key as Windows' own record, win32-input-mode, and finds a key's own character in Windows' layout. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text.
|
||||
- `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. `drop` types the paths of files dropped on the terminal. `fonts` loads the terminal's font and measures its cell. `click` turns a click on the line the shell reads into arrows to 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/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).
|
||||
@@ -86,10 +87,10 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair.
|
||||
- 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach.
|
||||
- 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. They are Ely's text input actions, which `forms` exports for this, and the terminal handles them too: gpui sends a menu's action to what holds focus and enables the item only where a handler waits, so one action serves the terminal, find and every field. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's, though a space under a combining mark shows a character and counts as one; copy on select takes it, and a blank grid selects nothing.
|
||||
- 2026-10-15: CI runs on self-hosted Gitea runners, each job with what its runner must hold. macOS runs on the host, with fish and PowerShell installed, or the two shell tests fail as they should. Windows runs on the host, with rustup's `cargo` and `rustc` proxies beside `rustup` and Visual Studio's C++ tools. Ubuntu (`ubuntu-24.04`) runs as root in `docker.gitea.com/runner-images:ubuntu-24.04`, installs the libraries gpui links, and runs the tests as a user it makes, since root ignores the file modes they check; its runner serves this repository alone, in a VM of its own, so a build cannot starve other containers. The Linux and Windows jobs build without debuginfo, as `scripts/linux.sh` does: with it, linking gpui's test binaries outgrew that VM's 8 GB. The macOS and Windows jobs run on the owner's own machines, so they skip `Swatinem/rust-cache`: its post step cleans `$CARGO_HOME`, which there is the owner's own, and after every job it deleted `rustup` from `~/.cargo/bin`, leaving its proxies pointing at nothing, and pruned the shared registry; the Linux job keeps it, inside its container. A run that waits on a missing runner stays queued: cancel stale ones before a new runner registers, or it runs every one. The shell tests type a line only once the line editor reads raw keys, canonical mode off: on CI, a line typed before PowerShell's first read was echoed by the terminal and never run. Every job installs the toolchain `rust-toolchain.toml` names with `rustup toolchain install --no-self-update` and never runs `rustup default`, so the owner's machines keep their own default toolchain and rustup; the macOS job puts the host's `~/.cargo/bin` on the runner's PATH, and the Linux container installs rustup first. A toolchain file's components install only with the toolchain: one already there without them makes each cargo call try to download them, so `scripts/linux.sh` and `scripts/linux-tar.sh` add them to their images.
|
||||
- 2026-10-15: CI ran on self-hosted Gitea runners until v0.1.7, each job with what its runner must hold. macOS runs on the host, with fish and PowerShell installed, or the two shell tests fail as they should. Windows runs on the host, with rustup's `cargo` and `rustc` proxies beside `rustup` and Visual Studio's C++ tools. Ubuntu (`ubuntu-24.04`) runs as root in `docker.gitea.com/runner-images:ubuntu-24.04`, installs the libraries gpui links, and runs the tests as a user it makes, since root ignores the file modes they check; its runner serves this repository alone, in a VM of its own, so a build cannot starve other containers. The Linux and Windows jobs build without debuginfo, as `scripts/linux.sh` does: with it, linking gpui's test binaries outgrew that VM's 8 GB. The macOS and Windows jobs run on the owner's own machines, so they skip `Swatinem/rust-cache`: its post step cleans `$CARGO_HOME`, which there is the owner's own, and after every job it deleted `rustup` from `~/.cargo/bin`, leaving its proxies pointing at nothing, and pruned the shared registry; the Linux job keeps it, inside its container. A run that waits on a missing runner stays queued: cancel stale ones before a new runner registers, or it runs every one. The shell tests type a line only once the line editor reads raw keys, canonical mode off: on CI, a line typed before PowerShell's first read was echoed by the terminal and never run. Every job installs the toolchain `rust-toolchain.toml` names with `rustup toolchain install --no-self-update` and never runs `rustup default`, so the owner's machines keep their own default toolchain and rustup; the macOS job puts the host's `~/.cargo/bin` on the runner's PATH, and the Linux container installs rustup first. A toolchain file's components install only with the toolchain: one already there without them makes each cargo call try to download them, so `scripts/linux.sh` and `scripts/linux-tar.sh` add them to their images.
|
||||
- 2026-10-15: on macOS 27 gpui's `Blurred` gives its NSVisualEffectView the `Selection` material, which builds no backdrop layer, so the desktop showed through sharp. `blur/macos` sets the view to `underWindowBackground` whenever the window turns Blurred: a gaussian blur of radius 30, the radius the owner's Ghostty uses; gpui's `updateLayer` still strips the material's tint and saturation. The view's presence proved nothing, so `shot` reads the radius off its backdrop layer.
|
||||
- 2026-10-15: the icon is drawn by hand in SVG, flat, so one figure serves both forms: on macOS it stands on a night-purple rounded square on Apple's grid (824 of 1024, radius 185) with sparkles, the gradient grained so it does not band; on Windows and Linux it stands alone, as Papirus draws app icons. The app is `com.zacharyzhang.elyterminal`, after the owner's domain. A local build is signed ad hoc: it opens on the Mac that built it, and Gatekeeper asks once on any other; a release's is signed with the owner's Developer ID and notarized, so it opens on any Mac.
|
||||
- 2026-10-15: a release is a Gitea release tagged `vVERSION` with three builds, each from its script: the macOS disk image for Apple silicon, a Windows zip and a Linux tarball for x86_64. Each window names itself `com.zacharyzhang.elyterminal` through `Window::set_app_id` as it opens: on Linux that is Wayland's app id and X11's `WM_CLASS`, which the desktop entry's name and `StartupWMClass` match, so a dock pairs the window with the icon; gpui's X11 windows read no app id from `WindowOptions`.
|
||||
- 2026-10-15: a release is a GitHub release tagged `vVERSION` with three builds, each from its script: the macOS disk image for Apple silicon, a Windows zip and a Linux tarball for x86_64. Each window names itself `com.zacharyzhang.elyterminal` through `Window::set_app_id` as it opens: on Linux that is Wayland's app id and X11's `WM_CLASS`, which the desktop entry's name and `StartupWMClass` match, so a dock pairs the window with the icon; gpui's X11 windows read no app id from `WindowOptions`.
|
||||
- 2026-10-15: on Windows a release build is a GUI program, so no console window opens beside it; a debug build keeps its console for the log. git then starts with `CREATE_NO_WINDOW`, or each call would open a console. Windows builds link the C runtime statically, so the executable needs no Visual C++ redistributable. gpui's build script embeds its manifest, PerMonitorV2 DPI awareness among it; `build.rs` adds the icon as resource 1, which gpui loads for each window, and stops the build when no resource compiler is found.
|
||||
- 2026-10-15: the Linux tarball builds on Debian 12, in `rust:1.95.0-bookworm`: a binary asks for the glibc symbol versions it was linked against, so it is linked against an old glibc, not the builder's. `scripts/linux-tar.sh` prints the glibc its build asks for, and the README names it: 2.35 for v0.1.0, which runs on Ubuntu 22.04.
|
||||
- 2026-10-15: on Windows gpui answers the system's hit test from the last frame: under the pointer, a `WindowControlArea::Drag` hitbox that nothing occludes makes it the caption, and Windows keeps the press, so no click reaches the app. In v0.1.0 every title bar control was dead so. Ely now occludes the drag region under its window buttons, which then answer as close, maximize and minimize, and under each column header's item row, whose controls get plain presses; the row stays the scroll container, and its title is a drag region of its own.
|
||||
@@ -100,7 +101,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range does.
|
||||
- 2026-10-16: the sessions and right columns resize by a drag on their border toward the terminal, which the owner asked for on macOS, Windows and Linux. Ely's `ShellColumn::resizable`, added to Ely for this, puts Ely's `resize_handle` on the border toward the filling column, the sessions column's right and the right column's left: the handle draws that border, a filled hairline, since gpui paints a border no wider than half its box, and the line it drew at first showed half a pixel on macOS and none on X11; it occludes the header under it, so a press there drags the border, not the window, on Windows too, where an unoccluded drag region takes the press as the caption. A drag sets off from the wider of the width drawn and the one kept: a header's parts may hold a column wider, and a window too narrow for both widths squeezes them, where a drag from the squeezed width jumped back against the pointer; a drag that has not moved sideways changes nothing, and one that comes back where it set off gives the width it had. A set width in Ely became a flex basis, so a column holds its header's own parts: at a set width under them, Windows' caption buttons ran out of the window. The widths, 160 to 960 pixels, show at once and are saved in `sessions_width` and `panel_width` through `SettingsStore::update` 0.5 s after a drag rests, as the window's size is; a drag in the half second before the window closes is lost, and a save the store refuses puts the columns back. A window too narrow for both yields the terminal its header's width, as Ely's set widths give way.
|
||||
- 2026-10-16: prompts and commands carry OSC 133 marks, which the owner asked for, read as Ghostty reads them: A or N starts a prompt, B or I ends it, C starts a command's output, and D ends the command with its status, D's first option, none when that is no number. The tap reads any program's marks before the parser, so it never sees the cursor: A's fresh line and L, which need it, are not done, and P adds nothing to A. Each session keeps the status its last command ended with. The integration marks what the shell leaves unmarked, and the user's prompt still sees the command's status. zsh: precmd reads `$?` and sends D after a command ran, preexec sends C, and a last precmd hook wraps PS1 in A and B under `prompt_percent`, which the first prompt says while it is off; it moves to the end of `precmd_functions` at each prompt, since a theme that sets PS1 in a hook of its own loads after it, and at the first prompt `sched +0` wraps it once more, since zsh runs due events after every precmd hook; that call returns the command's status, which the prompt shows, but it would reset `$pipestatus`, so it serves the first prompt alone. bash: PROMPT_COMMAND's first entry reads `$?`, which each entry resets, and its last wraps PS1. From bash 4.4 PS0 sends C and its subscript notes that a command ran, since bash prints no PS0 for an empty line, and the first entry sends D for it; both need `promptvars`, on by default. macOS's bash 3.2 has no PS0 and nothing else between reading a line and running it: its DEBUG trap runs for a key binding's command (`bind -x`) as for a typed one, with nothing to tell the two apart, runs for no line that is only a subshell, and under `extdebug` its status decides whether a command runs. So bash 3.2 sends no C, and Ghostty's automatic integration skips bash 3.2. Its D comes from PS1 once `\#`, the command number, has moved, which neither a key binding nor an empty line does; readline sends a prompt's escapes again at each redraw, so that D goes again with it, the same status. Every mark of a command rides an expansion, which `promptvars` turns on, so while it is off at start the script says so rather than mark nothing without a word. fish 4.3 marks its own prompts and commands, fish 4.0 all but B, older ones none: the script adds what the running fish lacks, around the prompt shown, and wraps it again when the user's changes. PowerShell: the prompt reads `$?` first and sets it back for the user's prompt through `Write-Error '' -ErrorAction Ignore`, which adds nothing to `$Error`. A failure ends with `$LASTEXITCODE` when a program set it during the line, since each run stores a new object, equal or not, which `[object]::ReferenceEquals` tells apart, unless the line surely ends in PowerShell: its syntax tree, read as PSReadLine hands the line over, ends in a cmdlet, as `Get-Command` resolves the name, aliases through, in a name not found, or in no command; such a line ends with 1, a cmdlet's failure under `-ErrorAction Ignore` too. A line that ends in a block, a function, a script or a call through a variable takes the code of a program it ran: only a hook on every command lookup would name the last command run, and it slowed a loop of commands by half. `$LASTEXITCODE` is read through `Get-Variable`, since strict mode refuses an unset variable and a prompt that fails falls back to `PS>`. C goes out when PSReadLine hands over a line that is not blank; without PSReadLine the script says it marks no command. herdr passes no OSC 133 on, so a session running herdr keeps the status from before it.
|
||||
- 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end closes it. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree.
|
||||
- 2026-10-16: the view jumps between prompts, which the owner asked for, as Ghostty jumps: Cmd-Up and Cmd-Down, with Shift or without, on macOS, Ctrl-Shift-Up and Ctrl-Shift-Down elsewhere, scroll to the prompt that starts before or after the view's top; a prompt on the screen's last page means the bottom, which the view cannot pass, and with none there nothing moves. alacritty's rows hold no marks, and the tap, which reads before the parser, never sees the cursor, so the tap rewrites what goes to the parser: an OSC that may be a mark is held whole until it ends and read as `marks` reads it, and a prompt's start becomes an OSC 8 link of the terminal's own, its target `\x7f`, which no program names, its id `\x7f` and the prompt's number; the prompt's end, B, opens a link of the same target over the line typed after it, its id `\x7f`, the commands ended so far, C or D each one, and `i`, so every prompt a shell draws again on one line, as fish draws its right prompt at each key, shares its input's number; C or D closes it. macOS's bash 3.2, which sends no C, stamps a command's output as input until its D. The parser stamps each cell the prompt prints with the link, SGR 0 keeps it, and it rides the cells through scroll, reflow and history; a prompt starts on the first row that shows its number, blank rows within it passed. A program's own link keeps its target and takes the prompt's number in its id: one opened inside a prompt carries it, and a prompt that starts inside one opens it again with the number and gives back its own id at the prompt's end, its target kept whole as the scanner keeps it; its end inside a prompt takes the prompt's link up again. A cursor restore that brings back a link passes the rewrite by. An OSC held, a body of 4096 bytes at most as the scanner keeps it and twice that with the controls vte skips, shows nothing until it ends; controls vte runs inside an escape go on before an ESC that waits, and only an ESC from ground waits: one that ends an OSC goes on with it, so what follows runs where the parser stands. A rewrite turns a mark of 8 bytes inside a program's link into that link again, its target up to 4096 bytes, so the tap reads about 2 KB of the buffer's room at a time, which moved 3 MB through a pty as fast as larger reads. Links, Cmd-press among them, pass over the prompt's link. The tap's OSC scanner ends a body as vte does, at BEL, ESC, CAN or SUB, and skips the controls vte skips, so a mark and its link agree.
|
||||
- 2026-10-17: programs say what they are doing through OSC 7501, revision 0.4 of the Program Status Protocol, which the owner asked for, read as the spec reads it: pairs `key=value` joined by `:`, a malformed pair skipped, an unknown key passed by, and every pair of a known key checked, a clear's too, before the last of each key is kept. A sequence over 4096 bytes, counted from OSC through ST with the controls its body skips, a key over 16, an app over 32, an id outside its grammar or limits (128 bytes, 32 a segment, 8 levels), a title or msg over its limits, not base64, not UTF-8 or holding a control character, and a missing or unknown state each drop it whole; kind counts only with blocked, and progress, 0 to 100, only with working and blocked, each absent when unknown. Each terminal keeps its records by id, the root's empty: a report replaces its record whole, clear drops the record and those under it, or all without an id, and past 256 the record updated longest ago goes. A new prompt drops working, blocked and idle records, as kitty does, and keeps done and error; a full reset, which the tap's scanner sees as `ESC c`, through the controls, DEL and high bytes vte passes by in an escape, drops all, and DECSTR nothing. The query `OSC 7501 ; ?` is answered with every state and kind; the view writes the answer when the session hears the query, ahead of the parser's answers to what came later in the same read, so a DA1 sent after the query, as the spec suggests, comes back second, and one sent before it in the same read comes back after it.
|
||||
- 2026-10-17: programs ask for notifications through OSC 9, 777 and 99 and report progress through OSC 9;4, which the owner asked for. OSC 9 is ConEmu's command where Ghostty reads one, 9;1 to 9;12 each by its own shape, else iTerm2's notification, its text the title; OSC 777 is rxvt's `notify;title;body`, another extension logged and passed by. OSC 99 is kitty's: `k=v` pairs before the payload, a malformed pair passed by; `i`, of `[A-Za-z0-9_-+.]` alone or the sequence drops, gathers chunks while `d=0`, 32 notifications and 16384 bytes of text at most; a payload holds 2048 bytes plain or 4096 encoded at most; plain text is UTF-8 without a control character; `e=1` reads it as base64 group by group, so chunks split before encoding or after join, and it carries any UTF-8, newlines too, checked once the text is whole; a chunk that breaks a rule drops what its notification gathered; metadata holds 1024 bytes at most, and the tap keeps an OSC 99 body to 5124 bytes, room for metadata and a full payload, where other bodies stop at 4096; `p` takes title, body, close, ? and alive, and other payloads pass by; `a`, `c` and `w` are kept for posting, and `o` passes by; a lone body is the title, and one with neither passes by. The query and alive are answered as the notifications below say; an answer names the asker's id, or 0. OSC 9;4's states, remove, set, error, indeterminate and pause, read as Ghostty reads them, the progress clamped to 100, stand as the root record: set and pause working with their progress, indeterminate working without, error an error, remove none; this lasts until a program reports through OSC 7501, and again after a full reset, as the spec asks, so a mapped 9;4 never wipes a real report. The terminal posts the notifications, as below.
|
||||
- 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none.
|
||||
@@ -108,4 +109,17 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does;
|
||||
- 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities.
|
||||
- 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a repeat or a release goes as a record where its press did, a repeat that types after its modifiers came up too, and a release goes so only there; a full reset ends the mode, as Windows Terminal's does. A key that is down is known by its code, AppKit's key code on macOS and the virtual key on Windows, since gpui names a release by the modifiers left down: a release after Shift came up still goes out. Linux has only gpui's name. A console record holds no Windows key, so no Windows terminal passes herdr a `cmd` binding, and a keypad key goes as its main key, since gpui names it so. On Linux gpui keeps neither the key code nor its xkb state, and names a non-ASCII key by its place on a US keyboard, so a key's own character cannot be found: Shift with a symbol and Ctrl, Alt or Super goes as the symbol without Shift, which herdr matches to a binding on the symbol, `ctrl+{`.
|
||||
- 2026-10-18: inside herdr, herdr opens its panes' links, as the owner's Cmd-press and Ctrl-press ask. herdr 0.9.5 wraps a long URL at its pane's edge with no wrap flag and writes its panes' OSC 8 links to the terminal without their targets, so ELYTerminal's own finder opened a cut URL or nothing; herdr itself opens the link under a Ctrl-press, whole from its own pane, a wrapped URL or an OSC 8 target, and underlines it while Ctrl is held. So while herdr's client leads the session and tracks the mouse, every link press goes to herdr as the mouse report it is, and on macOS herdr hears Cmd as Ctrl, the wheel aside. herdr's rules come with it, by its design: while it takes the mouse it keeps its panes' OSC 8 targets from the terminal, so that it alone answers Ctrl-press, and it opens only `http` and `https` links; a `file://` link inside herdr stays shut, since the text the terminal sees may be the label of another link. Shift with the press, which takes the mouse back from a program, is the way past herdr its docs name: the terminal then opens what it sees, a `file://` link too. With herdr's mouse capture off, herdr writes its links' targets out and the terminal opens them itself. Elsewhere ELYTerminal opens the link itself, as before.
|
||||
- 2026-10-18: Ely peeks over the main window's top edge, which the owner asked for after the 0.1.3 film, her head and fingers drawn from the icon's own paths and cut at the edge she holds, in a transparent window of her own, 112 by 107 points with 16 above her for her hops, 140 in from the pane's left edge, less on a pane too narrow for that, so she holds its edge at any width, and the main window seems to hide the rest of her. Her window never takes the keys from the terminal: on macOS her panel becomes key only if needed, which gpui's view never asks; on Windows her window answers a press's activation with none, every other message passed on to gpui's own procedure; on X11 and Wayland a press moves no focus. gpui moves no window and places its popups only on Wayland, so each platform keeps her there its own way: on macOS gpui's popup panel is made the main window's child, at its level and in its Spaces, without a shadow, so AppKit moves it and hides it with the window; on Windows gpui's popup is owned by the main window and loses the topmost place gpui gives it, so it stays just above the main window and hides with it; gpui makes it an overlapped window, which Windows gives a caption and a frame, and opens it as large as the display when the middle of the box asked for lies off every display, as hers does over a window near the screen's top: in 0.1.4 a user found her at the bottom of a framed window the display's size, which took the clicks meant for the windows behind; so it becomes a bare popup, without caption, frame, corners or shadow, and the glue posts her window her bottom left corner on the pane's top edge as the main window moves, since Windows sends a new size at once and gpui hears it only outside its own updates, as gpui defers its own resize; her window sizes her at the DPI it has when the place lands, so her height is her own DPI's and her bottom the pane's top, whatever the two windows' DPI, and a place posted before her DPI changed sizes her at the new one; at a new DPI gpui takes the rect Windows suggests, and once the move that brought it is done she takes her box at that DPI on the corner her last place gave her, while that DPI is still hers and the box lies mostly on a display of it, as Windows reckons a window's display, else Windows' rect stands; so a keep changes no DPI and posts no other, where between two displays a corner kept always, or a keep whose DPI had passed while places piled up, set her DPI back, and back again; on X11 gpui's popup, which no window manager places, is moved onto the pane, stacked just above the window manager's frame as the window moves or comes forward, and unmapped while it hides; on Wayland she is gpui's anchored popup, an xdg_popup on the pane's top edge, which the compositor keeps there; gpui moves a popup's anchor never, so a new width that moves her place opens her again. She opens with the sessions' mood and moves all the time, as the films move her, which the owner asked for after 0.1.4, where she stood still but for a press. She is rigged from the icon's paths as the films rig her, in layers of one color, each an SVG mask gpui rasterizes once at its size and moves with a matrix of its own after its pixels are placed, so she moves between pixels; her head bobs with her breath while her neck, collar and hands stay on the edge, her side locks sway, her ahoge breathes, and she blinks on her own, at times twice. Her mood sets her act: calm, she glances left, then right, her head after her eyes, and dozes after 5 minutes, her eyes falling shut, her head and ahoge drooping, zs floating up; a program that works runs her eyes along the terminal's lines while her fingers tap by turns; one that waits on you widens her eyes, opens her mouth, rocks a mark beside her and hops her twice every 2.6 s; an error raises her brows and shakes her, more softly every 4.5 s after, while a drop runs down; a finish cheers her for 4 s with smiling eyes, a hop and sparkles; a press winks at you while a heart rises. Each act begins with her eyes opening and her mouth popping, and a change of act, a change of mood though the act stays, as inside a wink, and a press each ease her from the pose she was in, in 0.3 s, her brows with her, the marks she leaves fading. She draws at every frame the display gives her window while it shows, and gpui gives a hidden window none; her time is folded into each move's round in f64, so weeks of one mood keep her steps fine. Settings › Terminal › Ely turns her off; she is on by default.
|
||||
- 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.
|
||||
- 2026-10-19: a plain click on the line the shell reads moves its cursor there, which a user asked for after 0.1.6, as Ghostty's `cursor-click-to-move`: a press with no modifier, one click, let go in its own cell with no selection, while OSC 133 says the shell reads a line, B since the last A or C, and the program tracks no mouse at the release. A shell whose prompt start says `click_events=1` or `2`, as fish from 4.1 says on its own, hears a click past the prompt's last cell before the cursor, or, where no prompt cell of its own line comes before the input, as under fish's empty prompt, from the first cell of the newest line typed, known by its number, not its place, since a prompt drawn at the screen's top leaves older lines below it, down to that line's last row or the cursor's, and never on a prompt cell, itself, a left press in SGR, its row the screen's or counted from the prompt's first, as kitty and Ghostty send it, and moves its own cursor, combining marks and its own redraws included: fish moves by code point, and its redraws drop the wrap flags a count would follow. Any other shell gets Left or Right arrows, one per cell, each pressed and let go as the keyboard would, through the key encoder, so DECCKM, kitty's flags and win32-input-mode shape them; the count runs only on the cursor's line, soft wraps joined, past the prompt's cells, which the prompt's link marks, so bash 3.2, which sends no C, moves nothing once its command left the prompt's line. Cells after the last character typed count only up to the cursor, so a click past the end goes to the end; a wide character takes one arrow. Never on the alternate screen. One line only, as Ghostty's `cl=line`. A right prompt or an autosuggestion past the typed text counts as text, as in Ghostty, so a click on it may take the suggestion. The first click of a double click moves the cursor before the word selects, as in Ghostty.
|
||||
- 2026-10-19: files dropped on the terminal type their paths, which a user asked for after 0.1.6, as macOS Terminal does: each path followed by a space, the paths of one drop in its order, sent as a paste, bracketed when the program asked, and kept literal for the shell the session started, which its name says, as the integration reads it: for sh, bash, zsh, fish and any other, each character Ghostty escapes gets a backslash, and `^` and `~` too, which zsh's `extendedglob` reads; for pwsh and powershell, the default on Windows, single quotes, each single quote inside doubled, PowerShell's curly ones too, since PowerShell reads a backslash as a plain character and expands `$` inside double quotes; for cmd, double quotes, which keep `&`, `|`, `<`, `>` and `^` inert, the backslashes ending a path doubled, since a program's parser reads `\"` as a quote, as `C:\` ends, and a path holding `%`, or `!`, which cmd expands inside them under `/v:on`, stops the drop. Windows Terminal's quotes around a path with a space alone leave `$(...)` and `;` live in PowerShell. A program run inside the session, `cmd` under PowerShell among them, gets the session's own quoting. A path not in UTF-8, or holding a control character, which would act as it is typed, stops the drop, logged. gpui hands a drop to the hitbox under the pointer only while the last input was not the keyboard, and a file drag counts as no input of its own: after typing, a drop with no pointer move over the window since does nothing, and nothing in ELYTerminal sees it, which the owner chose to leave so. `shot`'s `drop@x,y` plays a drop through `Window::dispatch_event`, so AppKit's drag session itself goes untested.
|
||||
- 2026-10-19: fallback fonts, which a user asked for after 0.1.6, since the Chinese and Latin fonts people like seldom come as one: `font_fallbacks` in the settings lists installed families, tried in order for a character Font family lacks, through gpui's `Font::fallbacks`, which CoreText's cascade list, DirectWrite's fallback builder and cosmic-text's chain each honor; the system's own fallback follows the last. On Linux gpui's cosmic-text path keeps the fallback the last character took while it covers the next, so shaping clusters stay whole: with two fallbacks, a character only the second covers carries the next one, which the first covers too, into the second; macOS and Windows choose per character. The owner chose to leave that so. Each family must load as the terminal draws it, as Font family must, so a family gpui would pass over without a word stops the start or the save, the error naming `font_fallbacks`; an empty name is refused. The field is new beside `font_family`, so a settings file from before loads as it was. Settings › Appearance lists them under Family: each an Ely `FontPicker` whose pick replaces it and a button that removes it, then Add fallback, which opens an empty picker whose pick adds one, its button closing it; a refused pick shows the saved family again, as Family's does. Each row keeps an input of its own, and a removal drops the removed row's input and takes the focus away, since Ely's combobox keeps a focused input's text, and an input kept by place would show, and on Enter save, the family removed. Each picker's id is its input's entity, not its place: Ely's combobox keeps its state by id and listens to the input it first met, so a picker keyed by place, after a removal moved the inputs, wrote another row's family into its new input at the next blur. Ligatures and the bundled Nerd Font for private-use icons stand as before; the cursor preview keeps the family alone.
|
||||
- 2026-10-19: a user found the IME's candidate box jumping on Windows after 0.1.6. gpui places it, through `ImmSetCompositionWindow` and `ImmSetCandidateWindow`, only when a composition starts, at the caret the terminal reports, the grid cursor's cell from the last frame, hidden or not. On the owner's PC, WeType under powershell.exe and under a status line redrawn every 100 ms put the box at the caret in 23 compositions out of 23, so the owner closed it unreproduced. The terminal logs each caret the input method asks for, at debug, and each move of the caret, at trace, so the next report carries where the box was told to go.
|
||||
- 2026-10-19: Ely's shirt and back hair run on below the edge she holds, which a user asked for after 0.1.6, where a hop lifted her whole figure off the window and showed a gap under her collar and hands: both continue down to 340 in the icon's units, 54 below the edge at 286, past her highest lift, a hop of 31 with a press's 0.6 of one on it, so a hop shows more of her and never the gap; at rest the part below the edge lies outside her window and does not show. The shirt moves with her body, the hair with her head, as before; the hair behind her hands fills under them as she lifts.
|
||||
- 2026-10-19: a double press on a session's row renames it, which a user asked for after 0.1.6. Ely's `ListItem::editor`, added to Ely for this, draws Ely's text field in the title's place; the row's first press selects the session, as ever, and its second opens the field, holding what the session is called, selected, with the focus. Enter keeps what it holds, Escape drops it, and a blur keeps it, as Windows Terminal's tab rename does; whenever the field closes while it still holds the window's focus, the shown terminal takes it, and a press that took the focus elsewhere keeps it there. A window that goes inactive blurs the field and keeps its focus there, so a field closed by that blur, focus handed to nothing, left the terminal deaf to keys once the window came back. A press inside the open field stays the field's: the row would select its session, which takes the focus to the terminal and ends the rename. The name, its ends trimmed, stands over the program's title in the row, the terminal's header and the window's title until renamed again, a program's later titles passing under it; an empty one gives the title back to the program. It lives as long as the session, and notifications keep the program's title. Each program title, each rename and its end are logged.
|
||||
- 2026-10-19: on Windows 10 a blurred window's acrylic clears while the window moves or sizes, which a user asked for in issue #3 after 0.1.5, where dragging the window trailed the pointer by seconds: that DWM samples the acrylic again at each step of the move loop, while gpui draws through it every 10 ms, so the moves pile up; Windows 11's DWM does not, and keeps its acrylic. At start `RtlGetVersion` reads the build; below 22000 the main window's procedure is ELYTerminal's own, passing every message on to gpui's: at `WM_ENTERSIZEMOVE` a blurred window takes the accent gpui gives Transparent, a clear gradient, and at `WM_EXITSIZEMOVE` gpui's acrylic again, `SWP_FRAMECHANGED` telling DWM, both through `SetWindowCompositionAttribute` as gpui calls it. A window property marks the window blurred, set as the blur setting changes, so an opaque or clear window moves as it is; another marks it moving, from `WM_ENTERSIZEMOVE`, which Windows sends once, to `WM_EXITSIZEMOVE`, so a window blurred mid-move, which gpui gives acrylic at once, takes the clear accent again until the move ends. The log names which way the build goes and each switch. On Windows 11, with the build check raised, a drag by the terminal's header logged both switches and showed the glass clear mid-drag and blurred at rest; Windows 10 itself went untested, the fix following the issue's own trial.
|
||||
- 2026-10-20: the repo moved from Gitea to GitHub, `ELY-Open-Source/ELYTerminal`, public, at the owner's ask: history, tags v0.1.0 to v0.1.6 and their releases with every build came along. CI runs macOS on the org's self-hosted `macos-arm64` runner, Windows on `ely-windows`, and Linux on GitHub's hosted `ubuntu-24.04` inside the Gitea job's own image, `docker.gitea.com/runner-images:ubuntu-24.04`, testing as a user it makes: on the hosted image itself zsh's `compinit` stopped at insecure folders and fish's prompt tripped over its hg. Release runs on `macos-signing` and needs the repo secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`. The update check reads GitHub's `releases/latest`, which skips drafts and prereleases.
|
||||
Generated
+2
-2
@@ -1701,7 +1701,7 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git?rev=da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721#da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721"
|
||||
source = "git+https://github.com/ELY-Open-Source/Ely-GPUI-Components.git?rev=7f7a715d797d8450eed4f17ae8002b5427d01d28#7f7a715d797d8450eed4f17ae8002b5427d01d28"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -1747,7 +1747,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "elyterminal"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+5
-5
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "elyterminal"
|
||||
version = "0.1.4"
|
||||
version = "0.1.7"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
license = "MIT"
|
||||
description = "A terminal on GPUI and Ely."
|
||||
repository = "https://git.zacharyzhang.com/ZacharyZhang-NY/ELYTerminal"
|
||||
repository = "https://github.com/ELY-Open-Source/ELYTerminal"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
@@ -13,7 +13,7 @@ alacritty_terminal = { version = "=0.26.0", default-features = false }
|
||||
anyhow = "1"
|
||||
base64 = "0.22"
|
||||
dirs = "7"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git", rev = "da3fdcaf39f2fa85ce1dd9cad07c3c81cf1dd721" }
|
||||
ely-gpui-component = { git = "https://github.com/ELY-Open-Source/Ely-GPUI-Components.git", rev = "7f7a715d797d8450eed4f17ae8002b5427d01d28" }
|
||||
env_logger = "0.11"
|
||||
futures = "0.3"
|
||||
gethostname = "1.1.0"
|
||||
@@ -41,7 +41,7 @@ raw-window-handle = "0.6"
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
x11rb = "0.13"
|
||||
x11rb = { version = "0.13", features = ["randr"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dev-dependencies]
|
||||
wayland-protocols = { version = "0.32", features = ["server", "staging"] }
|
||||
@@ -61,7 +61,7 @@ image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
sysinfo = { version = "0.39", default-features = false, features = ["system"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(windows)'.dev-dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] }
|
||||
|
||||
@@ -16,11 +16,11 @@ Chapters run in build order. `TASKS.md` follows them line by line.
|
||||
## 2. Foundation
|
||||
|
||||
- One binary crate, `elyterminal`, edition 2024, Rust 1.95.
|
||||
- Ely from git.zacharyzhang.com by pinned rev. gpui, gpui_platform from crates.io `gpui-pre` at the exact version Ely pins.
|
||||
- Ely from github.com/ELY-Open-Source by pinned rev. gpui, gpui_platform from crates.io `gpui-pre` at the exact version Ely pins.
|
||||
- `alacritty_terminal` 0.26 for the grid, the parser and the pseudo-terminal, as Ely uses it.
|
||||
- Logs through `log` to stderr, `RUST_LOG` sets the level. No log setting in the app.
|
||||
- CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test.
|
||||
- Repo `ELYTerminal` on git.zacharyzhang.com, private until v0.1.1, public since. No credential in any file or commit.
|
||||
- CI on GitHub Actions: macOS builds, tests and lints; Ubuntu and Windows build and test.
|
||||
- Repo `ELY-Open-Source/ELYTerminal` on GitHub, public; it lived on git.zacharyzhang.com until v0.1.7. No credential in any file or commit.
|
||||
|
||||
## 3. Settings model and storage
|
||||
|
||||
@@ -52,14 +52,17 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
- Inside herdr, which passes no OSC 7501 on, herdr's agent states (idle, working, blocked, done) are the session's status records.
|
||||
- Grid: colors (named, 256, truecolor, OSC 4/10/11/12), bold, dim, italic, underline (single, double, curly, dotted, dashed), strikethrough, inverse, hidden, wide characters, combining marks.
|
||||
- Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
- Fonts: JetBrains Mono by default, any installed font by choice, and fallback families tried in order for a character it lacks, a Chinese font after a Latin one among them; Nerd Font icons the font lacks come from a bundled Nerd Font.
|
||||
- Cursor: block, bar, underline, hollow block; blinking per setting and per DECSCUSR; hollow when the window is not focused.
|
||||
- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags; a key Shift makes a symbol of goes as its own key with Shift, the symbol its alternate, as Ghostty sends it, on macOS and Windows. On Windows keys go as win32-input-mode records while ConPTY asks, as Windows Terminal sends them. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default.
|
||||
- IME: marked text drawn at the cursor; committed text sent to the program.
|
||||
- Mouse: select by character, word (double press) and line (triple press); Shift extends; mouse reporting (X10, normal, button, any; SGR and UTF-8); wheel scrolls back, or reports, or sends arrows on the alternate screen.
|
||||
- Click to move: while the shell reads a line, as its OSC 133 marks say, a plain click on that line, past its prompt, moves the shell's cursor there, as Ghostty's `cursor-click-to-move`: a shell that takes clicks, as fish does, hears the click; others get arrow keys.
|
||||
- Clipboard: copy, paste (bracketed when asked), copy on select per setting, OSC 52 writes.
|
||||
- Drop: files dropped on the terminal type their paths at the cursor, as macOS Terminal does, each kept literal for the session's shell and followed by a space.
|
||||
- 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`).
|
||||
- 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.
|
||||
|
||||
## 6. Main window and layout
|
||||
@@ -70,7 +73,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 title shows the active session's title, and no other text.
|
||||
- The pointer over the terminal is the arrow.
|
||||
- Ely, the icon's girl, peeks over the window's top edge from behind it, on macOS, Windows and Linux. She moves all the time, as she does in the films: she breathes, sways and blinks, and how the sessions are doing, waiting, an error, work, a finish or calm, moves her. A press pets her. A switch in Settings, Terminal, turns her off.
|
||||
- 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 hop lifts more of her from behind the edge, never her body off it. 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.
|
||||
- Every control in the title bar takes its own press on every platform, the window buttons Ely draws on Windows and Linux too.
|
||||
|
||||
@@ -78,6 +81,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a
|
||||
|
||||
- A list of sessions. Each row: title, then the working directory, home as `~`.
|
||||
- New session (+ and Cmd-T), close (row's ×, Cmd-W), select (press, Cmd-1…9, Ctrl-Tab and Ctrl-Shift-Tab).
|
||||
- Rename: a double press on a row's title opens a field in its place; Enter keeps the name, Escape drops it. The name shows in the row, the terminal's header and the window's title over the program's own until renamed again; an empty name gives the title back to the program.
|
||||
- A session whose program ends closes its row. The last row closing closes the window.
|
||||
- A bell in a hidden session marks its row.
|
||||
- A row shows its session's status: working, with its progress where known; waiting on the user, for a permission, a question or a login; done; error; and a mark when the last command failed. Hover shows each record's program, title and message. A hidden session that starts waiting marks its row until it is shown.
|
||||
@@ -107,7 +111,7 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
||||
- Appearance
|
||||
- Theme: a grid of previews; on Omarchy, the Omarchy theme first.
|
||||
- Separate dark theme: a switch; when on, a second grid for dark mode.
|
||||
- Font family: a list of installed fonts. Font size. Line height. Ligatures.
|
||||
- Font family: a list of installed fonts. Fallbacks: installed families tried in order for a character the family lacks, each added, changed or removed in a list of pickers. Font size. Line height. Ligatures.
|
||||
- Cursor: style (block, bar, underline, hollow), blink, with a live preview.
|
||||
- Background opacity: a slider. Background blur: a switch.
|
||||
- Padding: horizontal and vertical.
|
||||
@@ -118,12 +122,14 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
||||
- Option as Alt: off, left, right, both (macOS only).
|
||||
- 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.
|
||||
|
||||
## 11. Background blur
|
||||
|
||||
- macOS: gpui's `WindowBackgroundAppearance::Blurred` (NSVisualEffectView).
|
||||
- Windows: gpui's `Blurred` (acrylic accent).
|
||||
- Windows: gpui's `Blurred` (acrylic accent). On Windows 10, whose DWM drags an acrylic window seconds behind the pointer, the glass clears while the window moves or sizes and blurs again when it rests; Windows 11 keeps its acrylic throughout.
|
||||
- Wayland: gpui's `Blurred` speaks `org_kde_kwin_blur`; ELYTerminal adds `ext_background_effect_v1` on the window's surface, which GNOME 51, Plasma 6.7 and Hyprland support. Hyprland honors it only while its own blur is on, which Omarchy 4.0.4 turns off.
|
||||
- X11: ELYTerminal sets `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32) on the window, as Ghostty does; removes it when blur turns off.
|
||||
- A compositor with neither protocol shows transparency without blur. The app logs which path it took.
|
||||
@@ -147,7 +153,7 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in
|
||||
|
||||
## 15. Release
|
||||
|
||||
- A release on Gitea, tagged `vVERSION`, carries three builds: the macOS disk image (Apple silicon, signed with Developer ID and notarized), a Windows zip (x86_64) and a Linux tarball (x86_64).
|
||||
- A release on GitHub, tagged `vVERSION`, carries three builds: the macOS disk image (Apple silicon, signed with Developer ID and notarized), a Windows zip (x86_64) and a Linux tarball (x86_64).
|
||||
- The Windows executable carries the icon. The Linux tarball carries the icon and a desktop entry, and the window names itself after it, `com.zacharyzhang.elyterminal`, as the macOS bundle does.
|
||||
- Each build comes from its platform's script: `scripts/mac-app.sh`, `scripts/windows-zip.ps1`, `scripts/linux-tar.sh`. The macOS one runs on the signing runner, through the Release workflow.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ A terminal for macOS, Windows and Linux, in Rust on GPUI and Ely components. Ses
|
||||
|
||||
## Install
|
||||
|
||||
A release on Gitea carries three builds.
|
||||
A [release on GitHub](https://github.com/ELY-Open-Source/ELYTerminal/releases) carries three builds.
|
||||
|
||||
- macOS 11 or later, Apple silicon: open `ELYTerminal-VERSION.dmg` and drag ELYTerminal into Applications. The app is signed with Developer ID and notarized.
|
||||
- Windows 10 or later, x86_64: unzip `ELYTerminal-VERSION-windows-x86_64.zip` and run `elyterminal.exe`. It is unsigned; if SmartScreen stops it, press More info, then Run anyway.
|
||||
@@ -42,8 +42,9 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra
|
||||
## What it does
|
||||
|
||||
- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol, 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.
|
||||
- Ely peeks over the window's top edge and moves all the time: she breathes, sways and blinks. She follows your sessions: she hops when a program waits on you, shakes at an error, cheers a finish, reads along while a program works, and dozes when all is calm for long. Press her and she winks. Settings › Terminal › Ely turns her off.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -84,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.
|
||||
|
||||
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
|
||||
|
||||
`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.
|
||||
|
||||
@@ -128,4 +128,38 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co
|
||||
|
||||
## 27. Release
|
||||
|
||||
- [ ] T50 v0.1.4 rebuilt with T48 and T49, which the owner asked for in place of a v0.1.5: the version stays 0.1.4; the macOS disk image, the Windows zip and the Linux tarball, each from its script, replace the old ones on the release; the tag moves to their commit; the notes give the new SHA-256 and what changed.
|
||||
- [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
|
||||
|
||||
- [x] 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.
|
||||
|
||||
## 32. User feedback after 0.1.6
|
||||
|
||||
- [x] T59 A plain click on the line typed at the shell prompt moves the cursor there, which a user asked for after 0.1.6, as Ghostty's `cursor-click-to-move`: while the shell's OSC 133 marks say it reads a line, a click that does not drag sends Left or Right arrows, one per character, to the clicked cell of the cursor's line, soft wraps joined; never on the alternate screen, under mouse tracking, with a selection, or before the prompt's end.
|
||||
- [x] T66 Files dropped on the terminal type their paths, which a user asked for after 0.1.6, as macOS Terminal does: each path quoted for the shell, joined by spaces, sent as a paste, on macOS, Windows and Linux.
|
||||
- [x] T60 Fallback fonts, which a user asked for after 0.1.6: `font_fallbacks` in the settings, a list of installed families tried in order for a character Font family lacks, CJK among them; Settings › Appearance lists them under Font family, each an Ely `FontPicker` with a remove button, and an Add button; each saves only when gpui loads it.
|
||||
- [x] T61 On Windows the IME's candidate window stays at the cursor, which a user found jumping after 0.1.6: find why it moves, fix the cause, and log what the input handler tells the IME. Not reproduced on the owner's PC, so the owner closed it; the caret the IME is told is logged.
|
||||
- [x] T62 Ely's body runs on below the edge she holds, which a user asked for after 0.1.6, where a hop lifted her off the window and left a gap under her collar: her shirt continues down, hidden behind the edge, so a hop shows more of her and never a gap.
|
||||
- [x] T63 A double-click on a session's title renames it, which a user asked for after 0.1.6: the row's title turns into a field, Enter keeps the name, Escape drops it; the name shows in the row, the terminal's header and the window's title over the program's own until renamed again, and an empty name gives the title back to the program; it lives as long as the session.
|
||||
- [x] T65 Dragging the window on Windows 10 with Background blur on follows the pointer, which a user found lagging seconds behind in issue #3 after 0.1.5: find where the lag comes from in gpui's acrylic path, fix the cause on Windows 10 alone, log what changes, and reply on the issue.
|
||||
|
||||
## 33. Release
|
||||
|
||||
- [ ] T64 Release v0.1.7 with T59 to T63, T65 and T66: version 0.1.7; the macOS disk image, the Windows zip and the Linux tarball, each from its script; the notes list what changed in English and Chinese.
|
||||
+20
-1
@@ -5,7 +5,11 @@ fn main() {
|
||||
use std::time::Duration;
|
||||
|
||||
use ely_gpui_component::Assets;
|
||||
use elyterminal::{app, settings::window::SettingsWindow, term::Launch};
|
||||
use elyterminal::{
|
||||
app,
|
||||
settings::window::SettingsWindow,
|
||||
term::{Launch, Quote},
|
||||
};
|
||||
use gpui::AnyWindowHandle;
|
||||
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("elyterminal=info"))
|
||||
@@ -25,6 +29,7 @@ fn main() {
|
||||
cwd: std::env::current_dir().expect("a current folder"),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
gpui_platform::application()
|
||||
.with_assets(Assets)
|
||||
@@ -69,6 +74,20 @@ fn main() {
|
||||
aim = settings.expect("a settings window");
|
||||
capture::front(number(aim, cx)).expect("the window on screen");
|
||||
}
|
||||
spec if spec.starts_with("drop@") => {
|
||||
let paths =
|
||||
std::env::var("SHOT_DROP").expect("SHOT_DROP names the paths");
|
||||
let event = typing::drop(&spec[5..], &paths).expect("a drop");
|
||||
// A drag moves over frames before it lands.
|
||||
for step in event {
|
||||
aim.update(cx, |_, window, cx| window.dispatch_event(step, cx))
|
||||
.expect("the window lives");
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(120))
|
||||
.await;
|
||||
}
|
||||
log::info!("shot: dropped {paths:?} at {}", &spec[5..]);
|
||||
}
|
||||
spec => typing::post(spec, number(aim, cx)).expect("a key event"),
|
||||
}
|
||||
if spec.starts_with("to:") {
|
||||
|
||||
@@ -314,3 +314,20 @@ fn key(spec: &str, number: u32, kinds: &[u64]) -> anyhow::Result<()> {
|
||||
log::info!("shot: typed {spec}, the app {state}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file drop of `paths`, one a line, at `at`, `x,y` from the content's top-left, as gpui's AppKit view sends it.
|
||||
pub fn drop(at: &str, paths: &str) -> anyhow::Result<[gpui::PlatformInput; 4]> {
|
||||
use gpui::{ExternalPaths, FileDropEvent, PlatformInput, point, px};
|
||||
let (x, y) = at
|
||||
.split_once(',')
|
||||
.with_context(|| format!("a point in {at}"))?;
|
||||
let position = point(px(x.parse()?), px(y.parse()?));
|
||||
let paths = ExternalPaths(paths.lines().map(Into::into).collect());
|
||||
Ok([
|
||||
FileDropEvent::Entered { position, paths },
|
||||
FileDropEvent::Pending { position },
|
||||
FileDropEvent::Submit { position },
|
||||
FileDropEvent::Ended,
|
||||
]
|
||||
.map(PlatformInput::FileDrop))
|
||||
}
|
||||
@@ -1,32 +1,10 @@
|
||||
#!/bin/sh
|
||||
# release-asset.sh FILE: puts FILE on the Gitea release of this version, a draft too, in place of one of its name.
|
||||
# release-asset.sh FILE: puts FILE on the GitHub release of this version, a draft too, in place of one of its name.
|
||||
set -eu
|
||||
cd "$(dirname "$0")/.."
|
||||
file=$1
|
||||
[ -f "$file" ] || { echo "release-asset: no file $file" >&2; exit 1; }
|
||||
: "${RELEASE_API:?release-asset: RELEASE_API names the repository API, https://HOST/api/v1/repos/OWNER/REPO}"
|
||||
: "${RELEASE_TOKEN:?release-asset: RELEASE_TOKEN is a token that may edit the releases}"
|
||||
version=$(cargo pkgid)
|
||||
version=${version##*[#@]}
|
||||
name=$(basename "$file")
|
||||
auth="Authorization: token $RELEASE_TOKEN"
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl -fsS -H "$auth" "$RELEASE_API/releases?limit=50" > "$work/releases.json"
|
||||
ids=$(python3 -c '
|
||||
import json, sys
|
||||
tag, name, path = sys.argv[1:]
|
||||
found = [r for r in json.load(open(path)) if r["tag_name"] == tag]
|
||||
if len(found) != 1:
|
||||
sys.exit(f"release-asset: {len(found)} releases tagged {tag}")
|
||||
print(found[0]["id"], *(a["id"] for a in found[0]["assets"] if a["name"] == name))
|
||||
' "v$version" "$name" "$work/releases.json")
|
||||
set -- $ids
|
||||
release=$1
|
||||
shift
|
||||
for old in "$@"; do
|
||||
curl -fsS -X DELETE -H "$auth" "$RELEASE_API/releases/$release/assets/$old"
|
||||
echo "release-asset: removed the old $name"
|
||||
done
|
||||
curl -fsS -H "$auth" -F "attachment=@$file" "$RELEASE_API/releases/$release/assets?name=$name" > /dev/null
|
||||
echo "release-asset: $name on release $release, v$version"
|
||||
gh release upload "v$version" "$file" --clobber -R ELY-Open-Source/ELYTerminal
|
||||
echo "release-asset: $(basename "$file") on release v$version"
|
||||
+6
-2
@@ -13,7 +13,7 @@ use crate::{
|
||||
blur, menus,
|
||||
settings::{self, SettingsStore},
|
||||
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
||||
themes,
|
||||
themes, updates,
|
||||
workspace::{self, TITLE, Workspace},
|
||||
};
|
||||
|
||||
@@ -45,15 +45,19 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
);
|
||||
}
|
||||
SettingsStore::init(cx)?;
|
||||
updates::start(cx);
|
||||
ShellIntegration::init(cx)?;
|
||||
input::bind_keys(cx);
|
||||
workspace::bind_keys(cx);
|
||||
settings::window::bind_keys(cx);
|
||||
menus::init(cx);
|
||||
cx.set_app_identity(APP_ID, TITLE);
|
||||
// A press on a notification goes to the window whose session posted it.
|
||||
// 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| {
|
||||
let tag = response.tag;
|
||||
if updates::pressed(&tag, cx) {
|
||||
return;
|
||||
}
|
||||
let pressed = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
|
||||
+19
-2
@@ -6,6 +6,8 @@ use crate::settings::Settings;
|
||||
mod macos;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wayland;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod x11;
|
||||
|
||||
@@ -27,6 +29,8 @@ pub struct Blur {
|
||||
wayland: Option<wayland::Effect>,
|
||||
#[cfg(target_os = "linux")]
|
||||
x11: Option<x11::Effect>,
|
||||
#[cfg(windows)]
|
||||
moves: Option<windows::Moves>,
|
||||
}
|
||||
|
||||
impl Blur {
|
||||
@@ -37,14 +41,23 @@ impl Blur {
|
||||
if shown == WindowBackgroundAppearance::Blurred {
|
||||
macos::blur(window);
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
||||
let _ = window;
|
||||
#[cfg(windows)]
|
||||
let moves = windows::Moves::on(window).unwrap_or_else(|error| {
|
||||
log::error!("{error:#}");
|
||||
None
|
||||
});
|
||||
#[cfg(windows)]
|
||||
if let Some(moves) = &moves {
|
||||
moves.blurred(shown == WindowBackgroundAppearance::Blurred);
|
||||
}
|
||||
Self {
|
||||
shown,
|
||||
#[cfg(target_os = "linux")]
|
||||
wayland: wayland::Effect::on(window),
|
||||
#[cfg(target_os = "linux")]
|
||||
x11: x11::Effect::on(window),
|
||||
#[cfg(windows)]
|
||||
moves,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +79,10 @@ impl Blur {
|
||||
if wanted == WindowBackgroundAppearance::Blurred {
|
||||
macos::blur(window);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(moves) = &self.moves {
|
||||
moves.blurred(wanted == WindowBackgroundAppearance::Blurred);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a Linux compositor's blur on the window's pane while Blurred; each frame, sending only a change.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! On Windows 10 a blurred window's acrylic clears while it moves or sizes: that DWM samples the blur again at each step, and the window trails the pointer by seconds.
|
||||
|
||||
use std::sync::atomic::{AtomicIsize, Ordering};
|
||||
|
||||
use anyhow::{Context as _, bail};
|
||||
use gpui::Window;
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use windows_sys::{
|
||||
Wdk::System::SystemServices::RtlGetVersion,
|
||||
Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
||||
System::{
|
||||
LibraryLoader::{GetModuleHandleW, GetProcAddress},
|
||||
SystemInformation::OSVERSIONINFOW,
|
||||
},
|
||||
UI::WindowsAndMessaging::{
|
||||
CallWindowProcW, GWLP_WNDPROC, GetPropW, RemovePropW, SWP_FRAMECHANGED, SWP_NOACTIVATE,
|
||||
SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SetPropW, SetWindowLongPtrW, SetWindowPos,
|
||||
WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, WM_NCDESTROY, WNDPROC,
|
||||
},
|
||||
},
|
||||
core::{BOOL, PCWSTR},
|
||||
s, w,
|
||||
};
|
||||
|
||||
/// Windows 11's first build, whose DWM moves an acrylic window as fast as a clear one.
|
||||
const ELEVEN: u32 = 22000;
|
||||
|
||||
/// gpui's window procedure, which the main windows' pass every message on to.
|
||||
static GPUI: AtomicIsize = AtomicIsize::new(0);
|
||||
|
||||
/// A window's property while it is blurred, which its moves read.
|
||||
const BLURRED: PCWSTR = w!("ElyTerminalBlurred");
|
||||
/// A window's property while it moves or sizes, which a change of its blur reads.
|
||||
const MOVING: PCWSTR = w!("ElyTerminalMoving");
|
||||
|
||||
/// gpui's accents: acrylic for Blurred, a clear gradient for Transparent.
|
||||
const ACRYLIC: u32 = 4;
|
||||
const CLEAR: u32 = 2;
|
||||
|
||||
/// A main window on Windows 10, whose moves clear its acrylic.
|
||||
pub struct Moves {
|
||||
window: HWND,
|
||||
}
|
||||
|
||||
impl Moves {
|
||||
/// Takes the window's moves on Windows 10; none on 11 and later.
|
||||
pub fn on(window: &Window) -> anyhow::Result<Option<Self>> {
|
||||
let build = build()?;
|
||||
if build >= ELEVEN {
|
||||
log::info!("blur: Windows build {build} moves acrylic as it is");
|
||||
return Ok(None);
|
||||
}
|
||||
let window = native(window)?;
|
||||
let gpui = unsafe { SetWindowLongPtrW(window, GWLP_WNDPROC, moving as *const () as isize) };
|
||||
anyhow::ensure!(gpui != 0, "blur: the window's procedure does not read");
|
||||
let kept = GPUI.swap(gpui, Ordering::Relaxed);
|
||||
anyhow::ensure!(
|
||||
kept == 0 || kept == gpui,
|
||||
"blur: gpui's windows have two procedures"
|
||||
);
|
||||
log::info!("blur: on Windows build {build} acrylic clears while the window moves");
|
||||
Ok(Some(Self { window }))
|
||||
}
|
||||
|
||||
/// Notes whether the window is blurred, as its moves read it; one blurred mid-move stays clear until the move ends.
|
||||
pub fn blurred(&self, on: bool) {
|
||||
if !on {
|
||||
unsafe { RemovePropW(self.window, BLURRED) };
|
||||
return;
|
||||
}
|
||||
if unsafe { SetPropW(self.window, BLURRED, 1 as _) } == 0 {
|
||||
log::error!("blur: the window's blurred mark does not set");
|
||||
}
|
||||
if !unsafe { GetPropW(self.window, MOVING) }.is_null() {
|
||||
match accent(self.window, CLEAR) {
|
||||
Ok(()) => log::debug!("blur: blurred mid-move, the window moves clear"),
|
||||
Err(error) => log::error!("{error:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's procedure: a blurred window clears its acrylic while it moves; all else is gpui's.
|
||||
unsafe extern "system" fn moving(
|
||||
window: HWND,
|
||||
message: u32,
|
||||
wparam: WPARAM,
|
||||
lparam: LPARAM,
|
||||
) -> LRESULT {
|
||||
let gpui: WNDPROC = unsafe { std::mem::transmute(GPUI.load(Ordering::Relaxed)) };
|
||||
let blurred = !unsafe { GetPropW(window, BLURRED) }.is_null();
|
||||
match message {
|
||||
WM_ENTERSIZEMOVE => {
|
||||
if unsafe { SetPropW(window, MOVING, 1 as _) } == 0 {
|
||||
log::error!("blur: the window's moving mark does not set");
|
||||
}
|
||||
if blurred {
|
||||
match accent(window, CLEAR) {
|
||||
Ok(()) => log::debug!("blur: the window moves clear"),
|
||||
Err(error) => log::error!("{error:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
WM_EXITSIZEMOVE => {
|
||||
unsafe { RemovePropW(window, MOVING) };
|
||||
if blurred {
|
||||
match accent(window, ACRYLIC) {
|
||||
Ok(()) => log::debug!("blur: the window rests blurred again"),
|
||||
Err(error) => log::error!("{error:#}"),
|
||||
}
|
||||
let flags =
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED;
|
||||
if unsafe { SetWindowPos(window, std::ptr::null_mut(), 0, 0, 0, 0, flags) } == 0 {
|
||||
log::error!("blur: DWM hears of no new frame after the move");
|
||||
}
|
||||
}
|
||||
}
|
||||
WM_NCDESTROY => {
|
||||
unsafe { RemovePropW(window, BLURRED) };
|
||||
unsafe { RemovePropW(window, MOVING) };
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
unsafe { CallWindowProcW(gpui, window, message, wparam, lparam) }
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct AccentPolicy {
|
||||
state: u32,
|
||||
flags: u32,
|
||||
color: u32,
|
||||
animation: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct CompositionData {
|
||||
attribute: u32,
|
||||
data: *mut AccentPolicy,
|
||||
size: usize,
|
||||
}
|
||||
|
||||
/// Sets the window's accent as gpui's `set_window_composition_attribute` sets it.
|
||||
fn accent(window: HWND, state: u32) -> anyhow::Result<()> {
|
||||
type Set = unsafe extern "system" fn(HWND, *mut CompositionData) -> BOOL;
|
||||
let user32 = unsafe { GetModuleHandleW(w!("user32.dll")) };
|
||||
anyhow::ensure!(!user32.is_null(), "blur: user32.dll is not loaded");
|
||||
let found = unsafe { GetProcAddress(user32, s!("SetWindowCompositionAttribute")) }
|
||||
.context("blur: user32.dll has no SetWindowCompositionAttribute")?;
|
||||
let set: Set = unsafe { std::mem::transmute(found) };
|
||||
let acrylic = state == ACRYLIC;
|
||||
let mut policy = AccentPolicy {
|
||||
state,
|
||||
flags: if acrylic { 0 } else { 2 },
|
||||
color: if acrylic { 1 << 24 } else { 0 },
|
||||
animation: 0,
|
||||
};
|
||||
let mut data = CompositionData {
|
||||
attribute: 0x13,
|
||||
data: &mut policy,
|
||||
size: size_of::<AccentPolicy>(),
|
||||
};
|
||||
anyhow::ensure!(
|
||||
unsafe { set(window, &mut data) } != 0,
|
||||
"blur: the window's accent {state} does not set"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The running Windows' build.
|
||||
fn build() -> anyhow::Result<u32> {
|
||||
let mut version: OSVERSIONINFOW = unsafe { std::mem::zeroed() };
|
||||
version.dwOSVersionInfoSize = size_of::<OSVERSIONINFOW>() as u32;
|
||||
let status = unsafe { RtlGetVersion(&mut version) };
|
||||
anyhow::ensure!(status == 0, "blur: RtlGetVersion fails with {status:#x}");
|
||||
Ok(version.dwBuildNumber)
|
||||
}
|
||||
|
||||
/// The HWND gpui made for `window`.
|
||||
fn native(window: &Window) -> anyhow::Result<HWND> {
|
||||
let handle = HasWindowHandle::window_handle(window)
|
||||
.map_err(|error| anyhow::anyhow!("blur: no native handle: {error:?}"))?;
|
||||
let RawWindowHandle::Win32(win32) = handle.as_raw() else {
|
||||
bail!("blur: not a Win32 window");
|
||||
};
|
||||
Ok(win32.hwnd.get() as HWND)
|
||||
}
|
||||
+2
-2
@@ -25,9 +25,9 @@ fn line<A: AsRef<OsStr>>(args: &[A]) -> String {
|
||||
args.join(" ")
|
||||
}
|
||||
|
||||
/// Keeps Windows from opening a console window for git.
|
||||
/// Keeps Windows from opening a console window for git and curl.
|
||||
#[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.
|
||||
fn git<A: AsRef<OsStr>>(folder: &Path, args: &[A], input: &[u8]) -> anyhow::Result<Output> {
|
||||
|
||||
@@ -12,4 +12,5 @@ mod testlog;
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod testx11;
|
||||
pub mod themes;
|
||||
pub mod updates;
|
||||
pub mod workspace;
|
||||
+220
-150
@@ -1,20 +1,27 @@
|
||||
//! Ely, the icon's girl, peeking over the main window's top edge from a window of her own.
|
||||
//! 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::Instant};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
rc::Rc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, Context, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Render, Styled, Subscription, Window, WindowBackgroundAppearance,
|
||||
WindowBounds, WindowHandle, WindowKind, WindowOptions, canvas, div, point, popup, px, size,
|
||||
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")]
|
||||
@@ -33,8 +40,18 @@ use crate::{settings::SettingsStore, workspace::Workspace};
|
||||
const WIDTH: f32 = 112.0;
|
||||
const HEAD: f32 = 107.4;
|
||||
const ROOM: f32 = 16.0;
|
||||
/// How far in from the pane's left edge she holds its top edge.
|
||||
const LEFT: f32 = 140.0;
|
||||
/// 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 {
|
||||
@@ -48,8 +65,13 @@ pub struct Pet {
|
||||
act: Act,
|
||||
last: Option<Pose>,
|
||||
eased: Option<(Pose, Instant)>,
|
||||
/// How far in from the pane's left edge she opened.
|
||||
left: f32,
|
||||
/// 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>,
|
||||
@@ -57,7 +79,7 @@ pub struct Pet {
|
||||
|
||||
/// Shows Ely over `main` while the settings want her.
|
||||
pub fn follow(main: WindowHandle<Workspace>, cx: &mut App) {
|
||||
let shown: Rc<RefCell<Option<WindowHandle<Pet>>>> = Rc::default();
|
||||
let shown: Shown = Rc::default();
|
||||
let sync = {
|
||||
let shown = shown.clone();
|
||||
move |cx: &mut App| {
|
||||
@@ -74,25 +96,17 @@ pub fn follow(main: WindowHandle<Workspace>, cx: &mut App) {
|
||||
};
|
||||
sync(cx);
|
||||
cx.observe_global::<SettingsStore>(sync).detach();
|
||||
// A Wayland popup keeps the place it opened at, so she opens again where a new width puts her.
|
||||
let moved = main.update(cx, |_, window, cx| {
|
||||
cx.observe_window_bounds(window, move |_, window, cx| {
|
||||
let Some(pet) = *shown.borrow() else {
|
||||
return;
|
||||
};
|
||||
let at = left(pane(window).size.width);
|
||||
if wayland(window) && pet.read(cx).is_ok_and(|pet| pet.left != at) {
|
||||
let shown = shown.clone();
|
||||
cx.defer(move |cx| {
|
||||
close(pet, "to stand where the new width puts her", cx);
|
||||
*shown.borrow_mut() = open_pet(main, cx);
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
})
|
||||
});
|
||||
match moved {
|
||||
Ok(moved) => moved.detach(),
|
||||
Err(error) => log::error!("pet: the main window's width goes unwatched: {error:#}"),
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,33 +127,93 @@ fn close(pet: WindowHandle<Pet>, why: &str, cx: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far in from the pane's left edge she stands on a pane `width` points wide: `LEFT`, or less on a narrow pane, so she holds its edge.
|
||||
fn left(width: Pixels) -> f32 {
|
||||
LEFT.min(f32::from(width) - WIDTH).max(0.0)
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Opens her window on `main`'s top edge: a popup Wayland's compositor places, else a window kept there.
|
||||
/// 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 (bounds, pane, wayland) = main.update(cx, |_, window, _| {
|
||||
(window.bounds(), pane(window), wayland(window))
|
||||
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);
|
||||
let at = left(pane.size.width, kept);
|
||||
let room = size(px(WIDTH), px(HEAD + ROOM));
|
||||
let kind = if wayland {
|
||||
WindowKind::AnchoredPopup(popup::PopupOptions {
|
||||
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(
|
||||
point(pane.origin.x + px(at), pane.origin.y),
|
||||
size(px(1.), px(1.)),
|
||||
),
|
||||
anchor: popup::PopupAnchor::TopLeft,
|
||||
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 {
|
||||
WindowKind::PopUp
|
||||
(Edge::Top, WindowKind::PopUp)
|
||||
};
|
||||
let origin = bounds.origin + pane.origin + point(px(at), -room.height);
|
||||
let options = WindowOptions {
|
||||
@@ -182,30 +256,40 @@ fn show(main: WindowHandle<Workspace>, cx: &mut App) -> anyhow::Result<WindowHan
|
||||
act: Act::Calm,
|
||||
last: None,
|
||||
eased: None,
|
||||
left: at,
|
||||
edge,
|
||||
anchor: wayland.then_some(spot),
|
||||
at: kept,
|
||||
drag: None,
|
||||
main,
|
||||
glue: None,
|
||||
_watch: vec![feel],
|
||||
}
|
||||
})
|
||||
})?;
|
||||
if !wayland {
|
||||
glue(main, pet, cx)?;
|
||||
}
|
||||
let way = if wayland {
|
||||
"a Wayland popup"
|
||||
let (way, edge) = if wayland {
|
||||
("a Wayland popup", edge)
|
||||
} else {
|
||||
"a window kept there"
|
||||
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)
|
||||
};
|
||||
log::info!("pet: Ely peeks over the main window, {way}, feeling {start:?}");
|
||||
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 top edge as it moves, comes forward and hides.
|
||||
fn glue(main: WindowHandle<Workspace>, pet: WindowHandle<Pet>, cx: &mut App) -> anyhow::Result<()> {
|
||||
/// 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)?;
|
||||
glue.place(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)));
|
||||
@@ -217,7 +301,7 @@ fn glue(main: WindowHandle<Workspace>, pet: WindowHandle<Pet>, cx: &mut App) ->
|
||||
pet.shown(visibility.is_visible(), main)
|
||||
}),
|
||||
);
|
||||
anyhow::Ok(())
|
||||
anyhow::Ok(pet.edge)
|
||||
})?
|
||||
})?
|
||||
}
|
||||
@@ -290,16 +374,75 @@ impl Pet {
|
||||
pose
|
||||
}
|
||||
|
||||
fn place(&self, main: &Window) {
|
||||
if let Some(glue) = &self.glue
|
||||
&& let Err(error) = glue.place(main)
|
||||
{
|
||||
log::error!("{error:#}");
|
||||
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(&self, visible: bool, main: &Window) {
|
||||
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"),
|
||||
@@ -318,107 +461,34 @@ impl Render for Pet {
|
||||
// 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, _, _, cx| {
|
||||
cx.listener(|pet, event: &MouseDownEvent, window, cx| {
|
||||
pet.press(Instant::now());
|
||||
pet.grab(event.position.x, window.scale_factor());
|
||||
cx.notify();
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::{look::Mark, *};
|
||||
|
||||
/// Her, `mood` since `since`, come long before.
|
||||
fn ely(mood: Mood, since: Instant) -> Pet {
|
||||
Pet {
|
||||
rig: Rc::new(Rig::new()),
|
||||
mood,
|
||||
since,
|
||||
shown: since - Duration::from_secs(60),
|
||||
petted: None,
|
||||
act: Act::Calm,
|
||||
last: None,
|
||||
eased: None,
|
||||
left: LEFT,
|
||||
glue: None,
|
||||
_watch: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_change_of_mood_or_a_press_eases_her_from_where_she_is_though_the_act_stays() {
|
||||
let start = Instant::now();
|
||||
let at = |ms: u64| start + Duration::from_millis(ms);
|
||||
// A wait hops her, a press winks, and a program starts working inside the wink.
|
||||
let mut pet = ely(Mood::Waiting, start);
|
||||
pet.press(at(2250));
|
||||
let before = pet.pose(at(2750));
|
||||
pet.feel(Mood::Working, at(2750));
|
||||
let after = pet.pose(at(2751));
|
||||
assert_eq!((before.act, after.act), (Act::Wink, Act::Wink));
|
||||
assert!(
|
||||
(after.lift - before.lift).abs() < 1.0,
|
||||
"no drop: {} then {}",
|
||||
before.lift,
|
||||
after.lift
|
||||
);
|
||||
let marks: Vec<Mark> = after.marks.iter().map(|(mark, _)| *mark).collect();
|
||||
assert!(
|
||||
marks.contains(&Mark::Bang),
|
||||
"the wait's mark fades: {marks:?}"
|
||||
);
|
||||
// An old finish clears mid-glance: calm before and after, her eyes turn back at her pace.
|
||||
let mut pet = ely(Mood::Done, start);
|
||||
let before = pet.pose(at(13_600));
|
||||
pet.feel(Mood::Calm, at(13_600));
|
||||
let after = pet.pose(at(13_601));
|
||||
assert_eq!((before.act, after.act), (Act::Calm, Act::Calm));
|
||||
assert!(
|
||||
before.look.0 < -5.0 && (after.look.0 - before.look.0).abs() < 0.5,
|
||||
"no snap: {:?} then {:?}",
|
||||
before.look,
|
||||
after.look
|
||||
);
|
||||
let later = pet.pose(at(14_000));
|
||||
assert!(
|
||||
pet.eased.is_none() && later.look.0.abs() < 0.5,
|
||||
"eased in by then"
|
||||
);
|
||||
// A second press mid-hop restarts the wink without a drop.
|
||||
let mut pet = ely(Mood::Calm, start);
|
||||
pet.press(at(1000));
|
||||
let before = pet.pose(at(1300));
|
||||
pet.press(at(1300));
|
||||
let after = pet.pose(at(1301));
|
||||
assert!(
|
||||
before.lift > 10.0 && (after.lift - before.lift).abs() < 1.0,
|
||||
"no drop: {} then {}",
|
||||
before.lift,
|
||||
after.lift
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_holds_the_edge_of_any_pane() {
|
||||
assert_eq!(left(px(1200.)), LEFT);
|
||||
assert_eq!(left(px(200.)), 200.0 - WIDTH);
|
||||
assert_eq!(
|
||||
left(px(100.)),
|
||||
0.0,
|
||||
"narrower than she is, she stands at its left"
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -37,7 +37,7 @@ pub fn mood(looks: impl IntoIterator<Item = Look>) -> Mood {
|
||||
.unwrap_or(Mood::Calm)
|
||||
}
|
||||
|
||||
/// Seconds she cheers a program that finished, and calm lasts before she dozes.
|
||||
/// 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.
|
||||
@@ -127,14 +127,15 @@ pub struct Pose {
|
||||
pub marks: Vec<(Mark, Motion)>,
|
||||
}
|
||||
|
||||
/// Her pose `held` seconds into `mood`, `shown` seconds after she came, and `petted` seconds after a press.
|
||||
/// 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 held >= DOZE => dozing(held - DOZE),
|
||||
Mood::Calm | Mood::Done if stirred >= DOZE => dozing(stirred - DOZE),
|
||||
Mood::Calm => calm(held),
|
||||
Mood::Done => calm(held - CHEER),
|
||||
};
|
||||
|
||||
@@ -169,6 +169,23 @@ fn a_press_winks_with_a_heart_that_rises() {
|
||||
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);
|
||||
|
||||
+36
-9
@@ -1,18 +1,20 @@
|
||||
//! Ely's window on macOS: a child of the main window at its level, so it moves with it and keeps its layer and Space.
|
||||
//! 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::{
|
||||
msg_send,
|
||||
class, msg_send,
|
||||
runtime::{NO, Object, YES},
|
||||
sel, sel_impl,
|
||||
};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
|
||||
use super::left;
|
||||
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)]
|
||||
@@ -36,10 +38,21 @@ pub struct Glue {
|
||||
}
|
||||
|
||||
impl Glue {
|
||||
/// Makes her window the main window's child, at its level and in its Spaces, with no shadow and never key, so a press leaves the keys with the terminal.
|
||||
/// 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];
|
||||
@@ -51,17 +64,31 @@ impl Glue {
|
||||
Ok(Self { pet, main })
|
||||
}
|
||||
|
||||
/// Stands her on the main window's top edge, as far in as its width allows.
|
||||
pub fn place(&self, _: &Window) -> anyhow::Result<()> {
|
||||
/// 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))),
|
||||
y: frame.origin.y + frame.height,
|
||||
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)
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-2
@@ -61,10 +61,10 @@ const fn part(name: &'static str, color: u32, frame: [f32; 4], bone: Bone, shape
|
||||
/// 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("hair-back", 0xEC86B5, [117.0, 45.0, 278.0, 296.0], Bone::Head, r#"<path d="M256 46C192 46 142 90 136 158C131 214 126 262 118 306L118 340L394 340L394 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("collar", 0xFFFFFF, [193.0, 255.0, 126.0, 86.0], Bone::Body, r#"<path d="M198 284C204 268 222 258 238 256L256 272L274 256C290 258 308 268 314 284L318 340L194 340Z"/>"#),
|
||||
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#""/>"#)),
|
||||
@@ -270,6 +270,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn her_shirt_and_hair_run_below_the_edge_at_her_highest_hop() {
|
||||
use super::super::look::{Mood, pose};
|
||||
let moods = [
|
||||
Mood::Calm,
|
||||
Mood::Done,
|
||||
Mood::Working,
|
||||
Mood::Error,
|
||||
Mood::Waiting,
|
||||
];
|
||||
let mut highest = 0.0_f32;
|
||||
for mood in moods {
|
||||
for held in (0..130).map(|step| f64::from(step) * 0.02) {
|
||||
for petted in [None]
|
||||
.into_iter()
|
||||
.chain((0..31).map(|step| Some(f64::from(step) * 0.02)))
|
||||
{
|
||||
let pose = pose(mood, held, 10.0, petted);
|
||||
highest = highest.max(pose.lift + pose.bob);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
highest > 40.0,
|
||||
"a press inside a hop lifts her {highest} units"
|
||||
);
|
||||
let edge = VIEW[1] + VIEW[3];
|
||||
for name in ["collar", "hair-back"] {
|
||||
let part = PARTS.iter().find(|part| part.name == name).expect(name);
|
||||
let bottom = part.frame[1] + part.frame[3] - highest;
|
||||
assert!(
|
||||
bottom > edge,
|
||||
"{name} ends at {bottom}, above the edge at {edge}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn she_is_drawn_from_every_part_the_icon_gives_her() {
|
||||
let named = |name: &str| PARTS.iter().any(|part| part.name == name);
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
+41
-18
@@ -1,4 +1,4 @@
|
||||
//! Ely's window on Windows: a bare popup owned by the main window, so it stays above it and hides with it, sized and moved as it moves.
|
||||
//! 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,
|
||||
@@ -9,12 +9,14 @@ use anyhow::bail;
|
||||
use gpui::{Bounds, Pixels, Window};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM},
|
||||
Graphics::Gdi::{MONITOR_DEFAULTTONEAREST, MonitorFromRect},
|
||||
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, GetWindowInfo,
|
||||
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,
|
||||
@@ -23,7 +25,7 @@ use windows_sys::Win32::{
|
||||
},
|
||||
};
|
||||
|
||||
use super::{HEAD, ROOM, WIDTH, left, pane};
|
||||
use super::{Edge, HEAD, ROOM, WIDTH, edge, left, pane};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -75,33 +77,54 @@ impl Glue {
|
||||
Ok(Self { pet, main })
|
||||
}
|
||||
|
||||
/// Stands her on the main window's top edge, as far in as its width allows.
|
||||
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
|
||||
self.put(main.scale_factor(), pane(main))
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Posts her bottom left corner on `pane` at `scale`, in the main window's points; her window sizes her at the DPI it has when the place lands, as gpui did not when it opened her off the display.
|
||||
fn put(&self, scale: f32, pane: Bounds<Pixels>) -> anyhow::Result<()> {
|
||||
/// 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 corner = stand(main.rcClient, scale, pane);
|
||||
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:?}");
|
||||
Ok(())
|
||||
log::debug!("pet: Ely is to stand on {corner:?}, the pane's {edge:?} edge");
|
||||
Ok(edge)
|
||||
}
|
||||
}
|
||||
|
||||
/// Her bottom left corner on `pane` at `scale` over the main window's `client` rect, in pixels.
|
||||
fn stand(client: RECT, scale: f32, pane: Bounds<Pixels>) -> (i32, i32) {
|
||||
let (x, y) = (f32::from(pane.origin.x), f32::from(pane.origin.y));
|
||||
/// 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 + left(pane.size.width)) * scale).round() as i32,
|
||||
client.top + (y * scale).round() as i32,
|
||||
client.left + (x * scale).round() as i32,
|
||||
client.top + (f32::from(y) * scale).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+58
-22
@@ -42,6 +42,28 @@ fn deliver(window: HWND) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
@@ -70,7 +92,7 @@ unsafe extern "system" fn standin(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn her_bottom_stands_on_the_panes_top_edge_whatever_the_two_windows_dpi() {
|
||||
fn her_bottom_stands_on_either_edge_of_the_pane_whatever_the_two_windows_dpi() {
|
||||
let client = RECT {
|
||||
left: 100,
|
||||
top: 20,
|
||||
@@ -79,24 +101,26 @@ fn her_bottom_stands_on_the_panes_top_edge_whatever_the_two_windows_dpi() {
|
||||
};
|
||||
let pane = Bounds::new(point(px(10.), px(8.)), size(px(800.), px(600.)));
|
||||
for (scale, dpi) in [(1.0, 96), (1.0, 144), (1.5, 96), (2.0, 120)] {
|
||||
let her = boxed(stand(client, scale, pane), dpi);
|
||||
let own = dpi as f32 / 96.0;
|
||||
let own_box = (
|
||||
(WIDTH * own).round() as i32,
|
||||
((HEAD + ROOM) * own).round() as i32,
|
||||
);
|
||||
let corner = (
|
||||
100 + ((10.0 + left(px(800.))) * scale).round() as i32,
|
||||
20 + (8.0 * scale).round() as i32,
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
(her.right - her.left, her.bottom - her.top),
|
||||
(her.left, her.bottom)
|
||||
),
|
||||
(own_box, corner),
|
||||
"the main window at {scale}, hers at {dpi} DPI"
|
||||
);
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +258,7 @@ fn places_that_pile_up_across_two_displays_drain_in_her_dpis_size() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edge() {
|
||||
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);
|
||||
@@ -245,8 +269,10 @@ fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edg
|
||||
);
|
||||
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.)));
|
||||
glue.put(1.0, pane).expect("placed");
|
||||
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));
|
||||
@@ -269,6 +295,11 @@ fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edg
|
||||
};
|
||||
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);
|
||||
@@ -286,7 +317,7 @@ fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edg
|
||||
((HEAD + ROOM) * own).round() as i32,
|
||||
);
|
||||
let edge = (
|
||||
under.rcClient.left + left(px(380.)).round() as i32,
|
||||
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);
|
||||
@@ -311,4 +342,9 @@ fn the_large_framed_popup_gpui_makes_stands_bare_in_her_box_on_the_panes_top_edg
|
||||
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"
|
||||
);
|
||||
}
|
||||
+174
-24
@@ -1,20 +1,24 @@
|
||||
//! Ely's window on X11: gpui's popup, which no window manager places, put on the main window's top edge and just above it.
|
||||
//! 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};
|
||||
use gpui::{Bounds, Pixels, Window, px};
|
||||
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
|
||||
use x11rb::{
|
||||
protocol::xproto::{ConfigureWindowAux, ConnectionExt as _, StackMode},
|
||||
protocol::{
|
||||
randr::ConnectionExt as _,
|
||||
xproto::{Atom, AtomEnum, ConfigureWindowAux, ConnectionExt as _, StackMode},
|
||||
},
|
||||
rust_connection::RustConnection,
|
||||
};
|
||||
|
||||
use super::{HEAD, ROOM, left, pane};
|
||||
use super::{Edge, HEAD, ROOM, edge, left, pane};
|
||||
|
||||
/// Her X window, the main one, and a connection of her own to their server.
|
||||
/// 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 {
|
||||
@@ -22,35 +26,99 @@ impl Glue {
|
||||
let (pet, main) = (native(pet)?, native(main)?);
|
||||
// gpui opens $DISPLAY too; a window's place belongs to whoever asks.
|
||||
let (connection, _) = x11rb::connect(None).context("pet: no X server")?;
|
||||
Ok(Self {
|
||||
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, as far in as its width allows, stacked just above the main window's frame.
|
||||
pub fn place(&self, main: &Window) -> anyhow::Result<()> {
|
||||
self.put(main.scale_factor(), pane(main))
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Places her at `scale` over `pane`, in the main window's points.
|
||||
fn put(&self, scale: f32, pane: Bounds<Pixels>) -> anyhow::Result<()> {
|
||||
let (x, y) = (f32::from(pane.origin.x), f32::from(pane.origin.y));
|
||||
/// 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 at = (self.connection)
|
||||
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(i32::from(at.dst_x) + ((x + left(pane.size.width)) * scale).round() as i32)
|
||||
.y(i32::from(at.dst_y) + ((y - HEAD - ROOM) * scale).round() as i32)
|
||||
.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")
|
||||
.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.
|
||||
@@ -78,6 +146,21 @@ impl Glue {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@@ -93,7 +176,8 @@ mod tests {
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection as _,
|
||||
protocol::xproto::{CreateWindowAux, MapState, WindowClass},
|
||||
protocol::xproto::{CreateWindowAux, MapState, PropMode, WindowClass},
|
||||
wrapper::ConnectionExt as _,
|
||||
};
|
||||
|
||||
use gpui::{point, px, size};
|
||||
@@ -102,7 +186,45 @@ mod tests {
|
||||
use crate::testx11::Xvfb;
|
||||
|
||||
#[test]
|
||||
fn she_stands_on_the_pane_just_above_the_frame_and_hides_with_it() {
|
||||
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;
|
||||
@@ -140,17 +262,23 @@ mod tests {
|
||||
&CreateWindowAux::new().override_redirect(1),
|
||||
);
|
||||
// A window manager's frame, its title bar 20 pixels tall, round the main window.
|
||||
let frame = window(root, (100, 80, 800, 620), &plain);
|
||||
let 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.)));
|
||||
glue.put(2.0, pane).expect("she is placed");
|
||||
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;
|
||||
@@ -159,11 +287,15 @@ mod tests {
|
||||
.expect("the request")
|
||||
.reply()
|
||||
.expect("her place");
|
||||
let tall = ((HEAD + ROOM) * 2.0).round() as i32;
|
||||
let stands = (
|
||||
100 + ((10.0 + left(px(380.))) * 2.0).round() as i32,
|
||||
100 + ((10.0 - HEAD - ROOM) * 2.0).round() as i32,
|
||||
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)
|
||||
);
|
||||
assert_eq!((i32::from(at.x), i32::from(at.y)), stands);
|
||||
let order = check
|
||||
.query_tree(root)
|
||||
.expect("the request")
|
||||
@@ -193,5 +325,23 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
-7
@@ -5,6 +5,7 @@ use std::{
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
forms::{Input, TextInput},
|
||||
lists::ListItem,
|
||||
primitives::{Icon, IconName, Tooltip},
|
||||
theme::{ActiveTheme, ControlSize, IconSize},
|
||||
@@ -25,6 +26,10 @@ pub struct Session {
|
||||
pub terminal: Entity<TerminalView>,
|
||||
/// The program's title; empty while it names none.
|
||||
pub title: String,
|
||||
/// The name the user gave it; empty leaves the title to its program.
|
||||
pub name: String,
|
||||
/// The field its row shows while the user renames it.
|
||||
pub editing: Option<(Entity<TextInput>, Subscription)>,
|
||||
/// The folder it reports, else the one it started in.
|
||||
pub folder: PathBuf,
|
||||
/// Its shell has integration, or it reported a folder anyway.
|
||||
@@ -39,6 +44,14 @@ pub struct Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// What it is called: the user's name, else its program's title, else the app's name.
|
||||
pub fn named(&self) -> &str {
|
||||
[self.name.as_str(), self.title.as_str()]
|
||||
.into_iter()
|
||||
.find(|title| !title.is_empty())
|
||||
.unwrap_or(TITLE)
|
||||
}
|
||||
|
||||
/// The look its mark takes from its terminal now.
|
||||
pub fn looks(&self, cx: &App) -> Option<Look> {
|
||||
let view = self.terminal.read(cx);
|
||||
@@ -103,17 +116,20 @@ pub fn row(
|
||||
) -> ListItem {
|
||||
let terminal = session.terminal.read(cx);
|
||||
let pid = terminal.pid() as usize;
|
||||
let title = if session.title.is_empty() {
|
||||
TITLE
|
||||
} else {
|
||||
&session.title
|
||||
};
|
||||
let (select, close) = (workspace.clone(), workspace.clone());
|
||||
let item = ListItem::new(("session", pid), title.to_string())
|
||||
let editing = session.editing.is_some();
|
||||
let item = ListItem::new(("session", pid), session.named().to_string())
|
||||
.description(shown(&session.folder, home))
|
||||
.selected(active)
|
||||
.strong(session.bell || session.waiting)
|
||||
.on_click(move |_, window, cx| select.update(cx, |this, cx| this.select(index, window, cx)))
|
||||
// A press in the field it renames with stays the field's.
|
||||
.on_click(
|
||||
move |event, window, cx| match (editing, event.click_count()) {
|
||||
(true, _) => {}
|
||||
(false, 2..) => select.update(cx, |this, cx| this.rename(index, window, cx)),
|
||||
(false, _) => select.update(cx, |this, cx| this.select(index, window, cx)),
|
||||
},
|
||||
)
|
||||
.trailing(
|
||||
IconButton::new(("close-session", pid), IconName::X)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
@@ -124,6 +140,10 @@ pub fn row(
|
||||
close.update(cx, |this, cx| this.close(index, window, cx))
|
||||
}),
|
||||
);
|
||||
let item = match &session.editing {
|
||||
Some((input, _)) => item.editor(editor(workspace, session, input)),
|
||||
None => item,
|
||||
};
|
||||
let mark = mark(session, now, cx);
|
||||
let bell = session
|
||||
.bell
|
||||
@@ -141,6 +161,23 @@ pub fn row(
|
||||
)
|
||||
}
|
||||
|
||||
/// The field a session's row renames it with; Escape drops what it holds.
|
||||
fn editor(
|
||||
workspace: &Entity<Workspace>,
|
||||
session: &Session,
|
||||
input: &Entity<TextInput>,
|
||||
) -> impl IntoElement {
|
||||
let (workspace, id) = (workspace.clone(), session.terminal.entity_id());
|
||||
div()
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if event.keystroke.key == "escape" {
|
||||
cx.stop_propagation();
|
||||
workspace.update(cx, |this, cx| this.end_rename(id, false, window, cx));
|
||||
}
|
||||
})
|
||||
.child(Input::new(input).size(ControlSize::Sm))
|
||||
}
|
||||
|
||||
/// What a mark shows: the most pressing record's state, else a failed last command.
|
||||
pub fn look(record: Option<&Record>, failed: bool) -> Option<Look> {
|
||||
Some(
|
||||
|
||||
@@ -60,6 +60,8 @@ pub struct Settings {
|
||||
/// The theme while the system is dark; none keeps `theme`.
|
||||
pub dark_theme: Option<String>,
|
||||
pub font_family: String,
|
||||
/// Families tried in order for a character Font family lacks.
|
||||
pub font_fallbacks: Vec<String>,
|
||||
pub font_size: f32,
|
||||
pub line_height: f32,
|
||||
pub ligatures: bool,
|
||||
@@ -74,6 +76,10 @@ pub struct Settings {
|
||||
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_panel: bool,
|
||||
pub panel: PanelTab,
|
||||
@@ -93,6 +99,7 @@ impl Default for Settings {
|
||||
theme: themes::default_id().into(),
|
||||
dark_theme: None,
|
||||
font_family: "JetBrains Mono".into(),
|
||||
font_fallbacks: Vec::new(),
|
||||
font_size: 13.0,
|
||||
line_height: 1.2,
|
||||
ligatures: true,
|
||||
@@ -106,6 +113,8 @@ impl Default for Settings {
|
||||
option_as_alt: OptionAsAlt::Off,
|
||||
copy_on_select: false,
|
||||
ely: true,
|
||||
ely_left: 140.0,
|
||||
check_updates: true,
|
||||
show_sessions: true,
|
||||
show_panel: true,
|
||||
panel: PanelTab::Git,
|
||||
@@ -124,6 +133,7 @@ pub const PADDING: RangeInclusive<f32> = 0.0..=64.0;
|
||||
pub const SCROLLBACK: RangeInclusive<u32> = 0..=100_000;
|
||||
pub const COLUMN: RangeInclusive<f32> = 160.0..=960.0;
|
||||
pub const WINDOW: RangeInclusive<f32> = 100.0..=16384.0;
|
||||
pub const ELY_LEFT: RangeInclusive<f32> = 0.0..=16384.0;
|
||||
|
||||
impl Settings {
|
||||
/// Every value inside its range, every theme built in; the error names the field.
|
||||
@@ -138,6 +148,7 @@ impl Settings {
|
||||
within("panel_width", self.panel_width, &COLUMN)?;
|
||||
within("window_width", self.window_width, &WINDOW)?;
|
||||
within("window_height", self.window_height, &WINDOW)?;
|
||||
within("ely_left", self.ely_left, &ELY_LEFT)?;
|
||||
for (field, id) in [
|
||||
("theme", Some(&self.theme)),
|
||||
("dark_theme", self.dark_theme.as_ref()),
|
||||
@@ -147,6 +158,10 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||
anyhow::ensure!(
|
||||
!self.font_fallbacks.iter().any(String::is_empty),
|
||||
"font_fallbacks holds an empty family"
|
||||
);
|
||||
if !self.shell.is_empty() {
|
||||
program_path(&self.shell)?;
|
||||
}
|
||||
@@ -261,6 +276,23 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "panel_width is 1000, outside 160 to 960");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_fonts_are_none_unless_named_and_none_is_empty() {
|
||||
let older: Settings = serde_json::from_str(r#"{"font_family":"Menlo"}"#).expect("parses");
|
||||
assert!(older.font_fallbacks.is_empty(), "a file from before");
|
||||
let named: Settings =
|
||||
serde_json::from_str(r#"{"font_fallbacks":["Kaiti SC","Noto Sans CJK SC"]}"#)
|
||||
.expect("parses");
|
||||
assert_eq!(named.font_fallbacks, ["Kaiti SC", "Noto Sans CJK SC"]);
|
||||
named.validate().expect("named families pass");
|
||||
let empty = Settings {
|
||||
font_fallbacks: vec!["Kaiti SC".into(), String::new()],
|
||||
..Settings::default()
|
||||
};
|
||||
let error = empty.validate().expect_err("an empty family");
|
||||
assert_eq!(error.to_string(), "font_fallbacks holds an empty family");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_names_a_program_or_none() {
|
||||
let shell = |shell: &str| Settings {
|
||||
|
||||
+17
-6
@@ -24,7 +24,7 @@ impl SettingsStore {
|
||||
let folder = dirs::config_dir().context("settings: the platform names no config folder")?;
|
||||
let path = folder.join("ELYTerminal").join("settings.json");
|
||||
let settings = load(&path)?;
|
||||
loads(&settings.font_family, cx)
|
||||
fonts(&settings, cx)
|
||||
.with_context(|| format!("settings: {} holds a bad value", path.display()))?;
|
||||
cx.set_global(Self { settings, path });
|
||||
Ok(())
|
||||
@@ -43,8 +43,10 @@ impl SettingsStore {
|
||||
return Ok(());
|
||||
}
|
||||
next.validate()?;
|
||||
if next.font_family != store.settings.font_family {
|
||||
loads(&next.font_family, cx)?;
|
||||
if (&next.font_family, &next.font_fallbacks)
|
||||
!= (&store.settings.font_family, &store.settings.font_fallbacks)
|
||||
{
|
||||
fonts(&next, cx)?;
|
||||
}
|
||||
save(&store.path, &next)?;
|
||||
cx.global_mut::<Self>().settings = next;
|
||||
@@ -52,9 +54,18 @@ impl SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the terminal can load `family`.
|
||||
fn loads(family: &str, cx: &App) -> anyhow::Result<()> {
|
||||
crate::term::load_font(&gpui::font(family.to_string()), cx.text_system()).map(|_| ())
|
||||
/// Checks the terminal can load its family and each fallback; the error names the field.
|
||||
fn fonts(settings: &Settings, cx: &App) -> anyhow::Result<()> {
|
||||
let fallbacks = settings
|
||||
.font_fallbacks
|
||||
.iter()
|
||||
.map(|family| ("font_fallbacks", family));
|
||||
for (field, family) in std::iter::once(("font_family", &settings.font_family)).chain(fallbacks)
|
||||
{
|
||||
crate::term::load_font(&gpui::font(family.clone()), cx.text_system())
|
||||
.with_context(|| field.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The file's settings; defaults when there is no file. A file that fails to read or check is an error.
|
||||
|
||||
+13
-2
@@ -17,7 +17,7 @@ use gpui::{
|
||||
};
|
||||
|
||||
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 terminal;
|
||||
@@ -96,10 +96,15 @@ pub struct SettingsWindow {
|
||||
/// Refusals so far; a field keyed by it drops its draft and shows the settings again.
|
||||
drafts: usize,
|
||||
font: Entity<TextInput>,
|
||||
/// A picker's input for each fallback family, and one for the family being added.
|
||||
fallbacks: Vec<Entity<TextInput>>,
|
||||
/// The empty picker that adds a fallback shows.
|
||||
adding: bool,
|
||||
/// Whether the cursor preview shows, as it blinks.
|
||||
lit: bool,
|
||||
_typed: Subscription,
|
||||
_settings: Subscription,
|
||||
_updates: Subscription,
|
||||
_blink: Task<()>,
|
||||
}
|
||||
|
||||
@@ -130,9 +135,12 @@ impl SettingsWindow {
|
||||
failed: None,
|
||||
drafts: 0,
|
||||
font,
|
||||
fallbacks: Vec::new(),
|
||||
adding: false,
|
||||
lit: true,
|
||||
_typed: typed,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
_updates: cx.observe_global::<Updates>(|_, cx| cx.notify()),
|
||||
_blink: Self::blink(cx),
|
||||
}
|
||||
}
|
||||
@@ -283,7 +291,10 @@ fn set(
|
||||
impl Render for SettingsWindow {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let page = match self.section.as_ref() {
|
||||
"appearance" => self.appearance(window, cx).into_any_element(),
|
||||
"appearance" => {
|
||||
self.grow_fallbacks(window, cx);
|
||||
self.appearance(window, cx).into_any_element()
|
||||
}
|
||||
"terminal" => self.terminal(cx).into_any_element(),
|
||||
other => unreachable!("settings: no section {other}"),
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use std::ops::RangeInclusive;
|
||||
|
||||
use ely_gpui_component::{
|
||||
forms::{FontPicker, NumberInput, RadioCard, Slider, Switch},
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{FontPicker, NumberInput, RadioCard, Slider, Switch, TextInput},
|
||||
layout::SimpleGrid,
|
||||
primitives::IconName,
|
||||
settings::{SettingsRow, SettingsSection},
|
||||
theme::{ActiveTheme, ControlSize, Mix, Radius, TextSize},
|
||||
};
|
||||
@@ -72,8 +74,12 @@ impl SettingsWindow {
|
||||
}
|
||||
}
|
||||
});
|
||||
let fallbacks = SettingsRow::new("Fallbacks")
|
||||
.description("Tried in order for characters the family lacks")
|
||||
.control(self.fallback_list(cx));
|
||||
let font = SettingsSection::new("Font")
|
||||
.row(SettingsRow::new("Family").control(div().w(theme.prose_width()).child(family)))
|
||||
.row(fallbacks)
|
||||
.row(SettingsRow::new("Size").control(self.number(
|
||||
"font_size",
|
||||
settings.font_size,
|
||||
@@ -173,6 +179,98 @@ impl SettingsWindow {
|
||||
.child(background)
|
||||
}
|
||||
|
||||
/// An input for each fallback picker and the one that adds a family.
|
||||
pub(super) fn grow_fallbacks(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let wanted = SettingsStore::get(cx).font_fallbacks.len() + 1;
|
||||
while self.fallbacks.len() < wanted {
|
||||
let input = cx.new(|cx| TextInput::new(window, cx));
|
||||
self.fallbacks.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each fallback family, a picker that replaces it and a button that removes it, then Add or the empty picker it opens.
|
||||
fn fallback_list(&self, cx: &Context<Self>) -> Div {
|
||||
let this = cx.entity();
|
||||
let families = SettingsStore::get(cx).font_fallbacks.clone();
|
||||
let width = cx.theme().prose_width();
|
||||
let row = |index: usize, picker: FontPicker, remove: IconButton| {
|
||||
let tip = if index < families.len() {
|
||||
"Remove"
|
||||
} else {
|
||||
"Cancel"
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(div().w(width).child(picker))
|
||||
.child(
|
||||
remove
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(tip),
|
||||
)
|
||||
};
|
||||
let rows = families.iter().enumerate().map(|(index, family)| {
|
||||
let picker = FontPicker::new(keyed(&self.fallbacks[index]), &self.fallbacks[index])
|
||||
.selected(family.clone())
|
||||
.on_change(pick(&this, index));
|
||||
let remove = IconButton::new(("remove_fallback", index), IconName::X).on_click({
|
||||
let (this, family) = (this.clone(), family.clone());
|
||||
move |_, window, cx| {
|
||||
let dropped = format!("font_fallbacks drops {family:?}");
|
||||
let removed = set(&this, dropped, cx, |settings| {
|
||||
settings.font_fallbacks.remove(index);
|
||||
});
|
||||
// Each input left keeps its own family, and none the dropped one.
|
||||
if removed {
|
||||
this.update(cx, |this, _| this.fallbacks.remove(index));
|
||||
window.blur(cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
row(index, picker, remove)
|
||||
});
|
||||
let next = families.len();
|
||||
let add = if self.adding {
|
||||
let picker = FontPicker::new(keyed(&self.fallbacks[next]), &self.fallbacks[next])
|
||||
.on_change(pick(&this, next));
|
||||
let cancel = IconButton::new("cancel_fallback", IconName::X).on_click({
|
||||
let this = this.clone();
|
||||
move |_, window, cx| {
|
||||
this.update(cx, |this, cx| this.leave_adding(cx));
|
||||
window.blur(cx);
|
||||
}
|
||||
});
|
||||
row(next, picker, cancel)
|
||||
} else {
|
||||
let add = Button::new("add_fallback", "Add fallback")
|
||||
.icon(IconName::Plus)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click({
|
||||
let this = this.clone();
|
||||
move |_, _, cx| {
|
||||
this.update(cx, |this, cx| {
|
||||
this.adding = true;
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
});
|
||||
div().child(add)
|
||||
};
|
||||
div().flex().flex_col().gap_2().children(rows).child(add)
|
||||
}
|
||||
|
||||
/// Closes the empty picker, its text gone.
|
||||
fn leave_adding(&mut self, cx: &mut Context<Self>) {
|
||||
let next = SettingsStore::get(cx).font_fallbacks.len();
|
||||
if let Some(input) = self.fallbacks.get(next) {
|
||||
input.update(cx, |input, cx| input.set_text("", cx));
|
||||
}
|
||||
self.adding = false;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// A field over `range`, by `step` to `places`, that saves each committed number through `put`; a refusal starts it again from the settings.
|
||||
fn number(
|
||||
&self,
|
||||
@@ -198,6 +296,50 @@ impl SettingsWindow {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fallback picker's id, its input's: Ely's combobox keeps state by id and listens to the input it met first.
|
||||
fn keyed(input: &Entity<TextInput>) -> (&'static str, u64) {
|
||||
("font_fallback", input.entity_id().as_u64())
|
||||
}
|
||||
|
||||
/// Saves the family picked at fallback `index`, past the last adds one; a refusal shows the saved family again, the list closed.
|
||||
fn pick(
|
||||
this: &Entity<SettingsWindow>,
|
||||
index: usize,
|
||||
) -> impl Fn(&SharedString, &mut Window, &mut App) + 'static {
|
||||
let this = this.clone();
|
||||
move |family, window, cx| {
|
||||
let family = family.to_string();
|
||||
let adds = index == SettingsStore::get(cx).font_fallbacks.len();
|
||||
let saved = set(
|
||||
&this,
|
||||
format!("font_fallbacks[{index}] is {family:?}"),
|
||||
cx,
|
||||
|settings| {
|
||||
if adds {
|
||||
settings.font_fallbacks.push(family)
|
||||
} else {
|
||||
settings.font_fallbacks[index] = family
|
||||
}
|
||||
},
|
||||
);
|
||||
if adds && saved {
|
||||
this.update(cx, |this, cx| {
|
||||
this.adding = false;
|
||||
cx.notify();
|
||||
});
|
||||
} else if !saved {
|
||||
let shown = SettingsStore::get(cx)
|
||||
.font_fallbacks
|
||||
.get(index)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let input = this.read(cx).fallbacks[index].clone();
|
||||
input.update(cx, |input, cx| input.set_text(shown, cx));
|
||||
window.blur(cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `fraction` in whole percents; the slider moves by them, so a saved fraction carries no float noise.
|
||||
fn percent(fraction: f32) -> f64 {
|
||||
(f64::from(fraction) * 100.).round()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
buttons::{Button, SegmentedControl},
|
||||
feedback::InlineMessage,
|
||||
forms::{Copy, NumberInput, Paste, PathInput, SelectAll},
|
||||
forms::{Copy, NumberInput, Paste, PathInput, SelectAll, Switch},
|
||||
primitives::Severity,
|
||||
settings::{KeyboardShortcutsList, SettingsRow, SettingsSection, Shortcut},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
@@ -15,6 +15,7 @@ use crate::{
|
||||
menus::{Bigger, Minimize, Quit, ResetSize, Smaller},
|
||||
settings::{OptionAsAlt, SCROLLBACK, SettingsStore, StartIn},
|
||||
term::input::{self, Find, NextPrompt, PreviousPrompt},
|
||||
updates::{self, Updates, VERSION},
|
||||
workspace::{
|
||||
self, CloseSession, LastSession, NewSession, NextSession, PreviousSession, SelectSession,
|
||||
TogglePanel, ToggleSessions,
|
||||
@@ -42,7 +43,7 @@ const PLACES: [&str; 8] = [
|
||||
];
|
||||
|
||||
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 {
|
||||
let settings = SettingsStore::get(cx);
|
||||
let this = cx.entity();
|
||||
@@ -106,13 +107,70 @@ impl SettingsWindow {
|
||||
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")
|
||||
.row(KeyboardShortcutsList::new(shortcuts(cx), &self.search));
|
||||
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.
|
||||
fn start_in(this: &Entity<SettingsWindow>, value: &SharedString, cx: &mut App) {
|
||||
let start = match value.as_ref() {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! A click on the line the shell reads moves its cursor there, as Ghostty's `cursor-click-to-move`: arrows, or the press itself where the shell takes clicks.
|
||||
|
||||
use alacritty_terminal::{
|
||||
grid::Dimensions,
|
||||
index::{Column, Line, Point},
|
||||
term::{
|
||||
Term, TermMode,
|
||||
cell::{Cell, Flags},
|
||||
},
|
||||
};
|
||||
use gpui::Context;
|
||||
|
||||
use super::{
|
||||
marks::Click,
|
||||
mouse::Tracking,
|
||||
prompts::{Part, part, prompted, start},
|
||||
view::TerminalView,
|
||||
};
|
||||
|
||||
/// Where the shell stands, as its OSC 133 marks say.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Shell {
|
||||
/// A command runs, or nothing said otherwise.
|
||||
#[default]
|
||||
Busy,
|
||||
/// Its prompt draws; the line after it takes a click so.
|
||||
Prompting(Click),
|
||||
/// It reads a line, which takes a click so.
|
||||
Reading(Click),
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// The shell after B: it reads the line its prompt started, which takes arrows without one.
|
||||
pub fn reads(self) -> Self {
|
||||
match self {
|
||||
Self::Prompting(click) | Self::Reading(click) => Self::Reading(click),
|
||||
Self::Busy => Self::Reading(Click::Arrows),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrows from the cursor to `click`, right positive; none when `click` lies off the input after the cursor's prompt, soft wraps joined.
|
||||
pub fn arrows<T>(term: &Term<T>, click: Point) -> Option<i32> {
|
||||
if term.mode().contains(TermMode::ALT_SCREEN) {
|
||||
return None;
|
||||
}
|
||||
let grid = term.grid();
|
||||
let columns = grid.columns();
|
||||
let cursor = grid.cursor.point;
|
||||
let wraps = |line: i32| {
|
||||
grid[Line(line)][Column(columns - 1)]
|
||||
.flags
|
||||
.contains(Flags::WRAPLINE)
|
||||
};
|
||||
let mut first = cursor.line.0;
|
||||
while first > grid.topmost_line().0 && wraps(first - 1) {
|
||||
first -= 1;
|
||||
}
|
||||
let mut last = cursor.line.0;
|
||||
while last < grid.bottommost_line().0 && wraps(last) {
|
||||
last += 1;
|
||||
}
|
||||
if !(first..=last).contains(&click.line.0) {
|
||||
return None;
|
||||
}
|
||||
let cells: Vec<&Cell> = (first..=last)
|
||||
.flat_map(|line| (0..columns).map(move |column| &grid[Line(line)][Column(column)]))
|
||||
.collect();
|
||||
let at = |point: Point| (point.line.0 - first) as usize * columns + point.column.0;
|
||||
let (here, there) = (at(cursor), at(click));
|
||||
// Input starts past the prompt the cursor's line shows before it.
|
||||
let start = cells[..here].iter().rposition(|cell| prompted(cell))? + 1;
|
||||
if there < start {
|
||||
return None;
|
||||
}
|
||||
let inked = |cell: &&Cell| !prompted(cell) && !matches!(cell.c, ' ' | '\t');
|
||||
let end = cells[start..]
|
||||
.iter()
|
||||
.rposition(inked)
|
||||
.map_or(here, |last| here.max(start + last + 1));
|
||||
let mut target = there.min(end);
|
||||
if cells[target].flags.contains(Flags::WIDE_CHAR_SPACER) {
|
||||
target -= 1;
|
||||
}
|
||||
let spacer = Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER;
|
||||
let typed = |cell: &&&Cell| !prompted(cell) && !cell.flags.intersects(spacer);
|
||||
let count = |from: usize, to: usize| cells[from..to].iter().filter(typed).count() as i32;
|
||||
Some(if target >= here {
|
||||
count(here, target)
|
||||
} else {
|
||||
-count(target, here)
|
||||
})
|
||||
}
|
||||
|
||||
/// The left press, SGR, a shell that takes clicks hears for `click`, its row the screen's or counted from its prompt's first, as kitty and Ghostty send it; none on a prompt, before the input or below it.
|
||||
pub fn press<T>(term: &Term<T>, click: Point, relative: bool) -> Option<Vec<u8>> {
|
||||
if term.mode().contains(TermMode::ALT_SCREEN) {
|
||||
return None;
|
||||
}
|
||||
let grid = term.grid();
|
||||
let cell = |(line, column): (i32, usize)| &grid[Line(line)][Column(column)];
|
||||
let cursor = (grid.cursor.point.line.0, grid.cursor.point.column.0);
|
||||
// ponytail: scans of the rows on the way; index the terminal's own links if long histories lag.
|
||||
let back = (grid.topmost_line().0..=grid.bottommost_line().0)
|
||||
.rev()
|
||||
.flat_map(|line| (0..term.columns()).rev().map(move |column| (line, column)));
|
||||
let end = (back.clone())
|
||||
.filter(|&at| at < cursor)
|
||||
.find(|&at| prompted(cell(at)));
|
||||
// The newest line typed, by its number, which only grows, its first and last cells; a shell drawing its prompts again keeps it.
|
||||
let typed = back.filter_map(|at| match part(cell(at)) {
|
||||
Some(Part::Input(line)) => Some((at, line)),
|
||||
_ => None,
|
||||
});
|
||||
let newest = typed.fold(None, |newest, (at, line)| match newest {
|
||||
Some((_, _, kept)) if kept > line => newest,
|
||||
Some((_, last, kept)) if kept == line => Some((at, last, line)),
|
||||
_ => Some((at, at, line)),
|
||||
});
|
||||
let input = newest.map(|(first, _, line)| (first, line));
|
||||
let bottom = newest.map_or(cursor.0, |(_, last, _)| last.0.max(cursor.0));
|
||||
// An older line typed between a prompt and the input leaves that prompt behind.
|
||||
let older = |end: (i32, usize), (input, line): ((i32, usize), u32)| {
|
||||
(end.0..=input.0)
|
||||
.flat_map(|row| {
|
||||
let from = if row == end.0 { end.1 + 1 } else { 0 };
|
||||
let to = if row == input.0 {
|
||||
input.1
|
||||
} else {
|
||||
term.columns()
|
||||
};
|
||||
(from..to).map(move |column| (row, column))
|
||||
})
|
||||
.any(|at| matches!(part(cell(at)), Some(Part::Input(other)) if other != line))
|
||||
};
|
||||
let at = (click.line.0, click.column.0);
|
||||
// Input starts past the prompt's last cell before the cursor, or at its first typed cell where no prompt of its own came before it.
|
||||
let (before, first) = match (end, input) {
|
||||
(Some(end), Some(typed)) if typed.0 > end && older(end, typed) => (at < typed.0, typed.0.0),
|
||||
(Some(end), _) => (at <= end, start(term, end.0)?),
|
||||
(None, Some((input, _))) => (at < input, input.0),
|
||||
(None, None) => return None,
|
||||
};
|
||||
if before || at.0 > bottom || prompted(cell(at)) {
|
||||
return None;
|
||||
}
|
||||
let row = if relative { at.0 - first } else { at.0 } + 1;
|
||||
if row < 1 {
|
||||
return None;
|
||||
}
|
||||
Some(format!("\x1b[<0;{};{row}M", click.column.0 + 1).into_bytes())
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Moves the shell's cursor to `click` while it reads a line: arrows, each pressed and let go, or the press where the shell takes clicks.
|
||||
pub(super) fn click_to_move(&mut self, click: Point, cx: &mut Context<Self>) {
|
||||
let pid = self.pty.pid;
|
||||
let Shell::Reading(takes) = self.shell else {
|
||||
log::debug!("terminal {pid}: a click at {click:?}, no line read");
|
||||
return;
|
||||
};
|
||||
if self.mouse.tracking != Tracking::Off {
|
||||
log::debug!("terminal {pid}: a click at {click:?}, the program tracks the mouse");
|
||||
return;
|
||||
}
|
||||
if takes != Click::Arrows {
|
||||
let Some(bytes) = press(&self.pty.term.lock(), click, takes == Click::Relative) else {
|
||||
log::debug!("terminal {pid}: a click at {click:?}, off the input");
|
||||
return;
|
||||
};
|
||||
log::debug!("terminal {pid}: a click at {click:?} goes to the shell as {bytes:?}");
|
||||
return self.input(bytes, cx);
|
||||
}
|
||||
let Some(count) = arrows(&self.pty.term.lock(), click) else {
|
||||
log::debug!("terminal {pid}: a click at {click:?}, off the input");
|
||||
return;
|
||||
};
|
||||
log::debug!("terminal {pid}: a click at {click:?} moves the cursor {count}");
|
||||
let arrow = if count < 0 { "left" } else { "right" };
|
||||
for _ in 0..count.unsigned_abs() {
|
||||
self.tap_key(arrow, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,269 @@
|
||||
use alacritty_terminal::{
|
||||
event::VoidListener,
|
||||
term::{Config, test::TermSize},
|
||||
vte::ansi::Processor,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::term::prompts::Prompts;
|
||||
|
||||
const PROMPT: &str = "\x1b]133;A\x07$ \x1b]133;B\x07";
|
||||
|
||||
/// A terminal `columns` wide after `text` passed the prompts' rewrite, as the tap passes it.
|
||||
fn term(columns: usize, text: &str) -> Term<VoidListener> {
|
||||
let mut term = Term::new(Config::default(), &TermSize::new(columns, 4), VoidListener);
|
||||
let mut parser: Processor = Processor::new();
|
||||
parser.advance(&mut term, Prompts::default().pass(text.as_bytes()));
|
||||
term
|
||||
}
|
||||
|
||||
fn click(line: i32, column: usize) -> Point {
|
||||
Point::new(Line(line), Column(column))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_on_the_typed_line_counts_the_characters_to_it() {
|
||||
let typed = term(20, &format!("{PROMPT}echo hello"));
|
||||
assert_eq!(arrows(&typed, click(0, 7)), Some(-5), "back to hello");
|
||||
assert_eq!(
|
||||
arrows(&typed, click(0, 2)),
|
||||
Some(-10),
|
||||
"the first character"
|
||||
);
|
||||
assert_eq!(
|
||||
arrows(&typed, click(0, 12)),
|
||||
Some(0),
|
||||
"the cursor's own cell"
|
||||
);
|
||||
let moved = term(20, &format!("{PROMPT}echo hello\x1b[5D"));
|
||||
assert_eq!(arrows(&moved, click(0, 11)), Some(4), "on to the last o");
|
||||
assert_eq!(
|
||||
arrows(&moved, click(0, 18)),
|
||||
Some(5),
|
||||
"past the end, the end"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_off_the_input_moves_nothing() {
|
||||
let typed = term(20, &format!("{PROMPT}ls"));
|
||||
assert_eq!(arrows(&typed, click(0, 0)), None, "on the prompt");
|
||||
assert_eq!(arrows(&typed, click(0, 1)), None, "on the prompt's space");
|
||||
assert_eq!(arrows(&typed, click(2, 3)), None, "another line");
|
||||
let ran = term(20, &format!("{PROMPT}cat\r\nhello"));
|
||||
assert_eq!(arrows(&ran, click(1, 2)), None, "a line no prompt shows");
|
||||
let output = term(20, "$ plain text");
|
||||
assert_eq!(arrows(&output, click(0, 3)), None, "no marks at all");
|
||||
let full = term(20, &format!("{PROMPT}vim\x1b[?1049h"));
|
||||
assert_eq!(arrows(&full, click(0, 3)), None, "the alternate screen");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wide_character_takes_one_arrow() {
|
||||
let wide = term(20, &format!("{PROMPT}中文ab"));
|
||||
assert_eq!(arrows(&wide, click(0, 3)), Some(-4), "the right half of 中");
|
||||
assert_eq!(arrows(&wide, click(0, 4)), Some(-3), "文");
|
||||
assert_eq!(arrows(&wide, click(0, 7)), Some(-1), "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soft_wrapped_rows_are_one_line() {
|
||||
let wrapped = term(10, &format!("{PROMPT}abcdefghijkl"));
|
||||
assert_eq!(wrapped.grid().cursor.point, click(1, 4));
|
||||
assert_eq!(arrows(&wrapped, click(0, 3)), Some(-11), "back to b");
|
||||
let back = term(10, &format!("{PROMPT}abcdefghijkl\x1b[3D"));
|
||||
assert_eq!(arrows(&back, click(0, 3)), Some(-8), "up to b");
|
||||
assert_eq!(arrows(&back, click(1, 3)), Some(2), "on to l");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_spaces_count_only_up_to_the_cursor() {
|
||||
let spaced = term(20, &format!("{PROMPT}ls "));
|
||||
assert_eq!(arrows(&spaced, click(0, 15)), Some(0));
|
||||
assert_eq!(arrows(&spaced, click(0, 4)), Some(-2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_over_two_lines_keeps_its_input_on_the_last() {
|
||||
let two = term(20, "\x1b]133;A\x07~/src\r\n> \x1b]133;B\x07make");
|
||||
assert_eq!(arrows(&two, click(1, 2)), Some(-4));
|
||||
assert_eq!(arrows(&two, click(0, 3)), None, "the prompt's first line");
|
||||
}
|
||||
|
||||
/// The press `term` sends for a click at `at`, as text.
|
||||
fn sent(term: &Term<VoidListener>, at: Point, relative: bool) -> Option<String> {
|
||||
press(term, at, relative).map(|bytes| String::from_utf8(bytes).expect("ASCII"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_that_takes_clicks_hears_the_press_where_it_landed() {
|
||||
let marked = "\x1b]133;A;click_events=1\x07$ \x1b]133;B\x07";
|
||||
let typed = term(20, &format!("out\r\n{marked}echo hi"));
|
||||
assert_eq!(
|
||||
sent(&typed, click(1, 7), false).as_deref(),
|
||||
Some("\x1b[<0;8;2M")
|
||||
);
|
||||
assert_eq!(
|
||||
sent(&typed, click(1, 7), true).as_deref(),
|
||||
Some("\x1b[<0;8;1M")
|
||||
);
|
||||
assert_eq!(
|
||||
sent(&typed, click(2, 3), true),
|
||||
None,
|
||||
"a row below the input"
|
||||
);
|
||||
assert_eq!(sent(&typed, click(0, 1), false), None, "before the prompt");
|
||||
assert_eq!(sent(&typed, click(1, 0), false), None, "on the prompt");
|
||||
assert_eq!(
|
||||
sent(&typed, click(1, 1), false),
|
||||
None,
|
||||
"on the prompt's space"
|
||||
);
|
||||
let past = sent(&typed, click(1, 15), false);
|
||||
assert_eq!(
|
||||
past.as_deref(),
|
||||
Some("\x1b[<0;16;2M"),
|
||||
"past the end, the shell decides"
|
||||
);
|
||||
let right = term(
|
||||
20,
|
||||
&format!("{marked}\x1b[1;17H\x1b]133;A\x07~/a\x1b]133;B\x07\x1b[1;3Hls"),
|
||||
);
|
||||
assert_eq!(
|
||||
sent(&right, click(0, 18), false),
|
||||
None,
|
||||
"a right prompt after the cursor"
|
||||
);
|
||||
assert_eq!(
|
||||
sent(&right, click(0, 3), false).as_deref(),
|
||||
Some("\x1b[<0;4;1M")
|
||||
);
|
||||
assert_eq!(sent(&right, click(0, 1), false), None, "on the left prompt");
|
||||
let empty = term(
|
||||
20,
|
||||
"\x1b[1;17H\x1b]133;A;click_events=1\x07~/a\x1b]133;B\x07\x1b[1;1Hls",
|
||||
);
|
||||
let left = sent(&empty, click(0, 1), false);
|
||||
assert_eq!(left.as_deref(), Some("\x1b[<0;2;1M"), "only a right prompt");
|
||||
assert_eq!(
|
||||
sent(&empty, click(0, 17), false),
|
||||
None,
|
||||
"on that right prompt"
|
||||
);
|
||||
let two = term(
|
||||
20,
|
||||
"\x1b]133;A;click_events=2\x07~/src\r\n> \x1b]133;B\x07ls",
|
||||
);
|
||||
let relative = sent(&two, click(1, 3), true);
|
||||
assert_eq!(
|
||||
relative.as_deref(),
|
||||
Some("\x1b[<0;4;2M"),
|
||||
"from the prompt's first row"
|
||||
);
|
||||
let full = term(20, &format!("{marked}vim\x1b[?1049h"));
|
||||
assert_eq!(
|
||||
sent(&full, click(0, 3), false),
|
||||
None,
|
||||
"the alternate screen"
|
||||
);
|
||||
let scrolled = term(20, &format!("{marked}a\r\nb\r\nc\r\nd\r\ne"));
|
||||
assert_eq!(
|
||||
sent(&scrolled, click(-1, 0), false),
|
||||
None,
|
||||
"a row in the history"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_reads_the_line_its_prompt_started() {
|
||||
assert_eq!(Shell::Busy.reads(), Shell::Reading(Click::Arrows));
|
||||
assert_eq!(
|
||||
Shell::Prompting(Click::Absolute).reads(),
|
||||
Shell::Reading(Click::Absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
Shell::Reading(Click::Relative).reads(),
|
||||
Shell::Reading(Click::Relative)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_that_prints_nothing_starts_the_input_at_its_first_typed_cell() {
|
||||
let empty = "\x1b]133;A;click_events=1\x07\x1b]133;B\x07";
|
||||
let typed = term(20, &format!("echo hi\r\nhi\r\n{empty}abcdef"));
|
||||
let at = sent(&typed, click(2, 3), false);
|
||||
assert_eq!(at.as_deref(), Some("\x1b[<0;4;3M"), "on the d");
|
||||
let relative = sent(&typed, click(2, 3), true);
|
||||
assert_eq!(
|
||||
relative.as_deref(),
|
||||
Some("\x1b[<0;4;1M"),
|
||||
"from the input's row"
|
||||
);
|
||||
assert_eq!(sent(&typed, click(1, 1), false), None, "output above");
|
||||
assert_eq!(
|
||||
sent(&typed, click(0, 2), false),
|
||||
None,
|
||||
"the line run before"
|
||||
);
|
||||
let moved = term(20, &format!("{empty}abcdef\x1b[6D"));
|
||||
let back = sent(&moved, click(0, 4), false);
|
||||
assert_eq!(
|
||||
back.as_deref(),
|
||||
Some("\x1b[<0;5;1M"),
|
||||
"the cursor at the start"
|
||||
);
|
||||
let blank = term(20, &format!("echo hi\r\n{empty}"));
|
||||
assert_eq!(
|
||||
sent(&blank, click(0, 2), false),
|
||||
None,
|
||||
"nothing typed, nothing after"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_drawn_again_after_the_typed_text_keeps_its_line() {
|
||||
let marked = "\x1b]133;A;click_events=1\x07";
|
||||
let right = format!("\x1b[1;17H{marked}RIGHT\x1b]133;B\x07\x1b[1;7H");
|
||||
let repainted = term(24, &format!("{marked}\x1b]133;B\x07abcdef{right}"));
|
||||
let at = sent(&repainted, click(0, 3), false);
|
||||
assert_eq!(
|
||||
at.as_deref(),
|
||||
Some("\x1b[<0;4;1M"),
|
||||
"fish's right prompt again"
|
||||
);
|
||||
assert_eq!(
|
||||
sent(&repainted, click(0, 17), false),
|
||||
None,
|
||||
"on that prompt"
|
||||
);
|
||||
let ran = "\x1b]133;C\x07\x1b]133;D;0\x07";
|
||||
let after = format!("$ \x1b]133;B\x07echo hi{ran}\r\nhi\r\n{marked}\x1b]133;B\x07abcdef");
|
||||
let below = term(20, &format!("{marked}{after}"));
|
||||
assert_eq!(sent(&below, click(1, 1), false), None, "the output between");
|
||||
assert_eq!(
|
||||
sent(&below, click(0, 4), false),
|
||||
None,
|
||||
"the line run before"
|
||||
);
|
||||
let typed = sent(&below, click(2, 2), true);
|
||||
assert_eq!(
|
||||
typed.as_deref(),
|
||||
Some("\x1b[<0;3;1M"),
|
||||
"from the input's own row"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_newest_line_typed_is_known_by_its_number_not_its_place() {
|
||||
let marked = "\x1b]133;A;click_events=1\x07$ \x1b]133;B\x07";
|
||||
let ran = "\x1b]133;C\x07\x1b]133;D;0\x07";
|
||||
let home = format!("\r\n\r\n\r\n{marked}oldlower{ran}\x1b[H{marked}abcdef");
|
||||
let above = term(20, &home);
|
||||
let at = sent(&above, click(0, 5), false);
|
||||
assert_eq!(
|
||||
at.as_deref(),
|
||||
Some("\x1b[<0;6;1M"),
|
||||
"the prompt at the top, old input below"
|
||||
);
|
||||
assert_eq!(sent(&above, click(3, 4), false), None, "the old line below");
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Files dropped on the terminal type their paths, as macOS Terminal does.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use gpui::{Context, ExternalPaths};
|
||||
|
||||
use super::view::TerminalView;
|
||||
|
||||
/// What a POSIX shell reads specially in a path: Ghostty's list, and zsh's `extendedglob` marks.
|
||||
const SPECIAL: &str = "\\ ()[]{}<>\"'`!#$&;|*?^~";
|
||||
|
||||
/// PowerShell's single quotes, each one doubled inside a quoted string.
|
||||
const SINGLE: [char; 5] = ['\'', '\u{2018}', '\u{2019}', '\u{201a}', '\u{201b}'];
|
||||
|
||||
/// How a session's shell reads a path typed into it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Quote {
|
||||
/// sh, bash, zsh, fish: a backslash before each special character.
|
||||
Posix,
|
||||
/// Single quotes, which expand nothing.
|
||||
PowerShell,
|
||||
/// Double quotes, past which cmd still expands `%` and, under `/v:on`, `!`.
|
||||
Cmd,
|
||||
}
|
||||
|
||||
/// The text `paths` type, each followed by a space, as `quote` keeps each one literal.
|
||||
pub fn typed(paths: &[PathBuf], quote: Quote) -> anyhow::Result<String> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let text = (path.to_str())
|
||||
.with_context(|| format!("{} is no UTF-8 a session can type", path.display()))?;
|
||||
anyhow::ensure!(
|
||||
!text.chars().any(char::is_control),
|
||||
"{text:?} holds a control character, which would act as typed"
|
||||
);
|
||||
let mut out = String::with_capacity(text.len() + 3);
|
||||
match quote {
|
||||
Quote::Posix => text.chars().for_each(|ch| {
|
||||
if SPECIAL.contains(ch) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(ch);
|
||||
}),
|
||||
Quote::PowerShell => {
|
||||
out.push('\'');
|
||||
text.chars().for_each(|ch| {
|
||||
if SINGLE.contains(&ch) {
|
||||
out.push(ch);
|
||||
}
|
||||
out.push(ch);
|
||||
});
|
||||
out.push('\'');
|
||||
}
|
||||
Quote::Cmd => {
|
||||
anyhow::ensure!(
|
||||
!text.contains(['%', '!', '"']),
|
||||
"{text:?} holds %, ! or \", which cmd reads inside quotes"
|
||||
);
|
||||
// A program reads a backslash before the closing quote as a quote.
|
||||
let trailing = text.len() - text.trim_end_matches('\\').len();
|
||||
out = format!("\"{text}{}\"", "\\".repeat(trailing));
|
||||
}
|
||||
}
|
||||
out.push(' ');
|
||||
Ok(out)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
/// Types the dropped paths at the cursor as a paste; a path it cannot type stops the drop.
|
||||
pub(super) fn drop_paths(&mut self, dropped: &ExternalPaths, cx: &mut Context<Self>) {
|
||||
let (paths, quote) = (dropped.paths(), self.pty.quote);
|
||||
match typed(paths, quote) {
|
||||
Ok(text) => {
|
||||
log::info!(
|
||||
"terminal {}: {} paths dropped, quoted for {quote:?}",
|
||||
self.pty.pid,
|
||||
paths.len()
|
||||
);
|
||||
self.paste_text(&text, cx);
|
||||
}
|
||||
Err(error) => log::error!(
|
||||
"terminal {}: the drop types nothing: {error:#}",
|
||||
self.pty.pid
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
|
||||
/// What `paths` type for `quote`.
|
||||
fn typed_as(quote: Quote, all: &[&str]) -> String {
|
||||
let paths: Vec<PathBuf> = all.iter().map(PathBuf::from).collect();
|
||||
typed(&paths, quote).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_posix_path_escapes_what_a_shell_reads_specially() {
|
||||
let typed = |all: &[&str]| typed_as(Quote::Posix, all);
|
||||
assert_eq!(typed(&["/tmp/a.txt"]), "/tmp/a.txt ");
|
||||
assert_eq!(typed(&["/tmp/my file.png"]), "/tmp/my\\ file.png ");
|
||||
assert_eq!(typed(&["/a/it's (1)&$x"]), "/a/it\\'s\\ \\(1\\)\\&\\$x ");
|
||||
assert_eq!(
|
||||
typed(&["/a/a^b~c#d"]),
|
||||
"/a/a\\^b\\~c\\#d ",
|
||||
"zsh's extendedglob"
|
||||
);
|
||||
assert_eq!(typed(&["/a/中文 b"]), "/a/中文\\ b ");
|
||||
assert_eq!(
|
||||
typed(&["/a", "/b c"]),
|
||||
"/a /b\\ c ",
|
||||
"each followed by a space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_powershell_path_is_single_quoted_with_its_quotes_doubled() {
|
||||
let typed = |all: &[&str]| typed_as(Quote::PowerShell, all);
|
||||
assert_eq!(typed(&["C:\\a.txt"]), "'C:\\a.txt' ");
|
||||
assert_eq!(typed(&["C:\\tmp\\it's.txt"]), "'C:\\tmp\\it''s.txt' ");
|
||||
assert_eq!(typed(&["C:\\$HOME $(1+1);x"]), "'C:\\$HOME $(1+1);x' ");
|
||||
assert_eq!(
|
||||
typed(&["C:\\a\u{2019}b"]),
|
||||
"'C:\\a\u{2019}\u{2019}b' ",
|
||||
"a curly quote"
|
||||
);
|
||||
assert_eq!(typed(&["/a b", "/c"]), "'/a b' '/c' ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cmd_path_is_double_quoted_its_trailing_backslashes_doubled_and_one_cmd_expands_types_nothing()
|
||||
{
|
||||
let typed = |all: &[&str]| typed_as(Quote::Cmd, all);
|
||||
assert_eq!(
|
||||
typed(&["C:\\My Files\\a&b^c.txt", "D:\\c"]),
|
||||
"\"C:\\My Files\\a&b^c.txt\" \"D:\\c\" "
|
||||
);
|
||||
assert_eq!(
|
||||
typed(&["C:\\", "D:\\clip.mp4"]),
|
||||
"\"C:\\\\\" \"D:\\clip.mp4\" ",
|
||||
"a root's backslash"
|
||||
);
|
||||
for name in ["C:\\%PATH%.txt", "C:\\!DROP!.txt"] {
|
||||
let refused = super::typed(&[PathBuf::from(name)], Quote::Cmd);
|
||||
assert!(refused.is_err(), "{name}: {refused:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_with_a_control_character_types_nothing() {
|
||||
for quote in [Quote::Posix, Quote::PowerShell, Quote::Cmd] {
|
||||
let paths = [PathBuf::from("/ok"), PathBuf::from("/tmp/a\nls")];
|
||||
let error = typed(&paths, quote).unwrap_err();
|
||||
assert!(error.to_string().contains("control character"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! The terminal's font, cell and metrics.
|
||||
|
||||
use gpui::{
|
||||
Font, FontFallbacks, FontFeatures, FontId, FontStyle, FontWeight, Pixels, TextSystem, Window,
|
||||
px, size,
|
||||
};
|
||||
|
||||
use super::{ink::Ink, paint::Look};
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// The bundled Nerd Font that draws icons any font lacks; gpui loads no font without an `m`.
|
||||
pub const ICONS: &str = "JetBrainsMono Nerd Font Mono";
|
||||
|
||||
/// The bundled code font, the default.
|
||||
pub const MONO: &str = "JetBrains Mono";
|
||||
|
||||
/// `font` as gpui loads it; gpui falls back to another font without a word, and loads none without an `m`.
|
||||
pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
|
||||
let id = system.resolve_font(font);
|
||||
let loaded = system.get_font_for_id(id);
|
||||
anyhow::ensure!(
|
||||
loaded.is_some_and(|loaded| loaded.family == font.family),
|
||||
"the terminal cannot load {:?}",
|
||||
font.family
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Font, cell and metrics at `points` on this window.
|
||||
pub(super) fn look(
|
||||
settings: &Settings,
|
||||
points: f32,
|
||||
ink: &Ink,
|
||||
window: &Window,
|
||||
) -> anyhow::Result<Look> {
|
||||
let font = Font {
|
||||
family: settings.font_family.clone().into(),
|
||||
features: if settings.ligatures {
|
||||
FontFeatures::default()
|
||||
} else {
|
||||
FontFeatures::disable_ligatures()
|
||||
},
|
||||
fallbacks: (!settings.font_fallbacks.is_empty())
|
||||
.then(|| FontFallbacks::from_fonts(settings.font_fallbacks.clone())),
|
||||
weight: FontWeight::NORMAL,
|
||||
style: FontStyle::Normal,
|
||||
};
|
||||
let text = px(points);
|
||||
let system = window.text_system();
|
||||
let id = load_font(&font, system)?;
|
||||
let advance = system
|
||||
.advance(id, text, 'M')
|
||||
.map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?;
|
||||
let scale = window.scale_factor();
|
||||
let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale);
|
||||
let cell = size(
|
||||
snap(advance.width).max(px(1.)),
|
||||
snap(text * settings.line_height),
|
||||
);
|
||||
let (ascent, descent) = (system.ascent(id, text), system.descent(id, text).abs());
|
||||
let icons = Font {
|
||||
family: ICONS.into(),
|
||||
features: FontFeatures::default(),
|
||||
..font.clone()
|
||||
};
|
||||
Ok(Look {
|
||||
font_id: id,
|
||||
icons,
|
||||
baseline: (cell.height - ascent - descent) / 2. + ascent,
|
||||
descent,
|
||||
rule: snap((text / 14.).max(px(1.))),
|
||||
size: text,
|
||||
cell,
|
||||
font,
|
||||
cursor: ink.cursor,
|
||||
cursor_text: ink.cursor_text,
|
||||
fg: ink.fg,
|
||||
bg: ink.bg,
|
||||
})
|
||||
}
|
||||
@@ -121,6 +121,18 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// Presses and lets go of `name` without modifiers, as the keyboard would.
|
||||
pub(super) fn tap_key(&mut self, name: &str, cx: &mut Context<Self>) {
|
||||
let keystroke = Keystroke {
|
||||
modifiers: Default::default(),
|
||||
key: name.into(),
|
||||
key_char: None,
|
||||
};
|
||||
let facts = Facts::default();
|
||||
let record = self.send(&keystroke, Action::Press, facts, false, cx) == Some(true);
|
||||
self.send(&keystroke, Action::Release, facts, record, cx);
|
||||
}
|
||||
|
||||
/// Sends what `keystroke` means to the program: none when it is text the input method types, else whether it went as Windows' record; `pressed` says that of a held key's press.
|
||||
fn send(
|
||||
&mut self,
|
||||
@@ -343,6 +355,11 @@ impl EntityInputHandler for TerminalView {
|
||||
_: &mut Window,
|
||||
_: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
log::debug!(
|
||||
"terminal {}: the input method asks for the caret, {:?}",
|
||||
self.pty.pid,
|
||||
self.caret
|
||||
);
|
||||
Some(self.caret)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ const LEFT_OPTION: u64 = 0x20;
|
||||
const RIGHT_OPTION: u64 = 0x40;
|
||||
|
||||
/// What gpui drops from the key event AppKit is handling.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct KeyFacts {
|
||||
pub left_option: bool,
|
||||
pub right_option: bool,
|
||||
|
||||
+45
-6
@@ -1,8 +1,8 @@
|
||||
/// A semantic prompt mark a program sends, OSC 133.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
/// A prompt starts: A, or N.
|
||||
Prompt,
|
||||
/// A prompt starts: A, or N, saying how its line takes a click.
|
||||
Prompt(Click),
|
||||
/// The prompt ends and input starts: B, or I.
|
||||
Input,
|
||||
/// Input ends and the command's output starts: C.
|
||||
@@ -11,6 +11,17 @@ pub enum Mark {
|
||||
Done(Option<i32>),
|
||||
}
|
||||
|
||||
/// How the line a prompt reads takes a click, as its start's `click_events` says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Click {
|
||||
/// Arrows the terminal counts.
|
||||
Arrows,
|
||||
/// A mouse press at the click's place on the screen, `click_events=1`.
|
||||
Absolute,
|
||||
/// A mouse press, its row counted from the prompt's first, `click_events=2`.
|
||||
Relative,
|
||||
}
|
||||
|
||||
/// The mark in an OSC 133 body past `133;`; none for one the terminal takes no action on.
|
||||
pub(crate) fn read(body: &[u8]) -> Option<Mark> {
|
||||
let shown = || String::from_utf8_lossy(body);
|
||||
@@ -27,7 +38,7 @@ pub(crate) fn read(body: &[u8]) -> Option<Mark> {
|
||||
}
|
||||
};
|
||||
match letter {
|
||||
b'A' | b'N' => Some(Mark::Prompt),
|
||||
b'A' | b'N' => Some(Mark::Prompt(click(options))),
|
||||
b'B' | b'I' => Some(Mark::Input),
|
||||
b'C' => Some(Mark::Output),
|
||||
b'D' => Some(Mark::Done(status(options))),
|
||||
@@ -43,6 +54,25 @@ pub(crate) fn read(body: &[u8]) -> Option<Mark> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How A's options say its line takes a click; arrows without `click_events`.
|
||||
fn click(options: &[u8]) -> Click {
|
||||
let value = options
|
||||
.split(|&byte| byte == b';')
|
||||
.find_map(|option| option.strip_prefix(b"click_events="));
|
||||
match value {
|
||||
None | Some(b"0") => Click::Arrows,
|
||||
Some(b"1") => Click::Absolute,
|
||||
Some(b"2") => Click::Relative,
|
||||
Some(other) => {
|
||||
log::warn!(
|
||||
"pty: OSC 133;A with click_events={}, no such mode, so arrows",
|
||||
String::from_utf8_lossy(other)
|
||||
);
|
||||
Click::Arrows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// D's status, its first option; none when the program gave none or no number.
|
||||
fn status(options: &[u8]) -> Option<i32> {
|
||||
let first = options.split(|&byte| byte == b';').next()?;
|
||||
@@ -67,9 +97,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn each_mark_reads_with_its_options_as_ghostty_reads_it() {
|
||||
assert_eq!(read(b"A"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"A;click_events=1"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt));
|
||||
assert_eq!(read(b"A"), Some(Mark::Prompt(Click::Arrows)));
|
||||
assert_eq!(
|
||||
read(b"A;click_events=1"),
|
||||
Some(Mark::Prompt(Click::Absolute))
|
||||
);
|
||||
assert_eq!(
|
||||
read(b"A;aid=3;click_events=2"),
|
||||
Some(Mark::Prompt(Click::Relative))
|
||||
);
|
||||
assert_eq!(read(b"A;click_events=0"), Some(Mark::Prompt(Click::Arrows)));
|
||||
assert_eq!(read(b"A;cl=line"), Some(Mark::Prompt(Click::Arrows)));
|
||||
assert_eq!(read(b"N;aid=7"), Some(Mark::Prompt(Click::Arrows)));
|
||||
assert_eq!(read(b"B"), Some(Mark::Input));
|
||||
assert_eq!(read(b"I"), Some(Mark::Input));
|
||||
assert_eq!(read(b"C;cmdline_url=false"), Some(Mark::Output));
|
||||
|
||||
+7
-1
@@ -1,5 +1,8 @@
|
||||
mod boxes;
|
||||
mod click;
|
||||
mod drop;
|
||||
mod find;
|
||||
mod fonts;
|
||||
pub mod frame;
|
||||
mod herdr;
|
||||
pub mod ink;
|
||||
@@ -16,6 +19,7 @@ mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
mod reports;
|
||||
mod scrollbar;
|
||||
pub mod shell;
|
||||
mod status;
|
||||
mod tap;
|
||||
@@ -27,6 +31,8 @@ mod view;
|
||||
mod win32;
|
||||
pub mod zoom;
|
||||
|
||||
pub use drop::Quote;
|
||||
pub use fonts::{ICONS, MONO, load_font};
|
||||
pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn};
|
||||
pub use status::{Kind, Record, Records, State};
|
||||
pub use view::{BLINK, ICONS, MONO, TerminalEvent, TerminalView, load_font};
|
||||
pub use view::{BLINK, TerminalEvent, TerminalView};
|
||||
+5
-1
@@ -54,7 +54,11 @@ pub fn grid(
|
||||
if let Some((row, column)) = at {
|
||||
let corner =
|
||||
origin + point(cell.width * column as f32, cell.height * row as f32);
|
||||
view.caret = Bounds::new(corner, cell);
|
||||
let caret = Bounds::new(corner, cell);
|
||||
if caret != view.caret {
|
||||
log::trace!("terminal {}: the caret moves to {caret:?}", view.pty.pid);
|
||||
view.caret = caret;
|
||||
}
|
||||
}
|
||||
if view.fit(columns, lines, cx) {
|
||||
cx.notify();
|
||||
|
||||
+23
-1
@@ -28,6 +28,8 @@ pub struct Pointer {
|
||||
pub cell: Size<Pixels>,
|
||||
/// The left button is down and drawing a selection.
|
||||
selecting: bool,
|
||||
/// A plain press's cell, until it drags off it; its release may move the shell's cursor.
|
||||
click: Option<Cell>,
|
||||
/// Buttons whose press went to the program.
|
||||
pressed: Vec<Button>,
|
||||
/// The cell of the last report; motion inside it sends nothing.
|
||||
@@ -149,6 +151,8 @@ impl TerminalView {
|
||||
if event.button != MouseButton::Left {
|
||||
return;
|
||||
}
|
||||
let plain = event.click_count == 1 && !event.modifiers.modified();
|
||||
self.pointer.click = plain.then_some(point);
|
||||
let pid = self.pty.pid;
|
||||
let mut term = self.pty.term.lock();
|
||||
match (
|
||||
@@ -183,6 +187,9 @@ impl TerminalView {
|
||||
) {
|
||||
let (cell, point, side) = self.cell_at(event.position);
|
||||
if self.pointer.selecting && event.pressed_button == Some(MouseButton::Left) {
|
||||
if self.pointer.click.is_some_and(|click| click != point) {
|
||||
self.pointer.click = None;
|
||||
}
|
||||
if let Some(selection) = self.pty.term.lock().selection.as_mut() {
|
||||
log::trace!("terminal {}: the selection runs to {point:?}", self.pty.pid);
|
||||
selection.update(point, side);
|
||||
@@ -207,6 +214,15 @@ impl TerminalView {
|
||||
) {
|
||||
if event.button == MouseButton::Left {
|
||||
self.end_selection(cx);
|
||||
let (_, point, _) = self.cell_at(event.position);
|
||||
match self.pointer.click.take() {
|
||||
Some(click) if click != point => log::debug!(
|
||||
"terminal {}: a press at {click:?} let go at {point:?}",
|
||||
self.pty.pid
|
||||
),
|
||||
Some(click) if self.selected().is_none() => self.click_to_move(click, cx),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(released) = button(event.button) {
|
||||
let (cell, ..) = self.cell_at(event.position);
|
||||
@@ -217,6 +233,7 @@ impl TerminalView {
|
||||
/// Ends the mouse's gesture as releases would: a hidden terminal hears none.
|
||||
pub fn let_go(&mut self, cx: &mut Context<Self>) {
|
||||
self.end_selection(cx);
|
||||
self.pointer.click = None;
|
||||
for held in self.pointer.pressed.clone() {
|
||||
log::debug!("terminal {}: hidden with {held:?} down", self.pty.pid);
|
||||
let cell = self
|
||||
@@ -361,6 +378,11 @@ impl TerminalView {
|
||||
log::debug!("terminal {}: no text to paste", self.pty.pid);
|
||||
return;
|
||||
};
|
||||
self.paste_text(&text, cx);
|
||||
}
|
||||
|
||||
/// `text` to the program as a paste, bracketed when it asked.
|
||||
pub(super) fn paste_text(&mut self, text: &str, cx: &mut Context<Self>) {
|
||||
let bracketed = self
|
||||
.pty
|
||||
.term
|
||||
@@ -373,7 +395,7 @@ impl TerminalView {
|
||||
text.chars().count(),
|
||||
if bracketed { ", bracketed" } else { "" }
|
||||
);
|
||||
self.input(keys::paste(&text, bracketed), cx);
|
||||
self.input(keys::paste(text, bracketed), cx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+95
-45
@@ -13,6 +13,24 @@ pub(crate) const LINK: &str = "\u{7f}";
|
||||
/// What a link's id starts with when it carries a prompt's number: the prompt's own link, or a program's inside it.
|
||||
const NUMBERED: char = '\u{7f}';
|
||||
|
||||
/// What ends the id of a link on the line typed after a prompt.
|
||||
const TYPED: char = 'i';
|
||||
|
||||
/// What the terminal's own link stamps: a prompt's cells, by its number, or the line typed after it, OSC 133 B to C, by the commands ended before it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Part {
|
||||
Prompt(u32),
|
||||
Input(u32),
|
||||
}
|
||||
|
||||
/// How a mark moves the link: a prompt starts, its input starts, or the command ends it.
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Edge {
|
||||
Prompt,
|
||||
Input,
|
||||
End,
|
||||
}
|
||||
|
||||
/// What ends a prompt's link.
|
||||
const CLOSE: &[u8] = b"\x1b]8;;\x07";
|
||||
|
||||
@@ -53,12 +71,14 @@ pub(crate) struct Prompts {
|
||||
overflow: bool,
|
||||
/// The held OSC's ESC has gone on: it ended the OSC before.
|
||||
sent: bool,
|
||||
/// The prompt open now, from its start mark to its end.
|
||||
prompt: Option<u32>,
|
||||
/// The prompt or input open now, from its mark to the next.
|
||||
open: Option<Part>,
|
||||
/// The target of a program's link open now, to open it again with a prompt's number and without.
|
||||
target: Option<Vec<u8>>,
|
||||
/// The last prompt's number.
|
||||
count: u32,
|
||||
/// Commands ended, C or D, so every prompt a shell draws again on one line shares its input's number.
|
||||
ended: u32,
|
||||
/// What goes to the parser from the last read.
|
||||
out: Vec<u8>,
|
||||
}
|
||||
@@ -116,7 +136,7 @@ impl Prompts {
|
||||
(State::Escape { .. }, _) => {
|
||||
// A full reset clears the link the parser writes with.
|
||||
if byte == b'c' {
|
||||
(self.prompt, self.target) = (None, None);
|
||||
(self.open, self.target) = (None, None);
|
||||
}
|
||||
self.held.push(byte);
|
||||
self.release();
|
||||
@@ -175,7 +195,7 @@ impl Prompts {
|
||||
let length = self.body.len().min(prefix.len());
|
||||
self.body[..length] == prefix[..length]
|
||||
};
|
||||
!self.overflow && (fits(b"133;") || (self.prompt.is_some() && fits(b"8;")))
|
||||
!self.overflow && (fits(b"133;") || (self.open.is_some() && fits(b"8;")))
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
@@ -186,27 +206,31 @@ impl Prompts {
|
||||
fn end(&mut self, end: Option<u8>) -> bool {
|
||||
let rewrite = if self.overflow {
|
||||
None
|
||||
} else if let Some(opens) = self.body.strip_prefix(b"133;").and_then(mark) {
|
||||
if opens {
|
||||
self.count = self.count.wrapping_add(1);
|
||||
self.prompt = Some(self.count);
|
||||
// A prompt inside a program's link carries its number on that link.
|
||||
Some(match &self.target {
|
||||
Some(target) => link(self.count, target),
|
||||
None => link(self.count, LINK.as_bytes()),
|
||||
})
|
||||
} else {
|
||||
match (self.prompt.take(), &self.target) {
|
||||
(Some(_), Some(target)) => Some(plain(target)),
|
||||
(Some(_), None) => Some(CLOSE.to_vec()),
|
||||
(None, _) => None,
|
||||
} else if let Some(edge) = self.body.strip_prefix(b"133;").and_then(mark) {
|
||||
// A prompt or input inside a program's link carries its number on that link.
|
||||
let target = self.target.as_deref().unwrap_or(LINK.as_bytes());
|
||||
match (edge, self.open) {
|
||||
(Edge::Prompt, _) => {
|
||||
self.count = self.count.wrapping_add(1);
|
||||
self.open = Some(Part::Prompt(self.count));
|
||||
Some(link(Part::Prompt(self.count), target))
|
||||
}
|
||||
(Edge::Input, Some(Part::Prompt(_))) => {
|
||||
self.open = Some(Part::Input(self.ended));
|
||||
Some(link(Part::Input(self.ended), target))
|
||||
}
|
||||
(Edge::Input, _) => None,
|
||||
(Edge::End, open) => {
|
||||
self.ended = self.ended.wrapping_add(1);
|
||||
self.open = None;
|
||||
open.map(|_| self.target.as_deref().map_or_else(|| CLOSE.to_vec(), plain))
|
||||
}
|
||||
}
|
||||
} else if let Some((opens, target)) = user(&self.body) {
|
||||
self.target = opens.then(|| target.to_vec());
|
||||
// Inside a prompt a program's link carries the prompt's number; its end takes the prompt's link up again.
|
||||
// Inside a prompt or input a program's link carries its number; its end takes the terminal's link up again.
|
||||
let target = if opens { target } else { LINK.as_bytes() };
|
||||
self.prompt.map(|number| link(number, target))
|
||||
self.open.map(|part| link(part, target))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -232,15 +256,16 @@ impl Prompts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mark's body opens a prompt or ends one, as `marks::read` reads its letter; none for neither.
|
||||
fn mark(body: &[u8]) -> Option<bool> {
|
||||
/// How a mark's body moves the link, as `marks::read` reads its letter; none for no mark.
|
||||
fn mark(body: &[u8]) -> Option<Edge> {
|
||||
let (&letter, rest) = body.split_first()?;
|
||||
if !rest.is_empty() && rest[0] != b';' {
|
||||
return None;
|
||||
}
|
||||
match letter {
|
||||
b'A' | b'N' => Some(true),
|
||||
b'B' | b'I' | b'C' | b'D' => Some(false),
|
||||
b'A' | b'N' => Some(Edge::Prompt),
|
||||
b'B' | b'I' => Some(Edge::Input),
|
||||
b'C' | b'D' => Some(Edge::End),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -255,9 +280,13 @@ fn user(body: &[u8]) -> Option<(bool, &[u8])> {
|
||||
Some((opens, target))
|
||||
}
|
||||
|
||||
/// A link to `target` whose id carries prompt `number`.
|
||||
fn link(number: u32, target: &[u8]) -> Vec<u8> {
|
||||
let mut link = format!("\x1b]8;id={NUMBERED}{number};").into_bytes();
|
||||
/// A link to `target` whose id carries `part`'s prompt number.
|
||||
fn link(part: Part, target: &[u8]) -> Vec<u8> {
|
||||
let id = match part {
|
||||
Part::Prompt(number) => format!("{NUMBERED}{number}"),
|
||||
Part::Input(number) => format!("{NUMBERED}{number}{TYPED}"),
|
||||
};
|
||||
let mut link = format!("\x1b]8;id={id};").into_bytes();
|
||||
link.extend_from_slice(target);
|
||||
link.push(0x07);
|
||||
link
|
||||
@@ -271,34 +300,55 @@ fn plain(target: &[u8]) -> Vec<u8> {
|
||||
link
|
||||
}
|
||||
|
||||
/// The number of the prompt `row` shows, as a link's id carries it.
|
||||
/// The prompt or input `cell` belongs to, as its link's id says.
|
||||
pub(super) fn part(cell: &Cell) -> Option<Part> {
|
||||
let link = cell.hyperlink()?;
|
||||
let number = link.id().strip_prefix(NUMBERED)?;
|
||||
match number.strip_suffix(TYPED) {
|
||||
Some(typed) => typed.parse().ok().map(Part::Input),
|
||||
None => number.parse().ok().map(Part::Prompt),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `cell` belongs to a prompt.
|
||||
pub(super) fn prompted(cell: &Cell) -> bool {
|
||||
matches!(part(cell), Some(Part::Prompt(_)))
|
||||
}
|
||||
|
||||
/// The number of the prompt `row` shows.
|
||||
fn prompt(row: &Row<Cell>) -> Option<u32> {
|
||||
row.into_iter().find_map(|cell| {
|
||||
let link = cell.hyperlink()?;
|
||||
link.id().strip_prefix(NUMBERED)?.parse().ok()
|
||||
row.into_iter().find_map(|cell| match part(cell) {
|
||||
Some(Part::Prompt(number)) => Some(number),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The first row of the nearest prompt at or above `line`, blank rows within it passed.
|
||||
pub(super) fn start<T>(term: &Term<T>, line: i32) -> Option<i32> {
|
||||
let first = term.topmost_line().0;
|
||||
let number = |line: i32| prompt(&term.grid()[Line(line)]);
|
||||
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
|
||||
let (found, own) = (first..=line)
|
||||
.rev()
|
||||
.find_map(|line| number(line).map(|own| (line, own)))?;
|
||||
let mut start = found;
|
||||
for above in (first..found).rev() {
|
||||
match number(above) {
|
||||
Some(other) if other != own => break,
|
||||
Some(_) => start = above,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Some(start)
|
||||
}
|
||||
|
||||
/// The display offset that puts the prompt before or after the view's top at the top, as Ghostty jumps.
|
||||
pub fn jump<T>(term: &Term<T>, up: bool) -> Option<usize> {
|
||||
let top = -(term.grid().display_offset() as i32);
|
||||
let (first, last) = (term.topmost_line().0, term.bottommost_line().0);
|
||||
let number = |line: i32| prompt(&term.grid()[Line(line)]);
|
||||
// ponytail: a scan of the rows on the way; index prompt rows if long histories lag.
|
||||
let found = if up {
|
||||
// The nearest prompt above the view's top, then its first row, blank rows within it passed.
|
||||
let (line, own) = (first..top)
|
||||
.rev()
|
||||
.find_map(|line| number(line).map(|own| (line, own)))?;
|
||||
let mut start = line;
|
||||
for above in (first..line).rev() {
|
||||
match number(above) {
|
||||
Some(other) if other != own => break,
|
||||
Some(_) => start = above,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
start
|
||||
start(term, top - 1)?
|
||||
} else {
|
||||
// The prompt at or above the view's top may go on below it; the next one starts.
|
||||
let here = (first..=top).rev().find_map(number);
|
||||
|
||||
+58
-16
@@ -40,7 +40,12 @@ fn target_of(term: &Term<VoidListener>, line: i32, column: usize) -> Option<Stri
|
||||
fn a_mark_becomes_its_prompt_s_numbered_link() {
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x1b\\x"]),
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;;\x07\x1b\\x"
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;id=\x7f0i;\x7f\x07\x1b\\x"
|
||||
);
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A\x07$ \x1b]133;B\x07ls\x1b]133;C\x07out"]),
|
||||
b"\x1b]8;id=\x7f1;\x7f\x07$ \x1b]8;id=\x7f0i;\x7f\x07ls\x1b]8;;\x07out",
|
||||
"the input runs from B to C"
|
||||
);
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b]133;A;k=i\x07\x1b]133;D;1\x18"]),
|
||||
@@ -63,7 +68,11 @@ fn the_rewrite_reads_a_mark_as_the_session_hears_it() {
|
||||
"A", "A;k=i", "N", "B", "I", "C;x", "D;1", "D;abc", "D", "", "AB", "L", "P;k=i", "Q", "a",
|
||||
];
|
||||
for body in bodies {
|
||||
let heard = marks::read(body.as_bytes()).map(|mark| mark == Mark::Prompt);
|
||||
let heard = marks::read(body.as_bytes()).map(|mark| match mark {
|
||||
Mark::Prompt(_) => Edge::Prompt,
|
||||
Mark::Input => Edge::Input,
|
||||
Mark::Output | Mark::Done(_) => Edge::End,
|
||||
});
|
||||
assert_eq!(mark(body.as_bytes()), heard, "{body}");
|
||||
}
|
||||
}
|
||||
@@ -154,14 +163,11 @@ fn a_program_s_link_inside_a_prompt_carries_its_number() {
|
||||
&open.grid()[Line(0)][Column(1)],
|
||||
&open.grid()[Line(0)][Column(2)],
|
||||
);
|
||||
assert!(
|
||||
z.hyperlink()
|
||||
.is_some_and(|link| !link.id().starts_with(NUMBERED))
|
||||
);
|
||||
assert_eq!(part(z), Some(Part::Input(0)), "the input inside it");
|
||||
assert_eq!(
|
||||
target_of(&open, 0, 2).as_deref(),
|
||||
Some("file:///x"),
|
||||
"its own again past the prompt"
|
||||
"its own target past the prompt"
|
||||
);
|
||||
assert!(
|
||||
y.hyperlink()
|
||||
@@ -178,16 +184,12 @@ fn a_long_target_numbers_a_prompt_in_either_order() {
|
||||
for bytes in [before, inside] {
|
||||
let term = parsed(bytes.as_bytes(), 8, 2);
|
||||
let row = &term.grid()[Line(0)];
|
||||
let numbered = |column: usize| {
|
||||
row[Column(column)]
|
||||
.hyperlink()
|
||||
.is_some_and(|link| link.id().starts_with(NUMBERED))
|
||||
};
|
||||
assert_eq!(prompt(row), Some(1), "{bytes:?}");
|
||||
assert!(
|
||||
numbered(1) && !numbered(2),
|
||||
prompted(&row[Column(1)]) && !prompted(&row[Column(2)]),
|
||||
"the prompt's cell alone: {bytes:?}"
|
||||
);
|
||||
assert_eq!(part(&row[Column(2)]), Some(Part::Input(0)), "{bytes:?}");
|
||||
assert_eq!(target_of(&term, 0, 2).as_deref(), Some(target.as_str()));
|
||||
}
|
||||
}
|
||||
@@ -215,7 +217,7 @@ fn controls_after_an_osc_s_closing_escape_run_where_vte_runs_them() {
|
||||
fn del_and_high_bytes_in_an_escape_go_on_after_it() {
|
||||
assert_eq!(
|
||||
passed(&[b"\x1b\x7f]133;A\x07$\x1b\xff", b"]133;B\x07"]),
|
||||
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;;\x07"
|
||||
b"\x1b\x7f]8;id=\x7f1;\x7f\x07$\x1b\xff]8;id=\x7f0i;\x7f\x07"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -228,7 +230,7 @@ fn controls_vte_runs_inside_an_escape_go_on_before_the_link() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
|
||||
fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_its_own() {
|
||||
let term = parsed(
|
||||
b"x\r\n\x1b]133;A\x07\x1b[31m$\x1b[0m \x1b]133;B\x07ls",
|
||||
8,
|
||||
@@ -237,7 +239,17 @@ fn a_prompt_s_cells_carry_its_link_through_sgr_0_and_its_input_none() {
|
||||
assert_eq!(prompt(&term.grid()[Line(0)]), None);
|
||||
assert_eq!(prompt(&term.grid()[Line(1)]), Some(1));
|
||||
assert_eq!(target_of(&term, 1, 1).as_deref(), Some(LINK));
|
||||
assert_eq!(target_of(&term, 1, 2), None, "input");
|
||||
assert_eq!(
|
||||
part(&term.grid()[Line(1)][Column(2)]),
|
||||
Some(Part::Input(0)),
|
||||
"input"
|
||||
);
|
||||
let ran = parsed(
|
||||
b"\x1b]133;A\x07$\x1b]133;B\x07ls\x1b]133;C\x07\r\nout",
|
||||
8,
|
||||
2,
|
||||
);
|
||||
assert_eq!(target_of(&ran, 1, 0), None, "output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -329,3 +341,33 @@ fn a_prompt_keeps_its_start_through_a_resize() {
|
||||
let top = term.topmost_line().0 + first.expect("prompt 1") as i32;
|
||||
assert_eq!(offset as i32, -top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_carries_the_commands_ended_before_it() {
|
||||
let line = b"\x1b]133;A\x07$\x1b]133;B\x07ab\x1b]133;A\x07R\x1b]133;B\x07c";
|
||||
let term = parsed(line, 8, 2);
|
||||
let at = |column: usize| part(&term.grid()[Line(0)][Column(column)]);
|
||||
assert_eq!(
|
||||
(at(0), at(1), at(3)),
|
||||
(
|
||||
Some(Part::Prompt(1)),
|
||||
Some(Part::Input(0)),
|
||||
Some(Part::Prompt(2))
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
at(4),
|
||||
Some(Part::Input(0)),
|
||||
"a prompt drawn again keeps the line"
|
||||
);
|
||||
let next = parsed(
|
||||
b"\x1b]133;A\x07$\x1b]133;B\x07a\x1b]133;C\x07\x1b]133;D\x07\x1b]133;A\x07$\x1b]133;B\x07b",
|
||||
8,
|
||||
2,
|
||||
);
|
||||
assert_eq!(
|
||||
part(&next.grid()[Line(0)][Column(3)]),
|
||||
Some(Part::Input(2)),
|
||||
"C and D each end it"
|
||||
);
|
||||
}
|
||||
+5
-1
@@ -11,7 +11,7 @@ use alacritty_terminal::{
|
||||
};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
|
||||
|
||||
use super::{marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
|
||||
use super::{drop::Quote, marks::Mark, mouse::MouseMode, notify::Ask, status::Status, tap::Tapped};
|
||||
use crate::settings::{CursorStyle, Settings};
|
||||
|
||||
/// What a session hears from its pty.
|
||||
@@ -61,12 +61,15 @@ pub struct Launch {
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Shell integration makes it report its folder.
|
||||
pub reports: bool,
|
||||
/// How its shell reads a path dropped on it.
|
||||
pub quote: Quote,
|
||||
}
|
||||
|
||||
/// A running program on a pseudo-terminal and the grid it draws into.
|
||||
pub struct Pty {
|
||||
pub term: Arc<FairMutex<Term<Listener>>>,
|
||||
pub pid: u32,
|
||||
pub quote: Quote,
|
||||
notifier: Notifier,
|
||||
#[cfg(unix)]
|
||||
master: std::fs::File,
|
||||
@@ -152,6 +155,7 @@ pub fn spawn(
|
||||
Pty {
|
||||
term,
|
||||
pid,
|
||||
quote: launch.quote,
|
||||
notifier,
|
||||
#[cfg(unix)]
|
||||
master,
|
||||
|
||||
+12
-1
@@ -5,6 +5,7 @@ use alacritty_terminal::{
|
||||
use futures::StreamExt;
|
||||
|
||||
use super::*;
|
||||
use crate::term::{drop::Quote, marks::Click};
|
||||
use crate::term::{
|
||||
notify::Ask,
|
||||
status::{Record, State},
|
||||
@@ -34,6 +35,7 @@ fn run(script: &str, size: (usize, usize)) -> Ran {
|
||||
cwd: std::env::temp_dir(),
|
||||
env: vec![("ELY".into(), "ok".into())],
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, size, (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
@@ -115,7 +117,12 @@ fn osc_133_reaches_the_session_as_marks() {
|
||||
"printf '\\033]133;A\\007>\\033]133;B\\007\\033]133;C\\033\\\\x\\033]133;D;3\\007'",
|
||||
(40, 4),
|
||||
);
|
||||
let marks = [Mark::Prompt, Mark::Input, Mark::Output, Mark::Done(Some(3))];
|
||||
let marks = [
|
||||
Mark::Prompt(Click::Arrows),
|
||||
Mark::Input,
|
||||
Mark::Output,
|
||||
Mark::Done(Some(3)),
|
||||
];
|
||||
assert_eq!(ran.marks, marks);
|
||||
assert_eq!(ran.text, ">x");
|
||||
assert_eq!(ran.prompt, [0], "the prompt's cell, not the output's");
|
||||
@@ -235,6 +242,7 @@ fn the_foreground_is_the_program_s_own_group() {
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
let (pty, _events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
@@ -264,6 +272,7 @@ fn close_after_ready(script: &str, reaped: &str) {
|
||||
cwd: std::env::temp_dir(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
let (pty, mut events) =
|
||||
spawn(&launch, (20, 4), (8, 16), config(&Settings::default())).expect("sh starts");
|
||||
@@ -310,6 +319,7 @@ fn a_missing_folder_stops_the_start() {
|
||||
cwd: "/nonexistent/ely".into(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
let error = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()))
|
||||
.err()
|
||||
@@ -328,6 +338,7 @@ fn a_folder_that_cannot_be_entered_stops_the_start() {
|
||||
cwd: folder.clone(),
|
||||
env: Vec::new(),
|
||||
reports: false,
|
||||
quote: Quote::Posix,
|
||||
};
|
||||
let started = spawn(&launch, (20, 4), (8, 16), config(&Settings::default()));
|
||||
std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).expect("700");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -7,7 +7,7 @@ use std::{
|
||||
use anyhow::Context as _;
|
||||
use gpui::{App, Global};
|
||||
|
||||
use super::pty::Launch;
|
||||
use super::{drop::Quote, pty::Launch};
|
||||
|
||||
/// Each script, by its path under the integration folder.
|
||||
const SCRIPTS: [(&str, &str); 4] = [
|
||||
@@ -89,6 +89,11 @@ pub fn launch(shell: &str, cwd: PathBuf, dir: &Path) -> anyhow::Result<Launch> {
|
||||
cwd,
|
||||
env: Vec::new(),
|
||||
reports: true,
|
||||
quote: match name.as_str() {
|
||||
"pwsh" | "powershell" => Quote::PowerShell,
|
||||
"cmd" => Quote::Cmd,
|
||||
_ => Quote::Posix,
|
||||
},
|
||||
};
|
||||
let script = |name: &str| dir.join(name).display().to_string();
|
||||
match name.as_str() {
|
||||
|
||||
+44
-7
@@ -9,7 +9,11 @@ use futures::channel::mpsc::TryRecvError;
|
||||
use super::*;
|
||||
use crate::{
|
||||
settings::Settings,
|
||||
term::{Events, Pty, PtyEvent, config, marks::Mark, spawn},
|
||||
term::{
|
||||
Events, Pty, PtyEvent, config,
|
||||
marks::{Click, Mark},
|
||||
spawn,
|
||||
},
|
||||
};
|
||||
|
||||
/// A folder name with a space, a percent, a bell and a newline.
|
||||
@@ -146,9 +150,9 @@ impl Run {
|
||||
|
||||
/// The prompt's folder, the script loaded by `probe`, then a cd into the inner folder.
|
||||
fn walk(&mut self, probe: &str) {
|
||||
let first = [Mark::Prompt, Mark::Input];
|
||||
let first = [Mark::Prompt(Click::Arrows), Mark::Input];
|
||||
self.until("the first prompt's marks", |run| {
|
||||
run.marks.starts_with(&first)
|
||||
run.marks.len() >= 2 && ends_like(&run.marks[..2], &first)
|
||||
});
|
||||
let start = self.start.clone();
|
||||
self.send(probe);
|
||||
@@ -173,7 +177,7 @@ impl Run {
|
||||
let from = self.marks.len();
|
||||
self.send(line);
|
||||
self.until(&format!("the marks of {line}"), |run| {
|
||||
run.marks[from..].ends_with(marks)
|
||||
ends_like(&run.marks[from..], marks)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,11 +226,22 @@ fn ended(status: i32) -> [Mark; 4] {
|
||||
[
|
||||
Mark::Output,
|
||||
Mark::Done(Some(status)),
|
||||
Mark::Prompt,
|
||||
Mark::Prompt(Click::Arrows),
|
||||
Mark::Input,
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether `heard` ends as `wanted` does, a prompt's way of taking a click aside: fish from 4.1 takes clicks itself.
|
||||
fn ends_like(heard: &[Mark], wanted: &[Mark]) -> bool {
|
||||
let Some(start) = heard.len().checked_sub(wanted.len()) else {
|
||||
return false;
|
||||
};
|
||||
heard[start..].iter().zip(wanted).all(|pair| match pair {
|
||||
(Mark::Prompt(_), Mark::Prompt(_)) => true,
|
||||
(heard, wanted) => heard == wanted,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scripts_install_once_and_mend_a_changed_one() {
|
||||
let dir = folder("install");
|
||||
@@ -289,7 +304,8 @@ fn zsh_says_why_it_marks_no_prompt_without_prompt_percent() {
|
||||
run.send("false");
|
||||
let ended = [Mark::Output, Mark::Done(Some(1))];
|
||||
run.until("the command's marks", |run| run.marks.ends_with(&ended));
|
||||
assert!(!run.marks.contains(&Mark::Prompt), "{:?}", run.marks);
|
||||
let prompted = run.marks.iter().any(|mark| matches!(mark, Mark::Prompt(_)));
|
||||
assert!(!prompted, "{:?}", run.marks);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
@@ -349,7 +365,7 @@ fn bash_says_why_it_marks_no_command_without_promptvars() {
|
||||
run.shows("ELYTerminal: bash marks no command while promptvars is off")
|
||||
});
|
||||
run.walk("declare -F _ely_prompt >/dev/null && echo ELY-LOAD''ED");
|
||||
run.ends("false", &[Mark::Prompt, Mark::Input]);
|
||||
run.ends("false", &[Mark::Prompt(Click::Arrows), Mark::Input]);
|
||||
run.finish();
|
||||
}
|
||||
|
||||
@@ -421,3 +437,24 @@ fn an_unknown_shell_starts_without_integration() {
|
||||
assert_eq!(launch.program, Some(("/bin/sh".into(), Vec::new())));
|
||||
assert!(launch.env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_shell_reads_a_dropped_path_its_own_way() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = folder("quote");
|
||||
for (name, quote) in [
|
||||
("zsh", Quote::Posix),
|
||||
("fish", Quote::Posix),
|
||||
("pwsh", Quote::PowerShell),
|
||||
("powershell", Quote::PowerShell),
|
||||
("cmd", Quote::Cmd),
|
||||
("other", Quote::Posix),
|
||||
] {
|
||||
let program = dir.join(name);
|
||||
fs::write(&program, "").expect("a program");
|
||||
fs::set_permissions(&program, fs::Permissions::from_mode(0o755)).expect("its mode");
|
||||
let shell = program.to_str().expect("utf-8");
|
||||
let launch = launch(shell, PathBuf::from("/"), &dir).expect("a launch");
|
||||
assert_eq!(launch.quote, quote, "{name}");
|
||||
}
|
||||
}
|
||||
+25
-80
@@ -12,35 +12,28 @@ use ely_gpui_component::{
|
||||
};
|
||||
use futures::StreamExt;
|
||||
use gpui::{
|
||||
Bounds, Context, EventEmitter, FocusHandle, Focusable, Font, FontFeatures, FontId, FontStyle,
|
||||
FontWeight, IntoElement, MouseButton, ParentElement, Pixels, Render, Styled, Subscription,
|
||||
Task, TextSystem, Window, div, prelude::*, px, size,
|
||||
Bounds, Context, EventEmitter, ExternalPaths, FocusHandle, Focusable, IntoElement, MouseButton,
|
||||
ParentElement, Pixels, Render, Styled, Subscription, Task, Window, div, prelude::*, px,
|
||||
};
|
||||
|
||||
use super::{
|
||||
click::Shell,
|
||||
find::Finder,
|
||||
fonts::look,
|
||||
frame::frame,
|
||||
ink::Ink,
|
||||
input::{self, Find, Held, NextPrompt, PreviousPrompt},
|
||||
marks::Mark,
|
||||
mouse::MouseMode,
|
||||
paint::{self, Look},
|
||||
paint,
|
||||
pointer::Pointer,
|
||||
pty::{Events, Pty, PtyEvent, config, fit},
|
||||
reports::Notices,
|
||||
scrollbar::{self, Span},
|
||||
status::{Records, State},
|
||||
zoom,
|
||||
};
|
||||
use crate::{
|
||||
settings::{Settings, SettingsStore},
|
||||
themes,
|
||||
};
|
||||
|
||||
/// The bundled Nerd Font that draws icons any font lacks; gpui loads no font without an `m`.
|
||||
pub const ICONS: &str = "JetBrainsMono Nerd Font Mono";
|
||||
|
||||
/// The bundled code font, the default.
|
||||
pub const MONO: &str = "JetBrains Mono";
|
||||
use crate::{settings::SettingsStore, themes};
|
||||
|
||||
/// How long the cursor shows, then hides, while it blinks.
|
||||
pub const BLINK: Duration = Duration::from_millis(600);
|
||||
@@ -84,6 +77,8 @@ pub struct TerminalView {
|
||||
pub(super) ended: Option<i32>,
|
||||
/// A command started, as OSC 133 C says, since the last one ended.
|
||||
pub(super) ran: bool,
|
||||
/// Where the shell stands, as its OSC 133 marks say.
|
||||
pub(super) shell: Shell,
|
||||
/// The system notifications it posted.
|
||||
pub(super) notices: Notices,
|
||||
/// The program's title; empty while it names none.
|
||||
@@ -145,6 +140,7 @@ impl TerminalView {
|
||||
records: Records::default(),
|
||||
ended: None,
|
||||
ran: false,
|
||||
shell: Shell::Busy,
|
||||
notices: Notices::default(),
|
||||
title: String::new(),
|
||||
front: true,
|
||||
@@ -272,12 +268,13 @@ impl TerminalView {
|
||||
PtyEvent::Win32(on) => self.win32 = on,
|
||||
PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx),
|
||||
// A new prompt ends what was working, waiting or idle; done and error stay.
|
||||
PtyEvent::Mark(Mark::Prompt) => {
|
||||
PtyEvent::Mark(Mark::Prompt(click)) => {
|
||||
self.shell = Shell::Prompting(click);
|
||||
self.records.prompt();
|
||||
self.restate(cx);
|
||||
}
|
||||
PtyEvent::Mark(Mark::Output) => self.ran = true,
|
||||
PtyEvent::Mark(_) => {}
|
||||
PtyEvent::Mark(Mark::Output) => (self.ran, self.shell) = (true, Shell::Busy),
|
||||
PtyEvent::Mark(Mark::Input) => self.shell = self.shell.reads(),
|
||||
PtyEvent::Status(status) => {
|
||||
self.status(status);
|
||||
self.restate(cx);
|
||||
@@ -343,65 +340,6 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// `font` as gpui loads it; gpui falls back to another font without a word, and loads none without an `m`.
|
||||
pub fn load_font(font: &Font, system: &TextSystem) -> anyhow::Result<FontId> {
|
||||
let id = system.resolve_font(font);
|
||||
let loaded = system.get_font_for_id(id);
|
||||
anyhow::ensure!(
|
||||
loaded.is_some_and(|loaded| loaded.family == font.family),
|
||||
"font_family is {:?}, which the terminal cannot load",
|
||||
font.family
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Font, cell and metrics at `points` on this window.
|
||||
fn look(settings: &Settings, points: f32, ink: &Ink, window: &Window) -> anyhow::Result<Look> {
|
||||
let font = Font {
|
||||
family: settings.font_family.clone().into(),
|
||||
features: if settings.ligatures {
|
||||
FontFeatures::default()
|
||||
} else {
|
||||
FontFeatures::disable_ligatures()
|
||||
},
|
||||
fallbacks: None,
|
||||
weight: FontWeight::NORMAL,
|
||||
style: FontStyle::Normal,
|
||||
};
|
||||
let text = px(points);
|
||||
let system = window.text_system();
|
||||
let id = load_font(&font, system)?;
|
||||
let advance = system
|
||||
.advance(id, text, 'M')
|
||||
.map_err(|error| anyhow::anyhow!("{} cannot measure M: {error}", settings.font_family))?;
|
||||
let scale = window.scale_factor();
|
||||
let snap = |length: Pixels| px((f32::from(length) * scale).round() / scale);
|
||||
let cell = size(
|
||||
snap(advance.width).max(px(1.)),
|
||||
snap(text * settings.line_height),
|
||||
);
|
||||
let (ascent, descent) = (system.ascent(id, text), system.descent(id, text).abs());
|
||||
let icons = Font {
|
||||
family: ICONS.into(),
|
||||
features: FontFeatures::default(),
|
||||
..font.clone()
|
||||
};
|
||||
Ok(Look {
|
||||
font_id: id,
|
||||
icons,
|
||||
baseline: (cell.height - ascent - descent) / 2. + ascent,
|
||||
descent,
|
||||
rule: snap((text / 14.).max(px(1.))),
|
||||
size: text,
|
||||
cell,
|
||||
font,
|
||||
cursor: ink.cursor,
|
||||
cursor_text: ink.cursor_text,
|
||||
fg: ink.fg,
|
||||
bg: ink.bg,
|
||||
})
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let settings = SettingsStore::get(cx).clone();
|
||||
@@ -413,6 +351,7 @@ impl Render for TerminalView {
|
||||
});
|
||||
let shown = frame(&term, &ink, &lit);
|
||||
let blinks = term.cursor_style().blinking;
|
||||
let span = Span::of(&term);
|
||||
drop(term);
|
||||
let focused = self.focus.is_focused(window) && window.is_window_active();
|
||||
self.blinking = focused && blinks;
|
||||
@@ -447,6 +386,7 @@ impl Render for TerminalView {
|
||||
.on_any_mouse_down(cx.listener(Self::mouse_down))
|
||||
.on_mouse_move(cx.listener(Self::mouse_move))
|
||||
.on_scroll_wheel(cx.listener(Self::wheel))
|
||||
.on_drop(cx.listener(|view, paths: &ExternalPaths, _, cx| view.drop_paths(paths, cx)))
|
||||
.size_full()
|
||||
.px(px(settings.padding_x))
|
||||
.py(px(settings.padding_y))
|
||||
@@ -480,9 +420,14 @@ impl Render for TerminalView {
|
||||
root.text_color(ink.fg).child(format!("{error:#}"))
|
||||
}
|
||||
};
|
||||
div().relative().size_full().child(root).children(
|
||||
self.find_box(cx)
|
||||
.map(|found| div().absolute().top_2().right_4().child(found)),
|
||||
)
|
||||
div()
|
||||
.relative()
|
||||
.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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
//! Checks for a newer release through curl and GitHub'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://api.github.com/repos/ELY-Open-Source/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 GitHub'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 github_s_release_reads() {
|
||||
let body = r#"{"id":213,"tag_name":"v0.1.5","name":"ELYTerminal 0.1.5","html_url":"https://github.com/ELY-Open-Source/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"));
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -29,6 +29,7 @@ use crate::{
|
||||
};
|
||||
|
||||
mod events;
|
||||
mod rename;
|
||||
mod widths;
|
||||
|
||||
use widths::{Dragged, Side};
|
||||
@@ -156,6 +157,8 @@ impl Workspace {
|
||||
self.sessions.push(Session {
|
||||
terminal,
|
||||
title: String::new(),
|
||||
name: String::new(),
|
||||
editing: None,
|
||||
folder: launch.cwd,
|
||||
reports: launch.reports,
|
||||
bell: false,
|
||||
@@ -268,13 +271,9 @@ impl Workspace {
|
||||
&self.sessions
|
||||
}
|
||||
|
||||
/// The shown session's title, or the app's name while its program names none.
|
||||
/// What the shown session is called.
|
||||
fn title(&self) -> &str {
|
||||
self.sessions
|
||||
.get(self.active)
|
||||
.map(|session| session.title.as_str())
|
||||
.filter(|title| !title.is_empty())
|
||||
.unwrap_or(TITLE)
|
||||
self.sessions.get(self.active).map_or(TITLE, Session::named)
|
||||
}
|
||||
|
||||
fn retitle(&self, window: &mut Window) {
|
||||
|
||||
@@ -21,7 +21,10 @@ impl Workspace {
|
||||
};
|
||||
let session = &mut self.sessions[index];
|
||||
match event {
|
||||
TerminalEvent::Title(title) => session.title.clone_from(title),
|
||||
TerminalEvent::Title(title) => {
|
||||
log::debug!("app: session {}'s program names it {title:?}", index + 1);
|
||||
session.title.clone_from(title);
|
||||
}
|
||||
TerminalEvent::Folder(folder) => {
|
||||
session.folder.clone_from(folder);
|
||||
session.reports = true;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! A session renamed in place, as a double-click on its row asks.
|
||||
|
||||
use ely_gpui_component::forms::{InputEvent, TextInput};
|
||||
use gpui::{AppContext, Context, EntityId, Focusable, Window};
|
||||
|
||||
use super::Workspace;
|
||||
|
||||
impl Workspace {
|
||||
/// Opens a field over session `index`'s title, what it is called selected.
|
||||
pub fn rename(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let Some(session) = self.sessions.get(index) else {
|
||||
return;
|
||||
};
|
||||
if session.editing.is_some() {
|
||||
return;
|
||||
}
|
||||
let named = session.named().to_string();
|
||||
let id = session.terminal.entity_id();
|
||||
let input = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx);
|
||||
input.set_text(named.clone(), cx);
|
||||
input.select(0..named.len(), cx);
|
||||
input
|
||||
});
|
||||
let events = cx.subscribe_in(
|
||||
&input,
|
||||
window,
|
||||
move |this, _, event, window, cx| match event {
|
||||
InputEvent::Submit | InputEvent::Blur => this.end_rename(id, true, window, cx),
|
||||
InputEvent::Changed | InputEvent::Focus => {}
|
||||
},
|
||||
);
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
log::debug!("app: session {} is renamed in place", index + 1);
|
||||
self.sessions[index].editing = Some((input, events));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Closes the field renaming the session `id` names, `keep`ing what it holds or not; a focus it still holds goes to the shown terminal.
|
||||
pub fn end_rename(
|
||||
&mut self,
|
||||
id: EntityId,
|
||||
keep: bool,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(session) = self
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.terminal.entity_id() == id)
|
||||
else {
|
||||
log::debug!("app: the session renamed is gone");
|
||||
return;
|
||||
};
|
||||
let Some((input, _)) = session.editing.take() else {
|
||||
return;
|
||||
};
|
||||
let pid = session.terminal.read(cx).pid();
|
||||
// A window that goes inactive blurs the field yet keeps its focus there.
|
||||
let held = input.focus_handle(cx).is_focused(window);
|
||||
if !keep {
|
||||
log::debug!("app: session {pid} keeps its name");
|
||||
} else {
|
||||
session.name = input.read(cx).text().trim().to_string();
|
||||
if session.name.is_empty() {
|
||||
log::info!("app: session {pid} takes its program's title again");
|
||||
} else {
|
||||
log::info!("app: session {pid} is named {:?}", session.name);
|
||||
}
|
||||
}
|
||||
if held && let Some(shown) = self.sessions.get(self.active) {
|
||||
window.focus(&shown.terminal.focus_handle(cx), cx);
|
||||
}
|
||||
self.retitle(window);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -23,7 +23,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| Ely by pinned rev; gpui and gpui_platform from `gpui-pre` at Ely's version | T01 | `Cargo.toml`: Ely `a2cb531`, `gpui-pre` `=0.3.8` | Pass |
|
||||
| `alacritty_terminal` 0.26 | T04 | `=0.26.0` | Pass |
|
||||
| Logs through `log` to stderr, `RUST_LOG` sets the level, no log setting | T01 | `src/main.rs`; no such field in `Settings` | Pass |
|
||||
| CI on Gitea Actions: macOS builds, tests and lints; Ubuntu and Windows build and test | T01, T23 | `.github/workflows/ci.yml`; run 926 on `65bb4d3`, see the CI notes | Pass |
|
||||
| CI on GitHub Actions: macOS builds, tests and lints; Ubuntu and Windows build and test | T01, T23 | `.github/workflows/ci.yml`; run 926 on `65bb4d3`, see the CI notes | Pass |
|
||||
| The repo, private until v0.1.1 and public since; no credential in any file or commit | T01, T26 | T22's review scanned the tree and its 398 reachable objects: 0 hits; T23's scans for the Gitea token and the runners' SSH details: 0 hits; before the owner made it public, a scan of all 91 commits' contents and messages found no token, SSH password, gateway, port, SSH user or private key | Pass |
|
||||
|
||||
## 3. Settings model and storage
|
||||
@@ -63,6 +63,8 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass |
|
||||
| Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass |
|
||||
| JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |
|
||||
| Rename: a double press on a row's title opens a field; Enter keeps the name, Escape drops it, a blur keeps it; the name shows in the row, the header and the window's title over the program's own; an empty one gives the title back | T63 | Ely test: `ListItem::editor` draws its field in the row, above the description; on macOS `shot` over a program titled `prog`: a double press on the row opened a field holding `prog`, selected; `Build box` and Enter showed it in the row, the header and the window's title, `app: session … is named "Build box"`, and the focus came back to the terminal; the program's later title `prog2`, logged four seconds after, left the window's title `Build box`; Escape logged `keeps its name` and left `prog2`; a blur from a press on the terminal while the app was active kept `Blurred`; emptied, the field gave the title back, `takes its program's title again`; a second session's rename named that row alone; round 1 of T63's review found that leaving the app mid-rename closed the field while the window's focus still pointed at it, so back in the app the terminal heard no key until pressed: the terminal now takes a focus the field still holds as it closes, and after the same steps `a` reached the program at once, as it did after Enter, Escape and a blur by a press on the terminal | Pass |
|
||||
| Fallback fonts: installed families tried in order for a character the family lacks, listed under Family in Settings › Appearance | T60 | `settings` test: a file without `font_fallbacks` loads with none, named families keep their order, and an empty one is refused; on macOS with Menlo and `"font_fallbacks": ["Kaiti SC"]` the terminal drew `中文字体 終端` in Kaiti and Latin in Menlo, where without it the system drew them in PingFang; a fallback gpui cannot load, `No Such Font`, stopped the start with `font_fallbacks: the terminal cannot load "No Such Font"`, and an empty one with `font_fallbacks holds an empty family`; in Settings › Appearance, Add fallback opened an empty picker, picking Kaiti SC saved `["Kaiti SC"]` and the terminal took it at once, the row's X saved `[]`, and Apple Color Emoji, which gpui loads no font for, was refused and nothing saved; round 1 of T60's review found that removing the row whose input held the focus left the removed family in it, and Enter saved it over the next: a removal drops that row's input and the focus, so after removing Kaiti SC from `[Kaiti SC, Songti SC, PingFang SC]` with its input focused the rows showed Songti SC and PingFang SC and Enter saved nothing new; round 2 found the pickers keyed by place, so after that removal a blur wrote PingFang SC into the first row and Enter saved it twice: each picker is keyed by its input, and the same removal, a click, the app activated and deactivated, and Enter, which in the build before saved `[PingFang SC, PingFang SC]`, kept `[Songti SC, PingFang SC]`; on Linux, gpui's cosmic-text path carries a character a second fallback took into the next, as AGENTS records | Pass |
|
||||
| Cursor shapes, blink by setting and DECSCUSR, hollow unfocused | T06 | T06 review | 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 |
|
||||
@@ -70,6 +72,11 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| 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; inside herdr, herdr opens them whole | T09, T45 | `term::links` tests; on macOS with real herdr 0.9.5 and a stand-in `open` on its PATH, Cmd-press on a URL herdr wraps, on its wrapped tail, on an OSC 8 link and on a URL over three rows opened the whole URL and the OSC 8 target, where before the app opened `.../ELYTermin` and nothing; Cmd-hover underlined the long URL across its rows; outside herdr the app opened the URL itself; an OSC 8 link whose label holds `file:///etc/hosts` and whose target is a web page opened the page through herdr, which keeps the target from the terminal while it takes the mouse; Shift-Cmd-press on a plain `file:///etc/hosts` inside herdr the app opened itself, caught by an `NSWorkspace` stand-in | Pass |
|
||||
| Focus reporting, title, scrollback limit, find | T10 | `term::find` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass |
|
||||
| A 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 |
|
||||
| Windows 10: dragging a blurred window follows the pointer, its acrylic clear while it moves | T65 | on the owner's Windows 11 PC (build 26300) the release path logged `blur: Windows build 26300 moves acrylic as it is`; a probe build with the build check raised logged `acrylic clears while the window moves`, and a drag by the terminal's header moved the window 180 by 90 pixels, logged `the window moves clear` and `the window rests blurred again`, and pictured clear glass mid-drag and acrylic at rest; round 1 of T65's review found that blur turned on mid-move, which gpui sets as acrylic at once, kept acrylic for the rest of the move: a moving mark now clears it again, and the reviewer's replay of the procedure and the blur's changes against recorded Win32 calls passed its 9 cases, a blur turned on and on again mid-move among them; the drag on the PC logged both switches again; Windows 10 went untested | Pass |
|
||||
| IME on Windows: the candidate box at the caret | T61 | on the owner's Windows 11 PC at 150%, WeType under powershell.exe: 23 compositions, on one line, on lines below, begun again at once after a commit, and under a status line redrawn every 100 ms, each logged `the input method asks for the caret` at the cursor's cell and drew the box under it; the user's jump did not reproduce and the owner closed it | Closed |
|
||||
| Drop: files dropped on the terminal type their paths at the cursor, kept literal for the session's shell, each followed by a space | T66 | `term::drop` tests: a POSIX path escapes what a shell reads specially, spaces, quotes, parentheses, `&`, `$`, `^`, `~` and `#` among them, CJK kept, each path followed by a space; a PowerShell path is single-quoted with its quotes doubled, curly ones too; a cmd path is double-quoted, and one holding `%` types nothing; a path with a control character types nothing; `term::shell` test: zsh, fish and other shells take POSIX quoting, pwsh and powershell PowerShell's, cmd cmd's; round 1 of T66's review found Windows' quotes around a spaced path alone leaving `$(...)` and `;` live in PowerShell, and `^` unescaped under zsh's `extendedglob`: the formatter, extracted, typed 22 hard names that zsh with and without `extendedglob`, bash with `extglob`, fish and PowerShell each read back exactly, `it's`, `$HOME`, `$(1+1)`, `a;Write-Output(42)` and curly quotes among them, and cmd on the owner's PC echoed `&`, `|`, `<`, `>` and `^` inside its quotes back as text while it expanded `%PATH%`; round 2 found cmd's `/v:on` expanding `!` inside quotes, and `"C:\"` read by a program as one argument with the next: a `!` now stops a cmd drop, and the backslashes ending a path are doubled, so node, run from a cmd batch line on the owner's PC, received `C:\`, `D:\clip.mp4`, `C:\My Files\a&b^c.txt`, `C:\x|y<z>(1).txt`, `C:\dir with space\` and `C:\tmp\a;b,c=d.txt` exactly; on macOS `shot` dropped `/tmp/my file.png`, `/Users/x/it's (1).txt` and `/a/中文 b` on zsh after a pointer move, and the log read `3 paths dropped` and `pastes 53 characters, bracketed`; without the move, after typing, gpui passed the drop by, as AGENTS records | Pass |
|
||||
| Click to move: a plain click on the line the shell reads, past its prompt, moves its cursor there, the press itself to a shell that takes clicks, else arrows | T59 | `term::marks` test: A's `click_events=1` and `2` read as a press at the screen's row or counted from the prompt; `term::click` tests: a shell that takes clicks hears an SGR left press at the clicked cell, a row below the prompt too, none before the prompt, in the history or on the alternate screen; B reads the line its prompt started; a click on the typed line counts the characters to it, left or right, past the end means the end, a wide character takes one arrow, soft-wrapped rows are one line, trailing spaces count up to the cursor, and a click on the prompt, another line, a line no prompt shows, output without marks or the alternate screen moves nothing; on macOS `shot` ran zsh with a marked prompt: after `echo hello` a click on the `h` logged `moves the cursor -5`, and `X` typed then ran `echo Xhello`; a drag sent nothing, and clicks on the prompt and on output logged `off the input`; round 1 of T59's review found fish 4.9 a code point per Left, so a count put `X` inside `é`, and its redraw dropped the wrap flag a count followed: with fish's own `click_events=1` the press goes to fish, which then typed `echo Xéabc`, and a second click on the redrawn wrapped line put `Y` at row 2, column 3; zsh still counted 4 arrows; a press let go in another cell and one whose program turned on tracking before the release sent nothing; round 2 found a click on fish's `$` sent to fish, which moved its cursor to the start: a press now goes only past the prompt's last cell before the cursor and never on a prompt cell, so clicks on `$` and its space logged `off the input` and `X` still ended `echo helloX`; round 3 found that a prompt whose only cells lie after the cursor, fish's empty left prompt beside a marked right one, crashed the press: with no prompt cell before the cursor the input starts at the prompt's first row, and a click on `o` there put `X` at `echXo`; round 3 found that a prompt that prints nothing left no cells to find the input by, so clicks on fish's typed line did nothing: B now stamps the typed line with a link of its own, numbered by the commands ended, so fish's empty prompt gave `abcXdef`, with a plain or no right prompt too, a click on the output above moved nothing, and fish's right prompt drawn again at each key kept the typed line's number, `echXo`; `term::prompts` test: input carries the commands ended before it, through a prompt drawn again; `term::click` tests: a prompt that prints nothing starts the input at its first typed cell, the output and the line run before refused; round 4 found the newest typed line taken by its place: after `printf '\e[H'` fish drew its prompt at the top over older lines, and a click on its typed line did nothing; the newest line is now the one of the highest number, and a press goes no lower than its last row or the cursor's, so that click gave `abcXdef`, the reviewer's scene sent `ESC[<0;6;1M`, and a click on the older text below moved nothing, where fish had moved its cursor | Pass |
|
||||
| A bell makes no sound; the row marks it | T10, T12 | T12 review | Pass |
|
||||
|
||||
## 6. Main window and layout
|
||||
@@ -84,7 +91,8 @@ 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 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 |
|
||||
| Ely peeks over the window's top edge from behind it; her face follows the sessions; a press pets her | T46, T48, T49 | `pet::look` tests: the most pressing mark sets her mood; she moves in every mood, weeks into one too; she blinks on her own, twice at times, her eyes opening as an act begins; calm glances, then dozes with zs; a finish cheers her with a hop; work runs her eyes along the lines and taps her fingers; a wait widens her eyes and hops her twice a round; an error shakes her and runs a drop down; a press winks with a rising heart; she rises from behind the edge; a change of act eases her from her last pose, her brows easing and the marks she leaves fading; `pet` tests: a mood that changes inside a wink, an old finish cleared mid-glance and a second press mid-hop each ease her from where she was, where round 1 of T49's review found her drop 12 points; `pet::rig` tests: every layer of the icon's paths parses and stays in its box, and every mark has its layer; on macOS `shot`'s snaps, 40 in 10 s while calm, caught her breathing, blinking and glancing, and two a second apart in each mood caught her reading along, rocking her mark, fretting with her drop, cheering, and easing back to calm; `pet::x11` test under Xvfb: her window stands on the pane, just above the window manager's frame, and unmaps and maps with the main window; on macOS `shot` found her the main window's child, 112 by 124 points on its top edge 140 in, a capture of the screen showed her over the edge, and OSC 7501 reports gave her surprise, worry, eyes down and a cheer; a press on her window left the main window key and every key typed before and after it reached the program; switched off and on during an error she came back worried; on a 100-point window she stood at its left edge, and moved, resized and minimized she followed it; on Windows the `pet::windows` test, red before T48, finds the framed popup Windows makes, 1898 by 1481, bare in her own box, 168 by 185 at 150%, on the pane's top edge, and no size until her window takes the place posted to it; `pet::windows` tests, red before T48's second round: with the main window at 1, 1.5 or 2 and hers at 1, 1.25 or 1.5, her height is her own and her bottom the pane's top, where it fell 62 pixels into the terminal, and at a new DPI gpui takes the rect Windows suggests, then her box comes back on her corner, or Windows' rect stands where that box would lie on a display of another DPI; two displays side by side at 144 and 96 DPI, simulated, settle her after one change, where a corner kept always, red before T48's third round, changed her DPI back and forth; and two places piled up before either ran drain on the last corner in her DPI's size, where a keep whose DPI had passed, red before T48's fourth change, set it back and forth, and where a place sized when it was posted left her 112 by 123 at 144 DPI, red before its fifth; on a Windows 11 VM at 150%, with the main window 49 pixels from the screen's top, 0.1.4 opened her 2326 by 1654 with a caption, drawn at its bottom over the terminal, and a press inside the terminal went to her; T48's build opened her 168 by 185 without one, on the edge, the press went to the terminal, one beside her box to the program behind, and one on her to her; with the window mid-screen she showed whole on its edge; Wayland's popup is gpui's own | Pass |
|
||||
| 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
|
||||
| Ely's hop lifts more of her from behind the edge, never her body off it | T62 | `pet::rig` test: over every mood, 2.6 s of each and a press at each point of a hop, her highest lift, above 40 units, leaves the shirt and back hair below the edge; red with the shapes before; on macOS 24 snaps of her window waiting on the user, two hops a round, found the bottom row of her box opaque across its middle in every one, the highest 14 pixels from its top, where the build before, at the same height, showed that row clear across its width | Pass |
|
||||
|
||||
## 7. Sessions panel
|
||||
|
||||
@@ -126,6 +134,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp
|
||||
| On Omarchy, the Omarchy theme first in the grid | T27 | on X11 with Omarchy's theme folder, the Omarchy card leads the grid, chosen, over a preview in its colors | Pass |
|
||||
| Terminal: scrollback, Option as Alt (macOS), copy on select | T18 | T18 review | Pass |
|
||||
| Terminal: Ely, a switch | T46 | on macOS the switch closed her window and saved `"ely": false`, a start with it showed no Ely, and the switch brought her back | Pass |
|
||||
| Terminal: 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 |
|
||||
|
||||
## 11. Background blur
|
||||
@@ -160,16 +169,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 |
|
||||
| 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, T43, T47 | its run: `codesign --verify --strict` passes on the app and on its copy in the image; the `.icns` holds 16 to 1024 px; the image verifies and mounts with the app beside Applications; LaunchServices opens the app as `com.zacharyzhang.elyterminal`; for v0.1.1 the Release workflow (run 959) ran it on `macos-signing`: notarization Accepted, the ticket stapled, and on this Mac the downloaded image and its app pass `codesign --verify --strict` under the Developer ID and Gatekeeper's `accepted, source=Notarized Developer ID`; the owner opened it; for v0.1.2 the Release workflow (run 1001) ran it again: the downloaded image's stapled ticket validates and Gatekeeper accepts it as `Notarized Developer ID`, and its app, version 0.1.2, passes `codesign --verify --strict --deep` under the Developer ID with the hardened runtime and is accepted the same way; for v0.1.3 the Release workflow (run 1157) ran it again, and its image and app, version 0.1.3, check the same way, as does the image rebuilt for T43 (run 1181); for v0.1.4 the Release workflow (run 1216) ran it again, and its image and app, version 0.1.4, for macOS 11.0 or later, check the same way; without the key the script stops before building | Pass |
|
||||
| `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, T57 | 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; v0.1.6's disk image, from the Release workflow (run 1343) at `8a9941b`: 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.6, macOS 11.0, arm64 | Pass |
|
||||
|
||||
## 15. Release
|
||||
|
||||
| PRD line | Task | Evidence | Status |
|
||||
|---|---|---|---|
|
||||
| A Gitea release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41, T43, T47 | `v0.1.4` at `2d9180e`: the zip from its script on the owner's Windows VM, the tarball from its script on the owner's PC under Omarchy, the disk image from the Release workflow (run 1216), signed and notarized; published once CI run 1215 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, each binary holds Ely's log line, and the tag names `2d9180e`. `v0.1.3` rebuilt for T43, at `eb96109`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1181), signed and notarized, each in place of the first build of its name, once CI run 1180 on that commit passed on macOS, Ubuntu and Windows; the tag moved from `7569f00` to `eb96109`, and the release's target with it; the notes give the new SHA-256 and say the builds were replaced; each file downloaded back anonymously matches its SHA-256, each binary holds the moving marks' log line and none the old ring's element id, and the tag's source archive holds `src/sessions/orb.rs`. The first `v0.1.3` builds, at `7569f00`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1157), signed and notarized; published once CI run 1156 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag named `7569f00`. `v0.1.2` at `4364802`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1001), signed and notarized; published once CI run 1000 on that commit passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes, and the tag names `4364802`. `v0.1.1` at `43ea5b1`: the zip from its script on the owner's PC, the disk image from the Release workflow, signed and notarized; the Linux tarball, rebuilt for T27 from `aae7fba` by its script on the owner's PC under Omarchy, took the first one's place, and the notes say so; each downloaded back anonymously, once the repository was public, matches the SHA-256 in the release notes. `v0.1.0` at `0075667`, its three files built from that commit by their scripts and checked again: the disk image verifies and its app passes `codesign --verify --strict`; the zip's executable has the icon and no C runtime DLL; the tarball opens its window on Ubuntu 22.04; each file downloaded back matches the SHA-256 in the release notes | Pass |
|
||||
| The Windows executable carries the icon | T25, T31, T41, T47 | its resources: icon group 1 with the seven images of `elyterminal.ico`, 16 to 256 px, and gpui's manifest (PerMonitorV2); a GUI program, importing no C runtime DLL; not yet seen on a Windows screen; v0.1.2's, v0.1.3's and v0.1.4's executables the same | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41, T47 | on Ubuntu 22.04: `desktop-file-validate` reports nothing, every library resolves, glibc 2.35; under Xvfb `WM_CLASS` reads `com.zacharyzhang.elyterminal` twice, and under headless sway the window's `app_id` reads the same; v0.1.2's passes the same checks on Ubuntu 22.04, run in the owner's WSL, `WM_CLASS` too; T27's rebuild passes the same checks on Ubuntu 22.04, whose llvmpipe (LLVM 15) leaves the window black in a capture, the first tarball's too, while Debian 13's paints it; v0.1.3's holds the same three files, its desktop entry names `com.zacharyzhang.elyterminal`, and its binary asks for glibc 2.35 at most; v0.1.4's the same | Pass |
|
||||
| Each build comes from its script | T25, T27, T31, T41, T43, T47 | `scripts/mac-app.sh` on macOS, `scripts/windows-zip.ps1` on the owner's Windows PC, `scripts/linux-tar.sh` in the owner's WSL, and for T27 on the owner's PC under Omarchy; v0.1.2's and v0.1.3's three the same way, and v0.1.3's again for T43, the disk image's on `macos-signing`; v0.1.4's zip on the owner's Windows VM, its tarball on the owner's PC under Omarchy, its disk image on `macos-signing` | Pass |
|
||||
| A GitHub release, `vVERSION`, with the macOS disk image, a Windows zip and a Linux tarball | T25, T26, T27, T31, T41, T43, T47, T50, T54, T57 | `v0.1.6` at `8a9941b`: the zip and the tarball from their scripts on the owner's PC and its WSL, the disk image from the Release workflow (run 1343), signed and notarized; a first build at `9e185ea`, held back when the owner asked for the scrollbar, was replaced on the draft file by file and never published; published once CI run 1342 on `8a9941b` passed on macOS, Ubuntu and Windows; each file downloaded back anonymously matches the SHA-256 in the release notes; each binary holds the drag's, the update check's and the scrollbar's lines; the tag names `8a9941b`. `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, T47, T50, T54, T57 | 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; v0.1.6's executable the same, its icon images, icon group and manifest byte for byte 0.1.5's | Pass |
|
||||
| The Linux tarball carries the icon and a desktop entry; the window names itself `com.zacharyzhang.elyterminal` | T25, T27, T41, T47, T50, T54, T57 | 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; v0.1.6's holds the same three files, its desktop entry and icon byte for byte 0.1.5's, and its binary asks for glibc 2.35 at most | Pass |
|
||||
| Each build comes from its script | T25, T27, T31, T41, T43, T47, T50, T54, T57 | `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`; v0.1.6's three the same way, rebuilt from `8a9941b` with T58 in place of a first build from `9e185ea` that was never published | Pass |
|
||||
|
||||
## CI notes
|
||||
|
||||
|
||||
@@ -250,3 +250,79 @@ Each task gets at most three codex rounds. Each round: the commit, the verdict,
|
||||
|
||||
- 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.
|
||||
|
||||
## T57
|
||||
|
||||
- No review: the owner asked for 0.1.6 without one. v0.1.6 published at 8a9941b once CI run 1342 passed on macOS, Ubuntu and Windows; its disk image from Release run 1343, notarized, its zip and tarball from their scripts on the owner's PC and its WSL; a first build at 9e185ea, held back for the scrollbar, was replaced on the draft and never published; each file downloaded back anonymously matches its SHA-256 in the notes.
|
||||
|
||||
## T59
|
||||
|
||||
- Round 1, 82cfe91: FAIL. fish 4.9 moves a code point per Left, so a counted click put `X` inside `é`; fish's redraw dropped the wrap flag of a long line, so a second click on its next row moved nothing; a press let go in another cell still moved the cursor; a program that turned on mouse tracking while the button was down still got arrows. Fixed in 0c55d41: OSC 133 A's `click_events`, which fish sends on its own, makes the click go to the shell as an SGR press, as kitty and Ghostty send it; the release must land in the press's cell, and the program must track no mouse at the release.
|
||||
- Round 2, 0c55d41: FAIL. The four findings of round 1 replayed fixed, and zsh, bash, the prompt jump and the shell tests held; but a click on fish's `$` went to fish as a press, and fish moved its cursor to the start, where T59 asks for clicks past the prompt. Fixed in e74ea74: the press goes only past the prompt's last cell before the cursor, and never on a prompt cell.
|
||||
- Round 3, e74ea74 and 9fa68b2: a first run found that fish's empty left prompt beside a right prompt carrying its own marks panicked in `click::press`, then stopped without a verdict when Codex's content filter cut it off; 9fa68b2 fixed the panic. A second run, at 9fa68b2: FAIL. Every earlier finding replayed fixed and the panic gone, but a prompt that prints nothing, fish's empty left prompt with a plain or no right prompt, left no cells to find the input by, so clicks on the typed line did nothing. The owner chose a fix and a fourth round over leaving it documented. Fixed in 84364ce: OSC 133 B stamps the line typed after a prompt with a link of its own, numbered by the commands ended, which every prompt drawn again on that line shares, so the input starts at its first typed cell where no prompt cell of its line comes before it.
|
||||
- Round 4, 84364ce: FAIL. Round 3's finding replayed fixed, every earlier one held, and the rewrite passed 5,291 split reads, 15 pty replays, Cmd-press in prompt, input and output, bash 3.2, find, select all and copy; but the newest typed line was taken by its place, lowest on the screen, so after `printf '\e[H'` fish's prompt at the top over older lines took no click. Fixed in 28d7615: the newest line is the one of the highest number, and a press goes no lower than its last row or the cursor's, where fish moved its cursor for a click on older text below. Round 4 was the last the owner allowed.
|
||||
- Round 5, 28d7615, which the owner allowed: PASS. fish's prompt drawn at the top over older lines took its click, `abcXdef`, and a click on the older text below did nothing; every earlier finding replayed fixed; zsh and bash arrows, the prompt jump, Cmd-press in prompt, input and output, bash 3.2, find, select all and copy held; 5,291 split reads and 15 pty replays of the rewrite matched; check.sh passed with 256 tests. Windows' win32-input-mode and Linux's native clicks went unexercised.
|
||||
|
||||
## T66
|
||||
|
||||
- Round 1, 545fb46: FAIL. On Windows a path went bare, or in double quotes when it held a space, so PowerShell expanded `$HOME` and `$(1+1)`, broke on `it's`, and ran `a;Write-Output(42)` as two statements, and cmd kept `&` live; and `^` went unescaped, so zsh with `extendedglob` read a dropped `a^b` as a glob and passed four files. zsh, bash and fish read 317 paths back exactly, controls and bad UTF-8 stopped the drop, and selection, click to move, mouse reports and the scrollbar held after a drop. Fixed in 887378a: a path stays literal for the shell the session started, by its name: backslashes for POSIX shells, `^` and `~` among them; single quotes with their quotes doubled for PowerShell; double quotes for cmd, which refuses a `%`.
|
||||
- Round 2, 887378a: FAIL. PowerShell read 384 hard names back exactly and a real pwsh session logged its quoting; zsh with `extendedglob`, bash and fish read 376; but cmd, from its docs and parsers, expands `!NAME!` inside quotes under `/v:on`, and `"C:\"` reads to a program as a quote, joining the next path. Fixed in 5660562: a `!` stops a cmd drop, and the backslashes ending a path are doubled; node, run from a cmd batch line on the owner's PC, received six hard paths exactly, `C:\` among them.
|
||||
- Round 3, 5660562: PASS. A `!` anywhere in a cmd drop stops it with no byte sent, and doubled trailing backslashes keep `C:\` and the next path apart under MSVCRT and UCRT parsing; 1,062 cmd paths, UNC and runs of up to 32 backslashes among them, kept their contents, order and bounds; zsh, zsh with `extendedglob`, bash and fish read 376 paths back exactly and PowerShell 384; check.sh passed with 261 tests. Windows' own cmd and ConPTY were checked only through the owner's PC run.
|
||||
|
||||
## T60
|
||||
|
||||
- Round 1, cb32f00: FAIL. On macOS the order, bold, italic, ligatures, icons, old files, refusals, add, change and cancel all held; but removing the row whose input held the focus left the removed family in it, and Enter saved it over the next family; and on Linux gpui's cosmic-text path carries the fallback a character took into the next while it covers it, so `क中` drew 中 in the second fallback. Fixed in 5f8ea89: a removal drops that row's input and the focus; the Linux path is gpui's, kept for its shaping clusters, and the owner chose to record it.
|
||||
- Round 2, 5f8ea89: FAIL. Round 1's removal case and removals first, middle, last, in sequence, then adds and cancels held, and the Linux record was true; but the pickers were keyed by place, so after a removal moved the inputs, the picker state of the second row, listening to the input now first, wrote PingFang SC into it at a blur, and Enter saved `[PingFang SC, PingFang SC]`. Fixed in 5d131e5: each picker is keyed by its input's entity.
|
||||
- Round 3, 5d131e5: PASS. Each picker keyed by its input kept round 2's case at `[Songti SC, PingFang SC]` through a removal, a focus cycle and Enter; drafts blurred after removals first, middle, last and among duplicates came back to the right family; a sequence of removals and adds through five focus cycles, and a cancel, re-add and removal through three, saved what the screen showed; a refused pick restored the saved list; check.sh passed with 262 tests. Windows and Linux windows went unexercised.
|
||||
|
||||
## T61
|
||||
|
||||
- No review: the owner closed it once it did not reproduce. On the owner's Windows 11 PC (26H2, 150%), with WeType and powershell.exe, 23 compositions put the candidate box at the caret: three on one line, then one on each of three lines below, fifteen committed and begun again at once, the caret stepping 4 cells each time, and eight under a status line redrawn every 100 ms with the cursor hidden; each caret the IME asked for matched the cursor. Microsoft Pinyin went untested.
|
||||
|
||||
## T62
|
||||
|
||||
- Round 1, 760f054: PASS. 24 poses rendered by gpui beside the build before, rest, breath, tilts, hops, a press in a hop, typing, a shake, the doze, waking and rising, kept her outline above the shoulders and joined the shirt and hair to the edge in every hop; 3,244,407 poses sampled, head bob, tilt and the rotated hair's corners counted, left the shirt 4.4 and the hair 1.3 units below the edge at her highest, 52.7 units; on macOS 48 snaps at the top and bottom edges and 52 through presses dispatched to her window kept her bottom row opaque; check.sh passed with 263 tests. Windows, X11 and Wayland went by their source.
|
||||
|
||||
## T63
|
||||
|
||||
- Round 1, 3085a94: FAIL. Presses, Enter, Escape, a blur by a press, other rows, closes, retitles, a full reset, long and Unicode names held, and Ely's `ListItem::editor` left other rows as they were; but leaving the app mid-rename blurred the field while the window's focus still pointed at it, the field went, and back in the app the terminal heard no key until pressed. Fixed in ca3809b: a field that closes while it still holds the focus gives it to the shown terminal; a press that took it elsewhere keeps it there.
|
||||
- Round 2, ca3809b: PASS. Back in the app after leaving it mid-rename, `a` reached the program with no press first; 18 scenarios held: Enter, Escape, a blur by a press, presses inside the field, switching and opening sessions, renaming two rows, closing the other session, the renamed one or by Cmd-W, Settings' fields and the Git summary keeping their focus, a triple press, a name of spaces and a later program title; check.sh passed with 263 tests. Windows and Linux windows went unexercised.
|
||||
|
||||
## T65
|
||||
|
||||
- Round 1, 1384ef5: FAIL. Moves and sizes, clear and opaque windows, changes at rest, blur turned off mid-move, per-window marks, destruction, the Windows 11 boundary and the structure layouts held in a replay against recorded Win32 calls; but blur turned on mid-move, which gpui sets as acrylic at once, kept acrylic for the rest of the move, since Windows sends `WM_ENTERSIZEMOVE` once. Fixed in 0a30760: a moving mark from `WM_ENTERSIZEMOVE` to `WM_EXITSIZEMOVE`, and a window blurred while it holds the mark takes the clear accent again.
|
||||
- Round 2, 0a30760: PASS. The build before failed its two mid-move cases and the fix passed them; 46,875 sequences of six actions from opaque, clear and blurred starts, settings, moves, ends, destruction and a second window among them, kept the accent, both marks and the frame refreshes as a model says; check.sh and the Windows type check passed. Windows 10 itself went untested.
|
||||
Reference in new issue
Block a user