diff --git a/AGENTS.md b/AGENTS.md index 7a72fc8..0037cc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `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 and waits on herdr's pipe. +- On Windows, `sysinfo` reads herdr's client's arguments and variables; `windows-sys`, the version the Windows build already holds, lists processes, waits on herdr's pipe, and reads the keyboard for win32-input-mode. - `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 @@ -18,6 +18,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `cargo run --example shot -- [--keys "SPECS"] [args…]` runs a program in the main window, plays SPECS on it, and writes a PNG of the window (macOS; the app it runs under needs Screen Recording, or the picture may hold the window's ground alone, and `shot` warns), naming the AppKit views under its content, an effect view with its blur radius, `none` when it draws no blur, and a capture of one window shows its alpha, not the blur behind it; it logs the menu bar, each item with its key. It shows its window without activating the app, so keystrokes or clicks meant for other apps stay there; gpui draws a window only while macOS shows it, so one on another Space or under a full-screen app keeps its last frame. Run it with `HOME` pointing at a temp folder to try settings without touching your own. SPECS, space-separated: - keys as real AppKit events: `a`, `ctrl-c`, `lopt-f` and `ropt-f` (left and right Option), `cmd-v`, `repeat-up` (AppKit's repeat flag); - input method calls: `mark:é` composes, `commit:é` types; + - `wait` pauses a second, so a program can start before the keys after it; - `activate` and `deactivate` the app, which takes real keystrokes while active: keep the two specs adjacent. macOS may delay or refuse the activation; each `shot: typed` line says whether the app was active; - `to:settings` and `to:main` aim the specs after them, and the picture, at that window, which becomes the app's key window, since AppKit sends keys there; the app stays inactive, and gpui sends an inactive window no focus or blur events, so press Enter to commit a field. AppKit gives an inactive app's key that a menu item holds to the menu, and gpui sends the item's action to the main window, which an inactive app lacks: only an app-wide action runs, such as Quit, Settings or the font's size; - mouse gestures handed to gpui's view, at points from the content's top-left, modifiers as for keys: `click@x,y`, `click2@`, `click3@`, `rclick@`, `mclick@`, `drag@x,y@x,y`, `move@x,y`, the steps `ldown@`, `lup@`, `ldrag@`, `rdown@`, `rup@`, `mdown@`, `mup@`, and `scrollup@x,y@lines`, `scrolldown@x,y@lines`. @@ -34,7 +35,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - `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/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 and the mouse modes before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side and repeat flag of AppKit's current event, keeps held keys repeating, and hands Command key-ups to the focused view. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text. +- `src/term`: the terminal engine. `pty` starts a program through alacritty's event loop; `tap` wraps the pty so its reader scans OSC 0, 2, 7, 9, 99, 133, 777 and 7501, the title stack's CSI 22 t and 23 t, full resets, the mouse modes and, on Windows, win32-input-mode (`tap/modes`) before the parser; `titles` keeps the title a session shows, read in the output's order; `marks` reads OSC 133's prompt marks; `status` reads OSC 7501 and keeps a terminal's records; `notify` reads OSC 9, 777 and 99, the notifications and OSC 9;4's progress, and gathers OSC 99's chunks; `reports` is the view's side of what programs report: it keeps OSC 7501's records, maps OSC 9;4, answers OSC 99's query and alive, and posts the system notifications its session posts; `prompts` turns each mark into an OSC 8 link the parser keeps in a prompt's cells, and finds the prompt to jump to; `herdr` follows herdr's focused pane and its agents' states through herdr's socket while herdr's client runs in the session: `herdr/unix` finds the client leading the pty's foreground, `herdr/windows` under the session's shell, and asks its named pipe. `unix` starts the program itself, so a failed `chdir` fails the start; Windows uses alacritty's ConPTY. `shell` writes the integration scripts at start and builds each shell's launch. `frame` reads the grid into styled rows, `boxes` turns box drawing, block elements and powerline arrows into rects and polygons (`boxes/outline` strokes curves and diagonals into polygons), `ink` resolves colors, `paint` draws rows, decorations and the cursor, `view` is the entity: pty events, metrics, blink, resize. `zoom` holds the points Bigger and Smaller add to the Size setting, unsaved. `keys` turns key events into bytes, the kitty protocol and, in `keys/legacy`, xterm's encoding, with no gpui types; `input` is the view's key listeners, the bindings that keep Tab and F6 from Ely's focus moves, and its IME handler; `mac` reads the Option side, the repeat flag and the key code of AppKit's current event, and the key's own character from the layout, as gpui reads key names; it keeps held keys repeating, and hands Command key-ups to the focused view. `win32` sends a key as Windows' own record, win32-input-mode, and finds a key's own character in Windows' layout. `mouse` encodes mouse reports, with no gpui types; `links` finds the link at a cell; `pointer` is the view's mouse listeners: selection, reports, wheel, links, copy and paste. `find` is the find box: Ely's `FindWidget`, and Ely's `find_all` over the grid's shown text. - `assets/shell-integration`: ELYTerminal's own scripts (not Ghostty's, which are GPL): zsh through `ZDOTDIR`, bash through `--rcfile`, fish through `XDG_DATA_DIRS`, PowerShell through `-NoExit -Command`. Each prompt sends OSC 7 and, except in fish, whose `fish_title` stays, a title; each marks prompts and commands with OSC 133 where the shell does not. - `assets/fonts`: JetBrains Mono 2.304's bold and italic faces (OFL; Ely bundles regular), and Nerd Fonts 3.5.1's JetBrains Mono NF Mono Regular, which draws icons a font lacks. - `assets/themes`: Ghostty theme files copied verbatim from iTerm2-Color-Schemes (MIT, its `LICENSE` beside them). @@ -62,7 +63,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: keys follow Ghostty's encoder (MIT). Keys the program reads go out from the key listener; plain text falls through to the input handler, so IME and dead keys compose. Ctrl+[, Ctrl+I and Ctrl+M go as fixterms CSI u, as in Ghostty. Keypad keys always type their characters: Ghostty keeps mode 1035 on, and gpui names keypad keys as main keys on every platform. Ely binds Tab, Shift-Tab, F6 and Shift-F6 to focus moves, and bindings run before listeners; `NoAction` bindings under the `Terminal` context hide them, so these keys reach the listeners with press, repeat and release. - 2026-10-14: each kitty flag reshapes only what it names, as kitty's encoder does: functional keys keep legacy bytes until disambiguate, events or all is set, and text keys type until all is set. Cmd is kitty's Super, bit 8; a Cmd combo no binding takes reaches the program. - 2026-10-14: an input method's commit goes to the program as UTF-8 under every kitty flag, as kitty (`keys.c`, `GLFW_IME_COMMIT_TEXT`) and Ghostty (test `kitty: composed text with report all`) send it. -- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it), and shifted punctuation reports the character it types as its key, since gpui clears Shift for it. +- 2026-10-14: what gpui hides from the kitty protocol: a key alone on a modifier sends nothing (gpui reports no key event for it). gpui names a key Shift turns into a symbol by that symbol and clears Shift; on macOS and Windows the layout gives the key back, as below, and on Linux the symbol goes as its key, without Shift. - 2026-10-14: on macOS, which Option key is down and whether a key repeats come from AppKit's current event: gpui rebuilds keys AppKit routes through its commands as fresh presses. As in Ghostty, `ApplePressAndHoldEnabled` is registered off, so held keys repeat instead of opening the accent menu, and a local event monitor hands Command key-ups to the focused view, since AppKit never routes them to the window. - 2026-10-14: mouse reports follow Ghostty's rules: X10 tracking sends presses alone without modifiers, SGR names the released button, motion inside one cell sends nothing, Shift takes the mouse back for selecting. The tap follows modes 9, 1000, 1002, 1003, 1005 and 1006 as xterm holds them: one tracking and one format at a time, the last set wins, any reset ends it, and RIS clears both. alacritty drops 9 and keeps each mode apart, so its mouse bits go unread. The tap's scanner reads controls inside a sequence as vte does: C0 runs, DEL and high bytes are skipped, CAN and SUB cancel, ESC restarts, subparameters are skipped. Motion reports the first held button: left, right, middle. The pointer over the grid is the arrow, as the owner asked, not Ghostty's I-beam. - 2026-10-14: a wheel notch scrolls 3 lines, as Ghostty's discrete wheel; a trackpad scrolls its pixels. The wheel reports to a program that tracks the mouse, sends arrows on the alternate screen under mode 1007, and scrolls history otherwise. @@ -103,3 +104,4 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-17: inside herdr, which passes no OSC 7501 on, herdr's agents are the session's records. The snapshot the follower asks for lists them; one in a state herdr knows, idle, working, blocked or done, is the record `herdr:PANE`, whose colon no OSC 7501 id holds, so a program's record of a name like it, `herdr/build` among them, stays its own; unknown and any state herdr adds later pass by. herdr names no kind and no progress; the app is the agent, the title its name, else the name herdr shows for it, and the message its terminal title less the spinner herdr strips, else the title its metadata sets; herdr's done is idle and not yet seen. A snapshot that lists other agents replaces them whole, so a full reset or a clear that dropped them sees them back at the next; they go when herdr leaves the foreground, and a snapshot that lands after the shell reported a folder is dropped, its agents with it. A change of their states is logged; a herdr whose snapshot lacks `agents` lists none. - 2026-10-17: a session's row shows its status, which the owner asked for: one mark before its title, the most pressing state first: 3 by 3 dots in the state's color, which the owner asked for, moving, after the S1 orb of aicss.dev; the dots and their motion are ELYTerminal's own. Working ripples out from the centre to the edges, then the corners, once every 1.2 s; with progress the outer ring fills clockwise from the top, as Ely's `ProgressRing` did, and the next dot pulses, the centre last. Blocked, in the theme's warning color, stays lit and dips twice every 1.6 s, whatever its kind, which hover names. Error, a program's or a failed last command's, draws an X in the danger color; done a check in the success color; idle a dimmer ring in the muted color. A settled shape lights dot by dot, then holds still: one that kept moving would draw the window for as long as it stands. The dots outside a shape stay faint. Every mark steps on one clock, every 80 ms, so the marks move together, and the window draws again at each step only while a mark it shows moves: a working program costs 12.5 frames a second, not one at every refresh of the display, which kept the loader still before. Every mark takes a 24-pixel box, so the title stays put as the state changes; the dots fill 16 of it, an icon's room. Among the records in that state, the one reported last speaks. Hover over the mark lists each record, ten at most: its state, its app, its own or its nearest ancestor's as the spec asks, its title and its message, each on one line and cut at 120 characters, with C1 controls, direction controls and invisible formatting taken out, as the spec asks; joiners stay, since scripts and emoji need them. A failed last command's status follows. Hover reads the records as they stand at each frame, so an open one follows a change; it stays while the pointer is over it and scrolls within the window's height, so ten long records all show. A key the user types, a paste too, sees the terminal's done and error records, which then go, as the spec suggests; herdr's stay, since herdr marks them seen. The view notes the state its row shows after each change it hears, not once an output, and tells the workspace of each, and a hidden session whose mark turns to blocked sets its title strong until it is shown, though it went on within the same output, so one output marks as two would. A change of herdr's records draws the view again, so an open hover follows them though the state holds. The view logs each change of that state, and keeps its last command's status. - 2026-10-17: a session posts system notifications through gpui's, which the owner asked for. At start `set_app_identity` names the app: on Windows that sets the process's AppUserModelID, which the taskbar groups its windows by too, so a pin made before may show apart, and the first notification writes the app's name under `HKCU\Software\Classes\AppUserModelId`, as gpui needs for an app without a package; on macOS gpui posts only from the app bundle, asks for leave at the first, and logs once, under the `gpui_macos` target, that it posts nothing outside one; on Linux it posts over D-Bus through notify-rust. Each terminal view posts its own, in the order its output asks, so OSC 99's answers keep their place before a DA1 sent after them: when its row's mark turns to waiting, done or error, at each turn, as the row's waiting mark is set, the hover's line for the record its body; when a command C started ends, `Command finished`, or `Command exited with N`, so a prompt drawn again, as macOS's bash 3.2 draws one with its D at a resize, posts nothing, and bash 3.2, which sends no C, posts no command's end, as Ghostty's integration, which skips it, posts none; and when a program asks through OSC 9, 777 or 99, its title and body the body, each line shown as text. It posts only while its window is not active or it is not the session shown, which the workspace tells it, one every 3 seconds at most, a later one logged and dropped; the title is the program's title, else the app's name, as the row names the session, which the tap reads in the output's order, so a title set before a notification in the same output names it. The session's own share a tag, so a new one takes the old one's place where the system allows, as an OSC 99 id does; OSC 9, 777 and OSC 99 without an id each take their own; a session keeps its last 64. A press goes to the window whose session posted it: it shows the window and the session, unless OSC 99 said `a=-focus`, and tells a program that asked `a=report`, `OSC 99 ; i=ID ;`; a press whose record went, a twin Linux stacked and pressed before or one past the 64 kept, still shows its session and tells no program. macOS brings the app forward at any press, `a=-focus` or not, and on Wayland gpui asks for the window with a serial the compositor likely refuses, so KWin and Mutter mark it as asking for attention instead. gpui hears of no close on any system, so `c=1` hears `p=close;untracked` once posted, and alive lists the ids posted and not yet pressed, closed or expired. On macOS and Windows a close takes the notification back, and `w` over 0 takes it back once it runs out, its session gone or not, unless a later post holds its tag; gpui on Linux closes and replaces none, so there a close and `w` pass by, logged, a closed one stays alive, and an update posts beside the old one. `o` passes by: each notification posts only while unseen, which is kitty's invisible, so the query answers `a=focus,report:o=invisible:p=title,body,close,?,alive:w=1`, on Linux `a=focus,report:o=invisible:p=title,body,?,alive`, never with the `c` that close events would need. Windows' toasts are silent, as macOS's are; on Linux the notification daemon decides, and reads the body as markup, so its `&`, `<` and `>` go as entities. +- 2026-10-18: herdr's shortcuts, which a user found dead, as kitty and Ghostty send them. herdr 0.9.5 asks for kitty's disambiguate, events and alternates on macOS and Linux, and takes `cmd+shift+ctrl+[` as `[` with Shift, Ctrl and Super, `CSI 91;14u`, the symbol as its alternate, `CSI 91:123;14u`, as Ghostty sends it; gpui named the key `{` and cleared Shift, so ELYTerminal sent `CSI 123;13u`, which herdr matches only to `cmd+ctrl+{`. Under kitty's flags a key Shift made a symbol of now goes as its own character with Shift, the symbol its alternate; legacy keys keep fixterms' symbol without Shift, as Ghostty's do. On macOS the key's own character comes from `UCKeyTranslate` on the event's key code, in the layout gpui reads: Command's while Command is down or while the plain one types no ASCII; AppKit's `charactersByApplyingModifiers:` ignores the layout for events built in-process. On Windows herdr asks for no kitty flags and reads the console's key records, and ConPTY asks every terminal for win32-input-mode, `CSI ? 9001 h`; so, as Windows Terminal does, each key that types no text goes as `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`: the virtual key from gpui's name and the layout (`VkKeyScanW`), its scan code, what Windows types for it (`ToUnicode`, a dead key left as it is), down or up, and the modifier keys, locks and enhanced block from `GetKeyState`. kitty's flags go first, as in Windows Terminal; text stays text, so a release goes as a record only where its press did; a full reset ends the mode, as Windows Terminal's does. 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+{`. diff --git a/Cargo.toml b/Cargo.toml index 4a80089..2ed4bb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ image = { version = "0.25", default-features = false, features = ["png"] } [target.'cfg(windows)'.dependencies] sysinfo = { version = "0.39", default-features = false, features = ["system"] } -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Pipes", "Win32_UI_Input_KeyboardAndMouse"] } [target.'cfg(windows)'.dev-dependencies] windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_System_IO"] } diff --git a/PRD.md b/PRD.md index 08ed7ef..0fea211 100644 --- a/PRD.md +++ b/PRD.md @@ -54,7 +54,7 @@ Ghostty is the bar for what the terminal does. ELYTerminal owns its engine on `a - Box drawing (U+2500–U+257F), block elements (U+2580–U+259F) and powerline shapes (U+E0B0–U+E0B7) painted as geometry, so lines and segments join across cells. - Fonts: JetBrains Mono by default, any installed font by choice; Nerd Font icons the font lacks come from a bundled Nerd Font. - Cursor: block, bar, underline, hollow block; blinking per setting and per DECSCUSR; hollow when the window is not focused. -- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default. +- Keys: xterm encoding with modifiers, application cursor mode, Option as Alt on macOS, the kitty keyboard protocol as the program sets its flags; a key Shift makes a symbol of goes as its own key with Shift, the symbol its alternate, as Ghostty sends it, on macOS and Windows. On Windows keys go as win32-input-mode records while ConPTY asks, as Windows Terminal sends them. Keypad keys type their characters, as Ghostty's do with mode 1035 on by default. - IME: marked text drawn at the cursor; committed text sent to the program. - Mouse: select by character, word (double press) and line (triple press); Shift extends; mouse reporting (X10, normal, button, any; SGR and UTF-8); wheel scrolls back, or reports, or sends arrows on the alternate screen. - Clipboard: copy, paste (bracketed when asked), copy on select per setting, OSC 52 writes. diff --git a/README.md b/README.md index 9c7c88e..274646a 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The app writes its log to stderr. `RUST_LOG=elyterminal=debug` writes more; `tra ## What it does -- The terminal runs on `alacritty_terminal`: 256 colors and truecolor, bold, dim, italic, five underline styles, wide characters and combining marks. Box drawing, block elements and powerline shapes are drawn as geometry, so lines join across cells. Keys follow xterm and the kitty keyboard protocol; input methods compose at the cursor; programs can track the mouse; pastes are bracketed when a program asks; OSC 8 and plain links open with the link press below; find searches the scrollback. +- 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; find searches the scrollback. - Shell integration for zsh, bash, fish and PowerShell: each prompt reports its folder and title, and OSC 133 marks each prompt, each command and its exit status. - 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`. diff --git a/TASKS.md b/TASKS.md index 57dd5f9..91aac8e 100644 --- a/TASKS.md +++ b/TASKS.md @@ -113,7 +113,7 @@ Progress truth. Top to bottom, one at a time. A task is ticked only after its co ## 24. Keys, links and Ely -- [ ] T44 herdr's shortcuts work, which a user found dead: `cmd+shift+ctrl+[` and `]` on macOS, and the same keys as herdr reads them on Linux and Windows; a shifted key with Ctrl, Alt or Cmd reaches the program as herdr reads it. +- [ ] T44 herdr's shortcuts work, which a user found dead: `cmd+shift+ctrl+[` and `]` on macOS, and the same keys as herdr reads them on Linux and Windows: under kitty's flags a key Shift makes a symbol of goes as its own key with Shift, the symbol its alternate, on macOS and Windows; on Windows keys go as win32-input-mode records, which ConPTY asks for and herdr reads; on Linux gpui keeps no key code, which the docs say. - [ ] T45 Cmd-press on macOS, Ctrl-press on Windows and Linux opens the link under the pointer, which the owner asked for, wherever the terminal shows one. - [ ] T46 Ely peeks over the main window's top edge from behind it, which the owner asked for after the 0.1.3 film, with several faces and moves; a switch in Settings, Terminal, turns her off. diff --git a/examples/shot/main.rs b/examples/shot/main.rs index 2dca284..0cd860f 100644 --- a/examples/shot/main.rs +++ b/examples/shot/main.rs @@ -42,6 +42,7 @@ fn main() { cx.background_executor().timer(Duration::from_secs(1)).await; for spec in &keys { match spec.as_str() { + "wait" => cx.background_executor().timer(Duration::from_secs(1)).await, "to:main" => { aim = main; capture::front(number(aim, cx)).expect("the window on screen"); diff --git a/examples/shot/typing.rs b/examples/shot/typing.rs index da66e1a..aab582e 100644 --- a/examples/shot/typing.rs +++ b/examples/shot/typing.rs @@ -12,7 +12,7 @@ use objc::{ }; /// US layout key codes and what each key types. -const KEYS: [(&str, u16, &str); 32] = [ +const KEYS: [(&str, u16, &str); 34] = [ (",", 43, ","), ("a", 0, "a"), ("b", 11, "b"), @@ -32,8 +32,10 @@ const KEYS: [(&str, u16, &str); 32] = [ ("=", 24, "="), ("minus", 27, "-"), ("m", 46, "m"), + ("n", 45, "n"), ("q", 12, "q"), ("[", 33, "["), + ("]", 30, "]"), ("space", 49, " "), ("tab", 48, "\t"), ("enter", 36, "\r"), diff --git a/src/term/input.rs b/src/term/input.rs index dc21f8e..c00a388 100644 --- a/src/term/input.rs +++ b/src/term/input.rs @@ -93,7 +93,7 @@ impl TerminalView { return; } self.held.insert(event.keystroke.key.clone()); - let facts = self.facts(); + let facts = self.facts(&event.keystroke); let action = if event.is_held || facts.repeat { Action::Repeat } else { @@ -109,7 +109,7 @@ impl TerminalView { if !self.held.remove(&event.keystroke.key) { return; } - let facts = self.facts(); + let facts = self.facts(&event.keystroke); if self.send(&event.keystroke, Action::Release, facts, cx) { cx.stop_propagation(); } @@ -138,6 +138,7 @@ impl TerminalView { alt, platform: modifiers.platform, action, + unshifted: facts.unshifted, }; let mode = *self.pty.term.lock().mode(); let kitty = Kitty { @@ -147,6 +148,22 @@ impl TerminalView { all: mode.contains(TermMode::REPORT_ALL_KEYS_AS_ESC), text: mode.contains(TermMode::REPORT_ASSOCIATED_TEXT), }; + // As in Windows Terminal: kitty's flags first, then Windows' records; text stays text. + #[cfg(windows)] + if self.win32 && kitty == Kitty::default() && !keys::types(&key) { + match super::win32::record(keystroke, action) { + Some(bytes) => { + log::trace!("terminal {}: {key:?} sends {bytes:?}", self.pty.pid); + self.input(bytes, cx); + return true; + } + None => log::debug!( + "terminal {}: no virtual key for {:?}, so it goes as bytes", + self.pty.pid, + keystroke.key + ), + } + } let Some(bytes) = keys::encode(&key, mode.contains(TermMode::APP_CURSOR), kitty) else { return false; }; @@ -157,19 +174,35 @@ impl TerminalView { /// AppKit's facts of the key; logged and none when unreadable. #[cfg(target_os = "macos")] - fn facts(&self) -> Facts { - super::mac::key_facts().unwrap_or_else(|error| { + fn facts(&self, keystroke: &Keystroke) -> Facts { + super::mac::key_facts(&keystroke.key, keystroke.modifiers.shift).unwrap_or_else(|error| { log::error!("terminal {}: {error:#}", self.pty.pid); Facts { left_option: false, right_option: false, repeat: false, + unshifted: None, } }) } - #[cfg(not(target_os = "macos"))] - fn facts(&self) -> Facts { + /// Windows' layout names the key gpui named by what Shift made of it. + #[cfg(windows)] + fn facts(&self, keystroke: &Keystroke) -> Facts { + let unshifted = if keystroke.modifiers.shift { + None + } else { + super::win32::unshifted(&keystroke.key) + }; + Facts { + repeat: false, + unshifted, + } + } + + /// gpui keeps no key code on Linux, so a shifted symbol stays as gpui names it. + #[cfg(not(any(target_os = "macos", windows)))] + fn facts(&self, _: &Keystroke) -> Facts { Facts::default() } @@ -199,6 +232,7 @@ type Facts = super::mac::KeyFacts; #[derive(Clone, Copy, Debug, Default)] struct Facts { repeat: bool, + unshifted: Option, } impl EntityInputHandler for TerminalView { diff --git a/src/term/keys.rs b/src/term/keys.rs index 2e23789..505b4a8 100644 --- a/src/term/keys.rs +++ b/src/term/keys.rs @@ -24,6 +24,8 @@ pub struct Key<'a> { /// Cmd, the Windows key or Super: kitty's super. pub platform: bool, pub action: Action, + /// The key's own character, where gpui named the one Shift made and dropped Shift. + pub unshifted: Option, } /// The kitty keyboard flags a program set. @@ -47,7 +49,21 @@ pub fn encode(key: &Key, app_cursor: bool, kitty: Kitty) -> Option> { } match functional(key.name) { Some((number, end)) => named(key, number, end, app_cursor, kitty), - None => typed(key, printable(key.name)?, kitty), + None => match key.unshifted { + // kitty names the key itself, with Shift, and what Shift made of it as its alternate. + Some(own) => { + let made = printable(key.name)?; + let mut name = [0; 4]; + let key = Key { + name: own.encode_utf8(&mut name), + shift: true, + unshifted: None, + ..*key + }; + typed(&key, own, Some(made), kitty) + } + None => typed(key, printable(key.name)?, None, kitty), + }, } } @@ -79,7 +95,7 @@ fn printable(name: &str) -> Option { } /// Whether the key types its text: some, none of it control, and no modifier that stops text. -fn types(key: &Key) -> bool { +pub(super) fn types(key: &Key) -> bool { let control = key.text.chars().any(char::is_control); !key.text.is_empty() && !control && !key.ctrl && !key.alt && !key.platform } @@ -165,8 +181,8 @@ fn named(key: &Key, number: u32, end: char, app_cursor: bool, kitty: Kitty) -> O Some(sequence.into_bytes()) } -/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say. -fn typed(key: &Key, ch: char, kitty: Kitty) -> Option> { +/// A printable key: text, then legacy bytes, then CSI u once a flag has more to say; `made` is what Shift made of it. +fn typed(key: &Key, ch: char, made: Option, kitty: Kitty) -> Option> { let typing = types(key); if typing && !kitty.all && key.action != Action::Release { return None; @@ -176,6 +192,7 @@ fn typed(key: &Key, ch: char, kitty: Kitty) -> Option> { .chars() .next() .filter(|&typed| typing && typed != ch && !typed.is_control()) + .or(made.filter(|&made| made != ch)) .or_else(|| (key.shift && ch.is_ascii_lowercase()).then(|| ch.to_ascii_uppercase())) .filter(|_| key.shift && kitty.alternates); let event = event(key, kitty); diff --git a/src/term/keys/tests.rs b/src/term/keys/tests.rs index ed15763..665e204 100644 --- a/src/term/keys/tests.rs +++ b/src/term/keys/tests.rs @@ -12,6 +12,7 @@ fn key(spec: &'static str, text: &'static str) -> Key<'static> { alt: parts.contains(&"alt"), platform: parts.contains(&"cmd"), action: Action::Press, + unshifted: None, } } @@ -296,6 +297,61 @@ fn flags_that_only_add_fields_keep_legacy_keys() { ); } +#[test] +fn kitty_reports_the_key_shift_turned_into_a_symbol() { + // gpui names Shift-[ by its `{` and drops Shift; the platform gives back `[`. + let made = |spec, text, action| Key { + unshifted: Some('['), + action, + ..key(spec, text) + }; + let herdr = Kitty { + events: true, + alternates: true, + ..DISAMBIGUATE + }; + assert_eq!( + kitty(made("cmd+ctrl+{", "", Action::Press), herdr).as_deref(), + Some("\x1b[91:123;14u") + ); + assert_eq!( + kitty(made("cmd+ctrl+{", "", Action::Release), herdr).as_deref(), + Some("\x1b[91:123;14:3u") + ); + assert_eq!( + kitty(made("ctrl+{", "", Action::Press), DISAMBIGUATE).as_deref(), + Some("\x1b[91;6u") + ); + assert_eq!( + kitty(made("{", "{", Action::Press), herdr), + None, + "it types" + ); + let all = Kitty { + all: true, + alternates: true, + ..DISAMBIGUATE + }; + assert_eq!( + kitty(made("{", "{", Action::Press), all).as_deref(), + Some("\x1b[91:123;2u") + ); + assert_eq!( + legacy(made("ctrl+{", "", Action::Press), false).as_deref(), + Some("\x1b[123;5u"), + "legacy keeps fixterms' shifted character" + ); + let same = Key { + unshifted: Some('7'), + ..key("ctrl+7", "") + }; + assert_eq!( + kitty(same, herdr).as_deref(), + Some("\x1b[55;6u"), + "no alternate where Shift made the same character" + ); +} + #[test] fn super_is_kittys_eighth_bit() { let events = Kitty { diff --git a/src/term/mac.rs b/src/term/mac.rs index 6871209..407b766 100644 --- a/src/term/mac.rs +++ b/src/term/mac.rs @@ -1,3 +1,6 @@ +use std::ffi::c_void; + +use anyhow::Context as _; use block::ConcreteBlock; use objc::{ class, msg_send, @@ -5,13 +8,43 @@ use objc::{ sel, sel_impl, }; +#[link(name = "Carbon", kind = "framework")] +unsafe extern "C" { + static kTISPropertyUnicodeKeyLayoutData: *const c_void; + fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut c_void; + fn TISGetInputSourceProperty(source: *mut c_void, key: *const c_void) -> *const c_void; + fn LMGetKbdType() -> u16; + fn UCKeyTranslate( + layout: *const c_void, + code: u16, + action: u16, + modifiers: u32, + keyboard: u32, + options: u32, + dead: *mut u32, + most: usize, + length: *mut usize, + text: *mut u16, + ) -> u32; +} + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFDataGetBytePtr(data: *const c_void) -> *const u8; + fn CFRelease(object: *const c_void); +} + /// AppKit's event types for a key going down and coming up. const KEY_DOWN: u64 = 10; const KEY_UP: u64 = 11; -/// The Command key's bit in an event's modifier flags. +/// The Shift and Command keys' bits in an event's modifier flags. +const SHIFT: u64 = 1 << 17; const COMMAND: u64 = 1 << 20; +/// Command in a layout's modifier state, as `UCKeyTranslate` takes it. +const LAYOUT_COMMAND: u32 = 1; + /// The device bits of the left and right Option keys in an event's modifier flags. const LEFT_OPTION: u64 = 0x20; const RIGHT_OPTION: u64 = 0x40; @@ -23,11 +56,13 @@ pub struct KeyFacts { pub right_option: bool, /// Held down; gpui loses this for keys AppKit routes through commands. pub repeat: bool, + /// The key's own character, where gpui named the one Shift made and dropped Shift. + pub unshifted: Option, } -/// The facts of the key event AppKit is handling now. -pub fn key_facts() -> anyhow::Result { - unsafe { +/// The facts of the key event AppKit is handling now, which gpui named `name`, with `shift` or not. +pub fn key_facts(name: &str, shift: bool) -> anyhow::Result { + let (flags, repeat, code) = unsafe { let app: *mut Object = msg_send![class!(NSApplication), sharedApplication]; let event: *mut Object = msg_send![app, currentEvent]; anyhow::ensure!(!event.is_null(), "macOS: no key event to read"); @@ -38,12 +73,75 @@ pub fn key_facts() -> anyhow::Result { ); let flags: u64 = msg_send![event, modifierFlags]; let repeat: BOOL = msg_send![event, isARepeat]; - Ok(KeyFacts { - left_option: flags & LEFT_OPTION != 0, - right_option: flags & RIGHT_OPTION != 0, - repeat: repeat != NO, - }) + let code: u16 = msg_send![event, keyCode]; + (flags, repeat != NO, code) + }; + let one = name.chars().count() == 1; + let unshifted = if flags & SHIFT != 0 && !shift && one { + let own = own(code, flags)?; + log::debug!("macOS: Shift made {name:?} of key {code}, whose own is {own:?}"); + own + } else { + None + }; + Ok(KeyFacts { + left_option: flags & LEFT_OPTION != 0, + right_option: flags & RIGHT_OPTION != 0, + repeat, + unshifted, + }) +} + +/// Key `code`'s character without Shift, read as gpui reads key names: Command's layout while Command is down or the plain one types no ASCII. +fn own(code: u16, flags: u64) -> anyhow::Result> { + let command = + flags & COMMAND != 0 || (!typed(0, 0)?.is_ascii() && typed(0, LAYOUT_COMMAND)?.is_ascii()); + let own = typed(code, if command { LAYOUT_COMMAND } else { 0 })?; + let mut chars = own.chars(); + Ok(match (chars.next(), chars.next()) { + (Some(ch), None) if !ch.is_control() => Some(ch), + _ => None, + }) +} + +/// What key `code` types under `modifiers` in the current layout; a dead key types its own mark. +fn typed(code: u16, modifiers: u32) -> anyhow::Result { + const SPACE: u16 = 49; + let mut text = [0u16; 4]; + let mut length = 0; + unsafe { + let source = TISCopyCurrentKeyboardLayoutInputSource(); + anyhow::ensure!(!source.is_null(), "macOS: no keyboard layout"); + let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + if !data.is_null() { + let layout = CFDataGetBytePtr(data).cast::(); + let keyboard = u32::from(LMGetKbdType()); + let mut dead = 0; + for key in [code, SPACE] { + UCKeyTranslate( + layout, + key, + 0, + modifiers, + keyboard, + 0, + &mut dead, + text.len(), + &mut length, + text.as_mut_ptr(), + ); + if dead == 0 { + break; + } + } + } + CFRelease(source); + anyhow::ensure!( + !data.is_null(), + "macOS: the keyboard layout has no key table" + ); } + String::from_utf16(&text[..length]).context("macOS: the layout typed no UTF-16") } /// Turns off the accent menu a held key opens, so held keys repeat as in other terminals. diff --git a/src/term/mod.rs b/src/term/mod.rs index d8a83d1..f0f8e68 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -23,6 +23,8 @@ mod titles; #[cfg(unix)] mod unix; mod view; +#[cfg(windows)] +mod win32; pub mod zoom; pub use pty::{Events, Launch, Listener, Pty, PtyEvent, config, fit, spawn}; diff --git a/src/term/pty.rs b/src/term/pty.rs index 3d857af..fbc525d 100644 --- a/src/term/pty.rs +++ b/src/term/pty.rs @@ -29,6 +29,9 @@ pub enum PtyEvent { Title(String), /// A full reset, `ESC c`. Reset, + /// win32-input-mode set or reset. + #[cfg(windows)] + Win32(bool), } /// Hands the grid's events to the session; `pid` names the program in logs. diff --git a/src/term/tap.rs b/src/term/tap.rs index 812d377..ca45773 100644 --- a/src/term/tap.rs +++ b/src/term/tap.rs @@ -9,7 +9,6 @@ use polling::{Event, PollMode, Poller}; use super::{ marks, - mouse::MouseMode, notify::{self, Kitty}, prompts::{self, Prompts}, pty::PtyEvent, @@ -24,7 +23,7 @@ pub(super) const MOST: usize = 4096; pub(crate) struct Tapped

{ pub inner: P, scanner: Osc, - mouse: MouseModes, + modes: Modes, prompts: Prompts, kitty: Kitty, titles: Titles, @@ -38,7 +37,7 @@ impl

Tapped

{ Self { inner, scanner: Osc::default(), - mouse: MouseModes::default(), + modes: Modes::default(), prompts: Prompts::default(), kitty: Kitty::default(), titles: Titles::default(), @@ -66,12 +65,24 @@ impl io::Read for Tapped

{ self.scanner.feed(read, &mut |found| { report(found, events, pid, last, kitty, titles) }); - if let Some(mode) = self.mouse.feed(read) { + #[cfg(windows)] + let win32 = self.modes.win32; + if let Some(mode) = self.modes.feed(read) { log::info!("pty {pid}: mouse {mode:?}"); if events.unbounded_send(PtyEvent::Mouse(mode)).is_err() { log::debug!("pty {pid}: a mouse mode after its session closed"); } } + #[cfg(windows)] + if self.modes.win32 != win32 { + log::info!("pty {pid}: win32-input-mode {}", self.modes.win32); + if events + .unbounded_send(PtyEvent::Win32(self.modes.win32)) + .is_err() + { + log::debug!("pty {pid}: win32-input-mode after its session closed"); + } + } let out = self.prompts.pass(&buf[..count]); buf[..out.len()].copy_from_slice(out); Ok(out.len()) @@ -338,89 +349,6 @@ impl Osc { /// The most values, subparameters too, one sequence holds before vte drops it. const PARAMS: usize = 32; -#[derive(Clone, Copy, Debug, Default, PartialEq)] -enum Csi { - #[default] - Ground, - Escape, - Bracket, - Private, -} - -/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9. -#[derive(Default)] -pub(crate) struct MouseModes { - state: Csi, - params: Vec, - /// Values begun, subparameters too. - fields: usize, - /// Past a `:`, whose subparameters the parser's mode sets skip. - sub: bool, - mode: MouseMode, -} - -impl MouseModes { - /// The mode after `bytes`, when they changed it. - pub fn feed(&mut self, bytes: &[u8]) -> Option { - let was = self.mode; - let mut rest = bytes; - while !rest.is_empty() { - if self.state == Csi::Ground { - let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else { - break; - }; - self.state = Csi::Escape; - rest = &rest[at + 1..]; - continue; - } - let byte = rest[0]; - rest = &rest[1..]; - self.state = match (self.state, byte) { - (_, 0x18 | 0x1a) => Csi::Ground, - (_, 0x1b) => Csi::Escape, - // Mid-sequence, C0 runs and DEL is skipped, as in vte. - (state, 0x00..=0x1f | 0x7f..=0xff) => state, - (Csi::Escape, b'[') => Csi::Bracket, - (Csi::Escape, b'c') => { - self.mode = MouseMode::default(); - Csi::Ground - } - (Csi::Bracket, b'?') => { - self.params = vec![0]; - self.fields = 1; - self.sub = false; - Csi::Private - } - (Csi::Private, b'0'..=b'9') => { - if !self.sub { - let last = self.params.last_mut().expect("a parameter in progress"); - *last = last - .saturating_mul(10) - .saturating_add(u32::from(byte - b'0')); - } - Csi::Private - } - (Csi::Private, b':' | b';') if self.fields < PARAMS => { - self.fields += 1; - self.sub = byte == b':'; - if !self.sub { - self.params.push(0); - } - Csi::Private - } - (Csi::Private, b'h' | b'l') => { - for &mode in &self.params { - self.mode.apply(mode, byte == b'h'); - } - Csi::Ground - } - _ => Csi::Ground, - }; - } - (self.mode != was).then_some(self.mode) - } -} - /// The local folder an OSC 7 `file://host/path` names; another host's folder is none. pub(crate) fn folder(body: &[u8]) -> Option { let Some(rest) = body.strip_prefix(b"file://") else { @@ -480,5 +408,8 @@ fn decode(bytes: &[u8]) -> Option { String::from_utf8(out).ok() } +mod modes; #[cfg(test)] mod tests; + +pub(crate) use modes::Modes; diff --git a/src/term/tap/modes.rs b/src/term/tap/modes.rs new file mode 100644 index 0000000..46fa2a4 --- /dev/null +++ b/src/term/tap/modes.rs @@ -0,0 +1,98 @@ +//! The private modes the tap follows before the parser. + +use super::PARAMS; +use crate::term::mouse::MouseMode; + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +enum Csi { + #[default] + Ground, + Escape, + Bracket, + Private, +} + +/// The mouse modes as xterm holds them, read as vte reads; alacritty drops 9. On Windows, win32-input-mode too. +#[derive(Default)] +pub(crate) struct Modes { + state: Csi, + params: Vec, + /// Values begun, subparameters too. + fields: usize, + /// Past a `:`, whose subparameters the parser's mode sets skip. + sub: bool, + pub(super) mode: MouseMode, + /// Keys go as Windows' records, as ConPTY asks; a full reset ends it, as in Windows Terminal. + #[cfg(windows)] + pub(super) win32: bool, +} + +impl Modes { + /// The mode after `bytes`, when they changed it. + pub fn feed(&mut self, bytes: &[u8]) -> Option { + let was = self.mode; + let mut rest = bytes; + while !rest.is_empty() { + if self.state == Csi::Ground { + let Some(at) = rest.iter().position(|&byte| byte == 0x1b) else { + break; + }; + self.state = Csi::Escape; + rest = &rest[at + 1..]; + continue; + } + let byte = rest[0]; + rest = &rest[1..]; + self.state = match (self.state, byte) { + (_, 0x18 | 0x1a) => Csi::Ground, + (_, 0x1b) => Csi::Escape, + // Mid-sequence, C0 runs and DEL is skipped, as in vte. + (state, 0x00..=0x1f | 0x7f..=0xff) => state, + (Csi::Escape, b'[') => Csi::Bracket, + (Csi::Escape, b'c') => { + self.mode = MouseMode::default(); + #[cfg(windows)] + { + self.win32 = false; + } + Csi::Ground + } + (Csi::Bracket, b'?') => { + self.params = vec![0]; + self.fields = 1; + self.sub = false; + Csi::Private + } + (Csi::Private, b'0'..=b'9') => { + if !self.sub { + let last = self.params.last_mut().expect("a parameter in progress"); + *last = last + .saturating_mul(10) + .saturating_add(u32::from(byte - b'0')); + } + Csi::Private + } + (Csi::Private, b':' | b';') if self.fields < PARAMS => { + self.fields += 1; + self.sub = byte == b':'; + if !self.sub { + self.params.push(0); + } + Csi::Private + } + (Csi::Private, b'h' | b'l') => { + for &mode in &self.params { + self.mode.apply(mode, byte == b'h'); + #[cfg(windows)] + if mode == crate::term::win32::MODE { + self.win32 = byte == b'h'; + } + } + Csi::Ground + } + _ => Csi::Ground, + }; + } + (self.mode != was).then_some(self.mode) + } +} diff --git a/src/term/tap/tests.rs b/src/term/tap/tests.rs index d8b5d57..f01d150 100644 --- a/src/term/tap/tests.rs +++ b/src/term/tap/tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::term::mouse::MouseMode; /// What the scanner finds in `chunks`, each one read: a body, or `RESET`. fn scan(chunks: &[&[u8]]) -> Vec { @@ -86,7 +87,7 @@ fn an_osc_99_body_holds_its_metadata_and_a_full_payload() { #[test] fn mouse_modes_follow_private_modes_in_order_across_reads() { use crate::term::mouse::{Format, Tracking}; - let mut modes = MouseModes::default(); + let mut modes = Modes::default(); assert_eq!(modes.feed(b"a\x1b[?"), None); assert!(modes.feed(b"1006;9h").is_some()); assert_eq!(modes.mode.format, Format::Sgr); @@ -106,7 +107,7 @@ fn mouse_modes_follow_private_modes_in_order_across_reads() { #[test] fn mouse_modes_read_through_controls_as_the_parser_does() { use crate::term::mouse::{Format, Tracking}; - let mut modes = MouseModes::default(); + let mut modes = Modes::default(); modes.feed(b"\x1b[?1000\x00h\x1b[?1006"); modes.feed(b"\x00h"); let on = MouseMode { @@ -220,7 +221,7 @@ fn the_tap_reads_mouse_modes_as_vte_does_byte_by_byte() { .chain([values(31), values(32)]) { let case = case.as_slice(); - let mut modes = MouseModes::default(); + let mut modes = Modes::default(); for end in 1..=case.len() { modes.feed(&case[end - 1..end]); assert_eq!(modes.mode, parsed(&case[..end]), "{case:?} to byte {end}"); @@ -278,3 +279,15 @@ fn a_title_s_body_holds_65536_bytes_and_another_s_4096() { "another stays at 4096" ); } + +#[cfg(windows)] +#[test] +fn win32_input_mode_follows_conptys_ask_until_a_full_reset() { + let mut modes = Modes::default(); + modes.feed(b"\x1b[c\x1b[?1004h\x1b[?9001h"); + assert!(modes.win32); + modes.feed(b"\x1b[?9001l"); + assert!(!modes.win32); + modes.feed(b"\x1b[?9001h\x1bc"); + assert!(!modes.win32, "a full reset ends it"); +} diff --git a/src/term/view.rs b/src/term/view.rs index fd3e201..8534172 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -67,6 +67,9 @@ pub struct TerminalView { pub(super) pointer: Pointer, /// The mouse mode, as the tap follows it. pub(super) mouse: MouseMode, + /// Keys go as Windows' records, as ConPTY asks. + #[cfg(windows)] + pub(super) win32: bool, /// Keys whose press this terminal saw, so their releases may go out. pub(super) held: HashSet, pub(super) finder: Option, @@ -130,6 +133,8 @@ impl TerminalView { caret: Bounds::default(), pointer: Pointer::default(), mouse: MouseMode::default(), + #[cfg(windows)] + win32: false, held: HashSet::new(), finder: None, size, @@ -260,6 +265,8 @@ impl TerminalView { } PtyEvent::Term(event) => self.term_event(event, cx), PtyEvent::Mouse(mode) => self.mouse = mode, + #[cfg(windows)] + PtyEvent::Win32(on) => self.win32 = on, PtyEvent::Mark(Mark::Done(status)) => self.finished(status, cx), // A new prompt ends what was working, waiting or idle; done and error stay. PtyEvent::Mark(Mark::Prompt) => { diff --git a/src/term/win32.rs b/src/term/win32.rs new file mode 100644 index 0000000..17bd5a3 --- /dev/null +++ b/src/term/win32.rs @@ -0,0 +1,166 @@ +//! win32-input-mode, which ConPTY asks for: a key goes as Windows' own record, as Windows Terminal sends it. + +use gpui::Keystroke; +use windows_sys::Win32::{ + System::Console::{ + CAPSLOCK_ON, ENHANCED_KEY, LEFT_ALT_PRESSED, LEFT_CTRL_PRESSED, NUMLOCK_ON, + RIGHT_ALT_PRESSED, RIGHT_CTRL_PRESSED, SCROLLLOCK_ON, SHIFT_PRESSED, + }, + UI::Input::KeyboardAndMouse::{ + GetKeyState, GetKeyboardState, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW, + ToUnicode, VK_APPS, VK_BACK, VK_BROWSER_BACK, VK_BROWSER_FORWARD, VK_CAPITAL, VK_DELETE, + VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LCONTROL, VK_LEFT, VK_LMENU, + VK_NEXT, VK_NUMLOCK, VK_PRIOR, VK_RCONTROL, VK_RETURN, VK_RIGHT, VK_RMENU, VK_SCROLL, + VK_SHIFT, VK_SPACE, VK_TAB, VK_UP, VkKeyScanW, + }, +}; + +use super::keys::Action; + +/// The mode ConPTY sets, `CSI ? 9001 h`. +pub const MODE: u32 = 9001; + +/// `keystroke` as ConPTY reads it, `CSI Vk ; Sc ; Uc ; Kd ; Cs ; Rc _`; none for a key no virtual key names. +pub fn record(keystroke: &Keystroke, action: Action) -> Option> { + let (key, enhanced) = virtual_key(&keystroke.key)?; + let scan = unsafe { MapVirtualKeyW(u32::from(key), MAPVK_VK_TO_VSC) }; + let mut state = [0u8; 256]; + let mut unit = [0u16; 2]; + // Flag 4 leaves the keyboard's dead key as it is. + let typed = unsafe { + if GetKeyboardState(state.as_mut_ptr()) == 0 { + log::error!("windows: the keyboard state did not read"); + return None; + } + ToUnicode( + u32::from(key), + scan, + state.as_ptr(), + unit.as_mut_ptr(), + unit.len() as i32, + 4, + ) + }; + let unit = if typed == 1 { unit[0] } else { 0 }; + let down = u8::from(action != Action::Release); + let control = control(enhanced); + Some(format!("\x1b[{key};{scan};{unit};{down};{control};1_").into_bytes()) +} + +/// The key's own character, where gpui named `made`, what Shift made of it, and dropped Shift. +pub fn unshifted(made: &str) -> Option { + let mut chars = made.chars(); + let (Some(made), None) = (chars.next(), chars.next()) else { + return None; + }; + if unsafe { GetKeyState(i32::from(VK_SHIFT)) } >= 0 { + return None; + } + let mut buffer = [0u16; 2]; + let &mut [unit] = made.encode_utf16(&mut buffer) else { + return None; + }; + // The high byte names the modifiers the character takes: here Shift alone. + let scan = unsafe { VkKeyScanW(unit) } as u16; + if scan == u16::MAX || scan >> 8 != 1 { + return None; + } + let own = unsafe { MapVirtualKeyW(u32::from(scan & 0xFF), MAPVK_VK_TO_CHAR) } & 0xFFFF; + char::from_u32(own) + .map(|own| own.to_ascii_lowercase()) + .filter(|own| !own.is_control()) +} + +/// The virtual key gpui's `name` stands for, and whether it sits in the enhanced block. +fn virtual_key(name: &str) -> Option<(u16, bool)> { + let named = match name { + "enter" => (VK_RETURN, false), + "tab" => (VK_TAB, false), + "escape" => (VK_ESCAPE, false), + "backspace" => (VK_BACK, false), + "space" => (VK_SPACE, false), + "menu" => (VK_APPS, true), + "back" => (VK_BROWSER_BACK, true), + "forward" => (VK_BROWSER_FORWARD, true), + "left" => (VK_LEFT, true), + "up" => (VK_UP, true), + "right" => (VK_RIGHT, true), + "down" => (VK_DOWN, true), + "home" => (VK_HOME, true), + "end" => (VK_END, true), + "pageup" => (VK_PRIOR, true), + "pagedown" => (VK_NEXT, true), + "insert" => (VK_INSERT, true), + "delete" => (VK_DELETE, true), + name => { + if let Some(number) = name + .strip_prefix('f') + .and_then(|number| number.parse::().ok()) + .filter(|number| (1..=24).contains(number)) + { + return Some((VK_F1 + number - 1, false)); + } + let mut chars = name.chars(); + let (Some(ch), None) = (chars.next(), chars.next()) else { + return None; + }; + let mut buffer = [0u16; 2]; + let &mut [unit] = ch.encode_utf16(&mut buffer) else { + return None; + }; + let scan = unsafe { VkKeyScanW(unit) } as u16; + return (scan != u16::MAX).then_some((scan & 0xFF, false)); + } + }; + Some(named) +} + +/// Windows' control key state now: the modifier keys, the locks, and the enhanced block. +fn control(enhanced: bool) -> u32 { + let down = |key: u16| unsafe { GetKeyState(i32::from(key)) } < 0; + let on = |key: u16| unsafe { GetKeyState(i32::from(key)) } & 1 != 0; + [ + (down(VK_SHIFT), SHIFT_PRESSED), + (down(VK_LCONTROL), LEFT_CTRL_PRESSED), + (down(VK_RCONTROL), RIGHT_CTRL_PRESSED), + (down(VK_LMENU), LEFT_ALT_PRESSED), + (down(VK_RMENU), RIGHT_ALT_PRESSED), + (on(VK_CAPITAL), CAPSLOCK_ON), + (on(VK_NUMLOCK), NUMLOCK_ON), + (on(VK_SCROLL), SCROLLLOCK_ON), + (enhanced, ENHANCED_KEY), + ] + .into_iter() + .filter(|(held, _)| *held) + .fold(0, |state, (_, flag)| state | flag) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_keys_and_letters_find_their_virtual_keys() { + assert_eq!(virtual_key("enter"), Some((VK_RETURN, false))); + assert_eq!(virtual_key("left"), Some((VK_LEFT, true))); + assert_eq!(virtual_key("f12"), Some((VK_F1 + 11, false))); + assert_eq!(virtual_key("f25"), None); + assert_eq!(virtual_key("a"), Some((u16::from(b'A'), false))); + } + + #[test] + fn a_record_names_the_key_its_scan_code_and_its_press() { + let key = Keystroke::parse("enter").expect("a keystroke"); + let text = + |action| String::from_utf8(record(&key, action).expect("a record")).expect("text"); + let scan = unsafe { MapVirtualKeyW(u32::from(VK_RETURN), MAPVK_VK_TO_VSC) }; + let down = text(Action::Press); + assert!( + down.starts_with(&format!("\x1b[13;{scan};13;1;")), + "{down:?}" + ); + assert!(down.ends_with(";1_"), "{down:?}"); + let up = text(Action::Release); + assert!(up.starts_with(&format!("\x1b[13;{scan};13;0;")), "{up:?}"); + } +} diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 29f2432..572b678 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -64,7 +64,7 @@ What no check reached: no real KWin drew the blur; of Wayland desktops, only Hyp | Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass | | JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass | | Cursor shapes, blink by setting and DECSCUSR, hollow unfocused | T06 | T06 review | Pass | -| xterm keys, cursor mode, Option as Alt, kitty protocol, keypad | T08 | `term::keys` tests | Pass | +| xterm keys, cursor mode, Option as Alt, kitty protocol, keypad; a shifted symbol as its own key with Shift on macOS and Windows; win32-input-mode on Windows | T08, T44 | `term::keys` tests, `kitty_reports_the_key_shift_turned_into_a_symbol` red at `CSI 123;13u` before the fix; on macOS herdr 0.9.5, its `[keys]` `previous_workspace = "cmd+shift+ctrl+["` and `next_workspace = "cmd+shift+ctrl+]"`, run by `shot` in a fresh HOME: before, ⌘⇧⌃] sent `CSI 125;13u` and herdr stayed on its workspace; after, `CSI 93:125;14u` moved it to the next and ⌘⇧⌃[, `CSI 91:123;14u`, back; `term::win32` and `term::tap` tests on Windows | Pass | | IME: marked text at the cursor, commits to the program | T08 | T08 review | Pass | | Selection by character, word, line; Shift extends; mouse reporting; wheel | T09 | `term::mouse`, `term::tap` tests | Pass | | Copy, paste, bracketed paste, copy on select, OSC 52 | T09 | T09 review | Pass |