ea49739c
merge: integrate gui text selection sprint
a73x 2026-09-06 13:45
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -63,6 +63,16 @@ and their diagnostics live in `build.zig`; `test/bans.sh` tests enforcement. | |||
| 63 | - Agent forwarding is opt-in. Clipboard reads through OSC 52 are refused. | 63 | - Agent forwarding is opt-in. Clipboard reads through OSC 52 are refused. |
| 64 | 64 | ||
| 65 | Read the detailed contracts for the area being changed: | 65 | Read the detailed contracts for the area being changed: |
| 66 | | Folder | Row — its child files | | ||
| 67 | |---|---| | ||
| 68 | | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | | ||
| 69 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | | ||
| 70 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `selection` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | ||
| 71 | | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` | | ||
| 72 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | ||
| 73 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | | ||
| 74 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | | ||
| 75 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | | ||
| 66 | 76 | ||
| 67 | - [Terminal and agent contracts](docs/terminal-contracts.md). | 77 | - [Terminal and agent contracts](docs/terminal-contracts.md). |
| 68 | - [Daemon, transport and upgrade contracts](docs/daemon-contracts.md). | 78 | - [Daemon, transport and upgrade contracts](docs/daemon-contracts.md). |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -79,6 +79,15 @@ sessions survive. Glyphs refresh automatically when display scale changes, | |||
| 79 | retaining the selected family. Installed Nerd Font Mono icons use that face; | 79 | retaining the selected family. Installed Nerd Font Mono icons use that face; |
| 80 | font fallback and cross-cell programming ligatures are not implemented. | 80 | font fallback and cross-cell programming ligatures are not implemented. |
| 81 | 81 | ||
| 82 | In the native GUI, a left drag selects and copies text unless the application | ||
| 83 | has requested mouse reporting. Applications such as tmux then receive ordinary | ||
| 84 | clicks and drags; hold Shift when starting a drag to select locally instead. | ||
| 85 | That choice remains fixed until release, even if Shift changes or the pointer | ||
| 86 | crosses another pane. Ctrl+Shift+C copies the current local selection. | ||
| 87 | Application OSC 52 writes update the desktop clipboard (`c`) or primary | ||
| 88 | selection (`p`/`s`); clipboard queries remain refused. GUI paste is not yet | ||
| 89 | implemented. | ||
| 90 | |||
| 82 | Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or | 91 | Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or |
| 83 | `~/.config/mux/themes/`), or by absolute path: | 92 | `~/.config/mux/themes/`), or by absolute path: |
| 84 | 93 | ||
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -974,7 +974,7 @@ backlog. Duplicate appearance checkboxes now point to their completed slices. | |||
| 974 | 974 | ||
| 975 | | Issue | Current gap and evidence | | 975 | | Issue | Current gap and evidence | |
| 976 | | --- | --- | | 976 | | --- | --- | |
| 977 | | `8b16e26b` mouse/wheel/selection | Missing pane wheel scrollback, terminal app mouse forwarding, drag selection and copy. `src/gui/frame.zig` handles left-button focus/rail drag, with no wheel event or clipboard path. Follow the issue's wheel-first slice, then shared selection and daemon-owned extraction. Paste is a subsequent slice. | | 977 | | `8b16e26b` mouse/wheel/selection | Visible-text drag selection and desktop copy are now delivered on the text-selection branch (demo acceptance pending). Wheel/history navigation, application mouse forwarding, extended selection gestures and GUI paste remain. See the text-selection delivery entry below; the user selected this slice before wheel scrolling. | |
| 978 | | `f3cf5785` SSH askpass | No native prompt UI for passphrases or first-use host-key confirmation. TUI owns the askpass listener/prompt flow; shared askpass support exists. Earlier SSH failure classification did not implement prompts. | | 978 | | `f3cf5785` SSH askpass | No native prompt UI for passphrases or first-use host-key confirmation. TUI owns the askpass listener/prompt flow; shared askpass support exists. Earlier SSH failure classification did not implement prompts. | |
| 979 | | `50ca9ba5` agent forwarding | No GUI `-A` policy or agent relay. Picker targets disable forwarding; functional relay handling remains in the TUI pump. | | 979 | | `50ca9ba5` agent forwarding | No GUI `-A` policy or agent relay. Picker targets disable forwarding; functional relay handling remains in the TUI pump. | |
| 980 | | `fb4a0ee4` prediction | No native local typing prediction overlay. The predictor remains TUI-local; sharing must preserve the authoritative replica. | | 980 | | `fb4a0ee4` prediction | No native local typing prediction overlay. The predictor remains TUI-local; sharing must preserve the authoritative replica. | |
| @@ -1043,3 +1043,544 @@ forward stale-reply, independent-pane, high-DPI, passive-observation and private | |||
| 1043 | webpage/video requirements. Implementation has not started. Ligatures remain | 1043 | webpage/video requirements. Implementation has not started. Ligatures remain |
| 1044 | deferred. Retain the appearance worktree's live review pages and preserve main's | 1044 | deferred. Retain the appearance worktree's live review pages and preserve main's |
| 1045 | staged user retro file when fast-forwarding. | 1045 | staged user retro file when fast-forwarding. |
| 1046 | |||
| 1047 | |||
| 1048 | ## Wheel sprint opening — ownership and duplication cleanup, 2026-09-06 | ||
| 1049 | |||
| 1050 | The user asked to review duplication, hexagonal architecture opportunities and | ||
| 1051 | refactoring, then clarified that font validation should belong to fonts rather | ||
| 1052 | than either input adapter. `font_options.zig` now owns the shared point-size | ||
| 1053 | rule. CLI/config callers retain their diagnostics; installed-face/monospace | ||
| 1054 | validation stays in `font.zig`. The first proposal shared the helper from config; | ||
| 1055 | that removed repetition but assigned the rule to the wrong owner. Removed a | ||
| 1056 | redundant empty-family check already enforced by the text parser. | ||
| 1057 | |||
| 1058 | Font settings, config and theme source now belong to the window-free native core. | ||
| 1059 | The native renderer consumes core theme values and preserves its existing public | ||
| 1060 | aliases. Luna implemented the bounded change; Terra reviewed ownership, error | ||
| 1061 | mapping and module/test reachability. Closing review removed a stale comment | ||
| 1062 | and retained the upper-limit rationale with its new policy owner. No extra | ||
| 1063 | transport or filesystem abstraction was introduced for a single caller. | ||
| 1064 | |||
| 1065 | Independent dependency probes hid GUI package metadata: all 29 native core tests | ||
| 1066 | passed, and the matching full GUI build failed on missing SDL3 as expected. | ||
| 1067 | Native build/units, full native integration and full CI passed. | ||
| 1068 | Evidence is in `dist/wheel-opening-cleanup/` in the new worktree. The first native | ||
| 1069 | integration launch overlapped the check stage's temporary source-ban mutation; | ||
| 1070 | it failed before building and passed after that stage finished. Both logs are | ||
| 1071 | retained. The native workflow now explicitly serializes other builds against | ||
| 1072 | that stage. Existing approved appearance recordings remain the visual baseline; | ||
| 1073 | this opening refactor adds no visible feature or performance claim. | ||
| 1074 | |||
| 1075 | Carry forward into wheel implementation: semantic wheel intent crosses the | ||
| 1076 | runtime/pump boundary; the pump consumes available mode frames before deciding | ||
| 1077 | history versus application input. Do not sample mutable pump modes in the GUI | ||
| 1078 | and queue pre-encoded bytes. Keep history/live snapshot ownership and reply | ||
| 1079 | correlation explicit, share actual semantic helpers instead of copying the TUI | ||
| 1080 | interaction core, and inject real SDL wheel events in the retained test adapter. | ||
| 1081 | These are implementation constraints, not completed wheel behavior. | ||
| 1082 | |||
| 1083 | |||
| 1084 | ## Text selection opening cleanup — 2026-09-06 | ||
| 1085 | |||
| 1086 | The user selected text selection as the next sprint using the updated skill. | ||
| 1087 | Wheel behavior remains unimplemented; its plan is deferred. The reuse map in | ||
| 1088 | `docs/superpowers/plans/2026-09-06-native-text-selection.md` identifies existing | ||
| 1089 | drag state, daemon extraction and shared request correlation before feature edits. | ||
| 1090 | |||
| 1091 | Moved the pure TUI selection module and all its tests to `client.selection`; | ||
| 1092 | both frontends can consume one gesture model. Deleted the old TUI source path | ||
| 1093 | and updated existing callers without changing wall behavior. Luna implemented; | ||
| 1094 | Terra independently found no opening-cleanup defect. The first focused client | ||
| 1095 | test attempt was sandbox-blocked on local sockets; the required escalated | ||
| 1096 | `make check` covers those tests. Logs are in `dist/text-selection/`. | ||
| 1097 | |||
| 1098 | Feature work owns the remaining snapshot/request freshness checks and SDL | ||
| 1099 | clipboard adapter. Review identified a send-time race in addition to UI | ||
| 1100 | cancellation: the pump must compare the displayed sequence when sending and | ||
| 1101 | receiving. The existing protocol does not carry a server sequence precondition; | ||
| 1102 | observed output changes can be rejected, but atomic copying of a historical | ||
| 1103 | frame is not promised. A protocol-level version guard is retained for the next | ||
| 1104 | selection-consistency change if that stronger guarantee is required. | ||
| 1105 | |||
| 1106 | |||
| 1107 | ## Native text selection delivery — 2026-09-06 | ||
| 1108 | |||
| 1109 | Implemented visible text drag selection and desktop copy on release. The user | ||
| 1110 | selected this before wheel scrolling; wheel remains deferred and unimplemented. | ||
| 1111 | The shared selection model was moved once in opening commit `b0a68b1`; both | ||
| 1112 | frontends consume it. Daemon extraction and the existing wire remain the owners | ||
| 1113 | of Unicode, soft wraps, trimming and wide-cell copy semantics. | ||
| 1114 | |||
| 1115 | The reuse map guided implementation. Closing cleanup replaced repeated source | ||
| 1116 | version fields/rules with `SelectionVersion`, centralized pump invalidation, | ||
| 1117 | removed redundant held-range storage and the painter's duplicate span type. | ||
| 1118 | Queued request tickets cancel work before transmission; one owned result and a | ||
| 1119 | bounded deadline cross into the SDL clipboard adapter. Core rules remain free | ||
| 1120 | of window/font libraries. No new runtime dependency or protocol message was | ||
| 1121 | introduced. Root completed shared pump cleanup/tests after Luna's initial | ||
| 1122 | integration; Terra independently reviewed the final production diff. | ||
| 1123 | |||
| 1124 | Review and real checks caught coordinate/history overflow, cross-pane subtraction, | ||
| 1125 | mouse capture remaining after release, lost divider release coordinates, stale | ||
| 1126 | press-version replacement, mode/reconnect invalidation, timeout locking, | ||
| 1127 | allocation-failure ownership and empty-copy behavior. All actionable production | ||
| 1128 | findings were resolved. Initial focused test claims covered existing tests only; | ||
| 1129 | closing work added explicit transport and GUI high-history cases before delivery. | ||
| 1130 | |||
| 1131 | Full CI, client/native units/build, native integration, core-without-GUI metadata, | ||
| 1132 | real NVIDIA Wayland pointer/clipboard checks and both selection-specific and | ||
| 1133 | retained DPI gates passed. Separate NVIDIA stress passed with frame p99 19,267 us | ||
| 1134 | and sampled input upper bound 65.3 ms; historical frame-budget misses remain | ||
| 1135 | unexplained. Logs and earlier failed fixture attempts remain in | ||
| 1136 | `dist/text-selection/`. No macOS or performance improvement is inferred. | ||
| 1137 | |||
| 1138 | The 24.4-second continuous recording uses real Wayland pointer motion and reads | ||
| 1139 | clipboard contents with a separate client. SDL injection alone lacked a valid | ||
| 1140 | Wayland input serial; the retained pointer adapter now makes that boundary | ||
| 1141 | explicit. Clipboard publication is asynchronous, so the independent reader waits | ||
| 1142 | for offers to arrive. An early recorder failure skipped cleanup; the recorder | ||
| 1143 | now guarantees fixture teardown and root stopped only that failed fixture's | ||
| 1144 | owned processes. Private page: https://charizard.folk-amberjack.ts.net/text-selection/ . | ||
| 1145 | Demo approval remains pending; automated success is not user acceptance. | ||
| 1146 | |||
| 1147 | Carry-forward actions and ownership: | ||
| 1148 | |||
| 1149 | - Next selection-consistency sprint: the existing wire has no source-version | ||
| 1150 | precondition at the daemon. Observed changes cancel copying, but an atomic | ||
| 1151 | historical-frame copy would require coordinated wire/frontend work. | ||
| 1152 | - Next mouse-parity sprint: implement wheel/history, then assess edge autoscroll, | ||
| 1153 | word/line/rectangular selection and GUI paste as separately scoped behavior. | ||
| 1154 | Application mouse forwarding remains open under issue `8b16e26b`. | ||
| 1155 | - Next renderer follow-up: investigate earlier NVIDIA budget misses and offscreen | ||
| 1156 | growth artifacts. Current passes do not settle their cause. | ||
| 1157 | - Next recording/tooling change: consider promoting the retained C virtual-pointer | ||
| 1158 | helper and recorder assets; the Python input adapter is now shared by tests and | ||
| 1159 | this demo, while source/protocol/binary provenance remains with the artifacts. | ||
| 1160 | - Next frame/test-protocol change: assess broader extraction of passive hooks; | ||
| 1161 | this slice added one read-only clipboard observation, not a second input path. | ||
| 1162 | |||
| 1163 | |||
| 1164 | The final private page passed HTTP range, desktop/mobile layout, playback and | ||
| 1165 | seeking checks; all three appearance pages still return success. The final | ||
| 1166 | retained recorder also passed after explicitly focusing the owned window before | ||
| 1167 | its first pane click. Offscreen selection was rechecked after the Wayland driver | ||
| 1168 | addition. Browser and compositor fixtures are stopped; only the allowlisted | ||
| 1169 | loopback page server (PID 3384909, port 18774) remains. The main checkout's staged | ||
| 1170 | `RETRO.pre-native-merge.md` remains byte-for-byte unchanged. | ||
| 1171 | |||
| 1172 | ### Acceptance teardown and application scrolling follow-up | ||
| 1173 | |||
| 1174 | The user requested teardown after demo acceptance. Removed the three accepted | ||
| 1175 | appearance routes and stopped their verified page-server processes; retained | ||
| 1176 | their recordings and page sources. Only the text-selection route remains pending | ||
| 1177 | review, and its page still responds successfully. The maintained sprint skill | ||
| 1178 | and native guide now require this cleanup on acceptance, including verification | ||
| 1179 | and preservation of unrelated services. Evidence: | ||
| 1180 | `dist/text-selection/accepted-demo-teardown.json`. | ||
| 1181 | |||
| 1182 | A focused probe ran real Vim and less in isolated daemon PTYs with the native | ||
| 1183 | offscreen GUI. In both apps, keyboard scrolling clears the GUI highlight while | ||
| 1184 | preserving text already copied on release. Scrolling during a held drag cancels | ||
| 1185 | it without overwriting the clipboard. Framebuffer and clipboard observations | ||
| 1186 | passed; all probe processes were closed. This does not add wheel scrolling or | ||
| 1187 | application mouse forwarding. Evidence and the rerunnable probe remain under | ||
| 1188 | `dist/text-selection/app-scroll*` and `check-app-selection.py`. | ||
| 1189 | |||
| 1190 | ### Hands-on copy shortcut — 2026-09-06 | ||
| 1191 | |||
| 1192 | The hands-on review found Ctrl+Shift+C reached the PTY as Ctrl+C. The SDL adapter | ||
| 1193 | now recognises the copy chord and the controller consumes it before terminal | ||
| 1194 | input, even with no selected range. Copy on release and explicit copying share | ||
| 1195 | one versioned request queue. Highlight and stale-attachment rules remain in the | ||
| 1196 | controller; extraction and clipboard writing keep their existing owners. The | ||
| 1197 | small cleanup removed an unnecessary forwarding helper. No new module, | ||
| 1198 | dependency or wire message was introduced. | ||
| 1199 | |||
| 1200 | Luna implemented and Terra reviewed. Root caught a modifier-mask mistake that | ||
| 1201 | required both left and right modifiers simultaneously; the final adapter tests | ||
| 1202 | cover all four physical Ctrl/Shift combinations and Alt/GUI/Mode exclusions. | ||
| 1203 | The real-PTY regression independently records SIGINT: shortcut copy during a | ||
| 1204 | held drag, after release and without a selection leaves the process alone; | ||
| 1205 | ordinary Ctrl+C still interrupts it. Root also corrected test ordering races by | ||
| 1206 | waiting for actual highlighted pixels before releasing output or injecting the | ||
| 1207 | shortcut after Wayland pointer input. Earlier failures remain in the logs. | ||
| 1208 | |||
| 1209 | Full CI, native integration, native/core units, full offscreen and independent | ||
| 1210 | Wayland clipboard/scale checks passed. The user confirmed copying works in the restarted review GUI. The new | ||
| 1211 | 26.4-second continuous recording demonstrates copying before mouse release and | ||
| 1212 | independent `wl-paste` output. Its pointer events come through Wayland; the copy | ||
| 1213 | chord uses SDL event injection. Original footage is retained separately. | ||
| 1214 | |||
| 1215 | The initial hands-on launch used default colors because the user's mux config | ||
| 1216 | does not exist; prior demonstrations used isolated configs. Relaunched with the | ||
| 1217 | recording's explicit appearance flags. Next hands-on launch: reuse the recorded | ||
| 1218 | appearance arguments in `dist/text-selection/hands-on-gui.json`, and distinguish | ||
| 1219 | demo config from persistent user config when describing the launch. | ||
| 1220 | |||
| 1221 | The user reached for paste but agreed it can remain deferred. They clarified | ||
| 1222 | their mouse-selection example was Claude Code, whose fullscreen mode requests | ||
| 1223 | mouse reporting and displays its own copy toast. Alternate-screen state alone | ||
| 1224 | does not confer mouse ownership. The TUI's `Core.forward` branches on `appMouse()`; | ||
| 1225 | the GUI currently owns drags locally. `session_pump.onFrame` also skips validated | ||
| 1226 | application `clipboard_set` effects. The next mouse/clipboard parity slice must | ||
| 1227 | assess both application mouse forwarding and application clipboard requests, | ||
| 1228 | alongside GUI paste and copy feedback. See the upstream explanation at | ||
| 1229 | https://code.claude.com/docs/en/fullscreen#keep-native-text-selection . | ||
| 1230 | Confirmation of copying is hands-on feedback, | ||
| 1231 | not acceptance of the whole sprint or authorization to end daemon sessions. | ||
| 1232 | |||
| 1233 | The user confirmed Shift+drag belongs to the overall mouse feature. It is now | ||
| 1234 | required acceptance for the application mouse/clipboard slice: force native | ||
| 1235 | selection despite application mouse reporting, retain gesture ownership through | ||
| 1236 | release and modifier changes, and prevent reports or text leaking across panes. | ||
| 1237 | The wheel plan records the remaining order: wheel/history first, application | ||
| 1238 | mouse/clipboard next. The latter slice's planner owns carrying these criteria | ||
| 1239 | into implementation, tests and the demo; GUI paste remains deferred. | ||
| 1240 | |||
| 1241 | ## Native wheel scrolling — 2026-09-06 | ||
| 1242 | |||
| 1243 | Implemented the authorized next GUI/TUI parity slice: wheel over shell history, | ||
| 1244 | mode-aware alternate-screen arrows, negotiated application wheel reports and | ||
| 1245 | selection/copy from displayed history. Three panes on two daemons retain separate | ||
| 1246 | scroll positions and fractional remainders. Wheel does not change keyboard focus; | ||
| 1247 | menus, headers, dividers and command mode consume it. Application click/drag, | ||
| 1248 | application clipboard writes and the required Shift+drag override remain next. | ||
| 1249 | |||
| 1250 | The controller owns geometry and gesture policy; the runtime checks attachment | ||
| 1251 | identity; the shared pump owns terminal modes, wire ordering and history memory. | ||
| 1252 | History uses the existing request/row decoder and a separate display grid, never | ||
| 1253 | a second replica. One outstanding fetch remains as a cancelled tombstone until | ||
| 1254 | drained, including across same-wire resync. New connections reset it; timeout | ||
| 1255 | reconnects. Grid and displayed origin are captured together. Output refreshes | ||
| 1256 | history while retaining its distance from live, and stale text cannot be copied. | ||
| 1257 | |||
| 1258 | Luna implemented the GUI input seam and pure wheel encoder; root integrated the | ||
| 1259 | pump/runtime and real-boundary tests. Terra reviewed correctness and ownership. | ||
| 1260 | Closing cleanup removed a duplicate receive loop and hand-written UTF-8 encoding; | ||
| 1261 | standard key/Unicode encoding stays with its existing owner. No dependency or wire | ||
| 1262 | message was added, and frozen TUI behavior is unchanged. Production growth is | ||
| 1263 | concentrated in bounded history lifetime and semantic routing; no generic adapter | ||
| 1264 | framework or separate module was needed. | ||
| 1265 | |||
| 1266 | Review and checks caught SDL wheel-coordinate fields, unrelated-selection | ||
| 1267 | cancellation, same-origin stale replies, history/live origin confusion, QUIC | ||
| 1268 | mailbox batching and a deleted pane stealing another pane's fractional remainder. | ||
| 1269 | Focused tests cover wire ordering, resync, resize, timeouts and legacy coordinate | ||
| 1270 | limits. Full CI and native integration passed on the final source. Real NVIDIA | ||
| 1271 | Wayland wheel/clipboard checks pass at 200%, 100%, 150% and 200%; the retained DPI | ||
| 1272 | resize gate also passed. Core tests run without GUI package metadata. | ||
| 1273 | |||
| 1274 | A test application initially read a partially rewritten mode-control file and | ||
| 1275 | crashed; atomic replacement fixed the fixture. Earlier failed logs are retained. | ||
| 1276 | Draft test snippets were not treated as validation: root replaced incorrect | ||
| 1277 | fixture assumptions with tests exercising the production wire and mailbox. | ||
| 1278 | Next test author: verify fixture preconditions and assert the named behavior; | ||
| 1279 | a timeout test must exercise expiration, not merely compare the current clock. | ||
| 1280 | |||
| 1281 | The local webpage and continuous actual-GUI recording show history scrolling, | ||
| 1282 | copying a history row through an independent clipboard reader and scrolling real | ||
| 1283 | less without changing keyboard focus. Desktop/mobile playback and seeking passed. | ||
| 1284 | Evidence is retained in `dist/wheel-scrolling/`; source is | ||
| 1285 | `docs/demos/native-wheel-scrolling.html`. User acceptance is pending. | ||
| 1286 | |||
| 1287 | The user clarified delivery preferences: demos remain required; serve them on | ||
| 1288 | localhost by default. Publishing is optional only when explicitly requested, | ||
| 1289 | without routine prompts. The maintained skill and native workflow now say this | ||
| 1290 | and no longer embed a specific publishing provider or standing-publish step. | ||
| 1291 | The attempted remote publication was rejected by automatic approval review; no | ||
| 1292 | new remote route was created. The local page is the handoff. Preserve earlier | ||
| 1293 | services awaiting review and stop this page's owned server once accepted. | ||
| 1294 | |||
| 1295 | Open work and ownership: | ||
| 1296 | |||
| 1297 | - Renderer follow-up: investigate draw/swap timing at 60 Hz on NVIDIA. Initial | ||
| 1298 | current build frame p99 was 20.199 ms, previous-release comparison 21.449 ms, | ||
| 1299 | and current comparison 22.244 ms against the 20 ms limit. All input-response | ||
| 1300 | samples passed the separate 250 ms bound; the current comparison's largest | ||
| 1301 | observed upper bound was 98.4 ms with 5 ms polling. These are mixed historical | ||
| 1302 | builds under the same conditions, not a speedup or no-regression claim. Keep | ||
| 1303 | the failed logs and the pre-existing offscreen-growth issue open. | ||
| 1304 | - Next shared-client scheduling change: a continuously readable stream can defer | ||
| 1305 | wheel input and following FIFO messages until ready mode frames are drained; | ||
| 1306 | continuous output may postpone a safely selectable history refresh. A finite | ||
| 1307 | protocol ordering boundary is the trigger if this is encountered; do not drop | ||
| 1308 | input or silently copy stale rows. | ||
| 1309 | - Next application mouse/clipboard slice: Shift+drag must force native selection | ||
| 1310 | through release and modifier changes; ordinary application gestures use pane | ||
| 1311 | coordinates. Handle validated application clipboard writes. GUI paste remains | ||
| 1312 | separately deferred. | ||
| 1313 | - Next portability run: repeat real desktop clipboard, wheel and DPI checks on | ||
| 1314 | macOS. Linux success is not macOS coverage. | ||
| 1315 | |||
| 1316 | Final-source timing passed at frame p99 18.745 ms and sampled input upper bound | ||
| 1317 | 65.3 ms. The earlier misses remain evidence for renderer follow-up, not discarded | ||
| 1318 | attempts or a performance-improvement claim. The final recording is 27 seconds. | ||
| 1319 | The localhost page is http://127.0.0.1:18776/wheel-scrolling/ ; publishing is no | ||
| 1320 | longer a pending action under the clarified workflow. Owned recording/browser | ||
| 1321 | and compositor fixtures are stopped after validation; the local review server | ||
| 1322 | remains pending acceptance. | ||
| 1323 | |||
| 1324 | |||
| 1325 | ### Wheel selection feedback — 2026-09-06 | ||
| 1326 | |||
| 1327 | Hands-on feedback corrected the initial cancellation policy: a completed | ||
| 1328 | highlight must follow selected text through viewport scrolling, including | ||
| 1329 | scrolling away and back. The pump already distinguishes absolute source rows | ||
| 1330 | from the viewport origin. Removed the three viewport-only invalidations and | ||
| 1331 | made input invalidation explicit in the mailbox; no new production state, | ||
| 1332 | modules or dependencies were needed (four fewer production lines). | ||
| 1333 | |||
| 1334 | Luna implemented the pump change; Terra independently reviewed freshness and | ||
| 1335 | input/resize/reconnect guards. Root corrected the regression fixture's history | ||
| 1336 | setup and verified actual pixels and independent Wayland clipboard reads. | ||
| 1337 | Held selection keeps its original anchor, with pointer motion/release resolving | ||
| 1338 | the endpoint in the current viewport. This intentionally permits extending a | ||
| 1339 | selection through scrolled history. | ||
| 1340 | |||
| 1341 | The user requested hands-on review instead of a new demo recording for this | ||
| 1342 | feedback fix. The existing recording is retained and labelled as preceding this | ||
| 1343 | change. Demo acceptance remains pending. | ||
| 1344 | |||
| 1345 | Lesson for the next mouse slice: distinguish source validity from viewport | ||
| 1346 | position; reuse the existing absolute selection coordinates. Keep the required | ||
| 1347 | Shift+drag override in that slice's acceptance criteria. | ||
| 1348 | |||
| 1349 | Validation: client/native unit tests and the full native integration gate pass. | ||
| 1350 | Real NVIDIA Wayland wheel/selection checks pass all ten checkpoints at the | ||
| 1351 | retained scale transitions; the separate ten-checkpoint copy suite also passes, | ||
| 1352 | including stale output, delayed replies, resize and detach. Logs are retained as | ||
| 1353 | `dist/wheel-scrolling/selection-preserve-*.log`. The first unit attempt exposed | ||
| 1354 | an incomplete test setup after returning live; restoring history fixed the | ||
| 1355 | fixture, and the final run passed. The isolated test compositor was stopped. | ||
| 1356 | This bounded feedback fix makes no new performance or macOS claim. | ||
| 1357 | |||
| 1358 | Repository `make check` passed inside the full `make ci` attempt. That attempt | ||
| 1359 | stopped in the existing terminal-client `agent-nested` exit wait (10 seconds), | ||
| 1360 | after the preceding e2e groups passed. The targeted `10_agent` rerun passed all | ||
| 1361 | six scenarios without source changes. Retain the failure rather than claiming | ||
| 1362 | a fully green CI run; the later e2e groups did not run in this attempt. Next | ||
| 1363 | trigger for the agent-test owner: investigate if this exit-wait timeout recurs. | ||
| 1364 | |||
| 1365 | The remaining agent and throughput gates passed in the follow-up run. | ||
| 1366 | |||
| 1367 | |||
| 1368 | ### Active-output selection feedback — 2026-09-06 | ||
| 1369 | |||
| 1370 | The user isolated a misleading fullscreen symptom: Claude's counter paused in | ||
| 1371 | the smaller window, and selection failed as soon as it resumed. Root reproduced | ||
| 1372 | this with a real tmux counter and real Wayland pointer/clipboard. Geometry | ||
| 1373 | inspection found no fullscreen-specific hit defect. | ||
| 1374 | |||
| 1375 | The user's Ghostty reference led to a simpler policy: keep the live selected | ||
| 1376 | range through redraws and copy current text, even after that text is overwritten. | ||
| 1377 | Terra verified the pinned Ghostty selection/page ownership and reviewed the | ||
| 1378 | client validity predicate. Root implemented the pump-only change; the new source | ||
| 1379 | provenance flag preserves strict cached-history freshness after returning live. | ||
| 1380 | No per-cell tracking, duplicate extraction, new module, daemon change or wire | ||
| 1381 | extension was needed. Existing epoch, dimensions, modes, input, attachment and | ||
| 1382 | history-watermark guards remain. This supersedes prior retro statements that | ||
| 1383 | any selected-pane output must cancel copying. | ||
| 1384 | |||
| 1385 | Acceptance now includes a counter that demonstrably advances during held and | ||
| 1386 | released selection/copy, real tmux, windowed/fullscreen Wayland, and copying the | ||
| 1387 | current text after an overwrite. Evidence is `dist/wheel-scrolling/active-selection-*`. | ||
| 1388 | The first counter run reproduces the bug. A later full-fixture run exposed shell | ||
| 1389 | job-completion text overwriting the next specimen; the fixture now waits for its | ||
| 1390 | writer before repainting. User requested hands-on review, with no new recording. | ||
| 1391 | |||
| 1392 | Follow-up owner/trigger: the next history-identity slice must define scrolling, | ||
| 1393 | reflow and capped-history eviction identity before promising Ghostty's tracked | ||
| 1394 | pin behavior across the remote protocol. No atomic source snapshot is claimed. | ||
| 1395 | |||
| 1396 | Validation: client/native units and full native integration pass. Actual NVIDIA | ||
| 1397 | Wayland selection passes eleven checkpoints through real tmux, and wheel/history | ||
| 1398 | passes all ten checkpoints including scale transitions. Final focused review has | ||
| 1399 | no remaining finding. The owned isolated compositor is stopped; existing demo | ||
| 1400 | servers awaiting acceptance are preserved. No new recording, macOS verification | ||
| 1401 | or performance claim is made for this behavioral fix. | ||
| 1402 | |||
| 1403 | The final full `make ci` run passed, including all e2e, agent and throughput | ||
| 1404 | gates. The prior nested-agent timeout did not recur. Hands-on acceptance is | ||
| 1405 | still pending; source commit and tests do not imply user approval. | ||
| 1406 | |||
| 1407 | |||
| 1408 | ### Follow-up: moving text and tmux selection ownership | ||
| 1409 | |||
| 1410 | The user's two-client trial found two remaining gaps. First, retaining a range | ||
| 1411 | through redraws does not track the same line through terminal scrolling; the | ||
| 1412 | highlight must move with that line and subsequent copy must still name it. | ||
| 1413 | Second, foot's apparent tmux-owned drag can be visible in muxg because tmux paints | ||
| 1414 | it, whereas muxg's current local drag is not sent to tmux. Local desktop copy, | ||
| 1415 | tmux's paste buffer and shared selection rendering are distinct outcomes. | ||
| 1416 | |||
| 1417 | Added explicit acceptance scenarios to the text-selection plan: row-following | ||
| 1418 | belongs to the selection-identity slice; normal application drag, shared tmux | ||
| 1419 | selection, independent buffer/clipboard checks and required Shift+drag override | ||
| 1420 | belong to the application mouse slice. Neither gap is claimed fixed by the | ||
| 1421 | redraw-preservation commit. User acceptance of the overall feature is pending. | ||
| 1422 | |||
| 1423 | |||
| 1424 | ### Application mouse and clipboard writes — 2026-09-06 | ||
| 1425 | |||
| 1426 | The Ghostty/foot comparison identified separate owners: a normal drag belongs | ||
| 1427 | to an application that requests mouse reporting; Shift at press chooses native | ||
| 1428 | selection for the complete gesture. Alternate screen alone does not decide. | ||
| 1429 | The new controller captures the originating attachment and mode token; the pump | ||
| 1430 | owns reports, original-format cancellation and stale-event rejection. The shared | ||
| 1431 | wheel encoder now supplies all pointer formats. Application clipboard writes | ||
| 1432 | reuse ClientCore validation and bounded decoding, with one shared text predicate | ||
| 1433 | and SDL writer. No daemon, wire, dependency or new module was needed. The functional change | ||
| 1434 | adds 291 production Zig lines beyond the opening refactor (excluding embedded | ||
| 1435 | unit tests and build wiring): 134 for pump lifecycle/delivery, 88 for pane input | ||
| 1436 | policy, 48 for SDL adaptation/hooks, and 21 for clipboard decoding/validation. | ||
| 1437 | The growth buys the missing behavior while retaining existing owners. | ||
| 1438 | |||
| 1439 | Luna implemented the opening encoder and initial controller/decoding/tests; | ||
| 1440 | Terra independently reviewed the boundaries and built acceptance fixtures; root | ||
| 1441 | integrated and independently validated them. Review and compilation caught | ||
| 1442 | incorrect expected bytes, runtime enum/character typing, double-counted pixel | ||
| 1443 | origins, partial-cell bounds, button ownership and outside-coordinate handling. | ||
| 1444 | The opening cleanup commit preceded the complete formatter gate because a | ||
| 1445 | concurrent GUI edit entered that check. Next sprint coordinator action: freeze | ||
| 1446 | all touched source until the opening check exits, then commit; never infer a | ||
| 1447 | successful gate from completed unit output. The later integrated check passed. | ||
| 1448 | |||
| 1449 | Real NVIDIA Wayland checks verify direct application reports and both clipboard | ||
| 1450 | targets with independent wl-paste reads. A separate foot/muxg two-client tmux | ||
| 1451 | fixture verifies ordinary selection and the shared tmux paste buffer in both | ||
| 1452 | directions, plus Shift-local copying without changing tmux state. An attempted | ||
| 1453 | raw-program-inside-tmux cancellation check assumed the inner application's | ||
| 1454 | mouse-off would disable the outer terminal's reporting. That assumption is | ||
| 1455 | false; removed that redundant path and kept direct mode-cancellation and shared | ||
| 1456 | tmux selection as separate oracles. Failure logs remain with the final evidence | ||
| 1457 | under `dist/application-mouse/`. | ||
| 1458 | |||
| 1459 | The user requested a hands-on binary without a new recording. No review server | ||
| 1460 | or publication was created; previous pending demos remain untouched. Hands-on | ||
| 1461 | acceptance is still pending. Final delivery gate results and fixture teardown | ||
| 1462 | are recorded below. | ||
| 1463 | |||
| 1464 | Retained debt and next owners/triggers: | ||
| 1465 | |||
| 1466 | - Selection-identity slice: track the same text through terminal scrolling, | ||
| 1467 | reflow and history eviction. This sprint fixes tmux-owned selection sharing; | ||
| 1468 | it does not give native local selection terminal line identity. | ||
| 1469 | - Input owner: GUI paste and composition remain separate. Additional physical | ||
| 1470 | mouse buttons during a held gesture are ignored; add chorded gestures only | ||
| 1471 | when needed, preserving each original attachment and cancellation contract. | ||
| 1472 | - Platform owner: Linux/Wayland evidence does not establish macOS. Native primary | ||
| 1473 | selection uses the available SDL adapter; numbered and secondary OSC52 targets | ||
| 1474 | remain unsupported, and clipboard reads remain refused. | ||
| 1475 | - Transport owner: the existing wheel wait behind a continuously readable stream | ||
| 1476 | remains a separate liveness follow-up; no scheduler rewrite was added here. | ||
| 1477 | |||
| 1478 | Final validation: full CI passed all 117 e2e scenarios, agent and throughput; | ||
| 1479 | final required check and ReleaseSafe native-core/native units passed. Full native | ||
| 1480 | integration passed, followed by separate final NVIDIA Wayland direct mouse, | ||
| 1481 | shared foot/tmux, ten wheel/scale and eleven selection checkpoints. The owned | ||
| 1482 | compositor PID 49292 was stopped and verified; test rigs cleaned up their own | ||
| 1483 | processes. Previous pending demos and the user's staged main-worktree file remain | ||
| 1484 | untouched. | ||
| 1485 | |||
| 1486 | The native raw-output frame gate remains red: initial p99 20.597 ms against a | ||
| 1487 | 20 ms budget. A controlled comparison reproduced it on the prior wheel release | ||
| 1488 | (23.134 ms) and new release (21.374 ms). All had ongoing output, successful | ||
| 1489 | reopen and input-to-painted upper bounds below 61 ms. Presentation dominated; | ||
| 1490 | this does not establish a stable performance pass or an improvement. Keep | ||
| 1491 | `stress.log`, `stress-baseline.log`, `stress-comparison.log` and their JSON | ||
| 1492 | artifacts. Renderer owner follow-up: resolve the existing NVIDIA presentation | ||
| 1493 | budget variability using this same isolated fixture; do not widen the budget | ||
| 1494 | or rerun until green. The new mouse feature is handed off for functional review | ||
| 1495 | with this limitation explicit. | ||
| 1496 | |||
| 1497 | |||
| 1498 | Application-mouse demo follow-up: user explicitly requested recording and | ||
| 1499 | publication after the hands-on handoff. Created the maintained review page and | ||
| 1500 | a 32.4-second continuous actual GUI recording, with input automation disclosed. | ||
| 1501 | Independent page review caught primary-selection coverage described as part of | ||
| 1502 | the video; corrected it to separate automated coverage. Both clients in the | ||
| 1503 | recording are muxg panes; foot remains separate acceptance evidence. Published | ||
| 1504 | only `/application-mouse` on the existing private host, preserving the older | ||
| 1505 | route. HTTPS page and byte ranges verified; recording fixtures stopped. Server | ||
| 1506 | PID 99795 remains pending acceptance, with exact ownership and targeted teardown | ||
| 1507 | in `dist/application-mouse/server.json` and the sprint plan. | ||
| 1508 | |||
| 1509 | |||
| 1510 | Application mouse/clipboard product sign-off — 2026-09-06: the user accepted | ||
| 1511 | the published demo and explicitly signed off the product slice. This is recorded | ||
| 1512 | demo review, not an assertion of additional hands-on testing. Removed only its | ||
| 1513 | `/application-mouse` sharing route, verified ownership and stopped server PID | ||
| 1514 | 99795, and confirmed port 18777 closed. The existing text-selection route was | ||
| 1515 | preserved. Recording, page and test evidence remain; cleanup is recorded in | ||
| 1516 | `dist/application-mouse/accepted-demo-teardown.json`. The existing rendering | ||
| 1517 | budget issue and selection-identity/paste follow-ups remain open. No next sprint | ||
| 1518 | was started by this sign-off. | ||
| 1519 | |||
| 1520 | |||
| 1521 | ### Selection follows terminal output — 2026-09-06 | ||
| 1522 | |||
| 1523 | Completed native selections now follow their original terminal occurrence | ||
| 1524 | through primary history and vertical scroll regions. Ctrl+Shift+C resolves the | ||
| 1525 | tracked endpoints; a held copy uses its current range and release registers the | ||
| 1526 | final range. One selection request/reply now covers extract/start/copy/clear for | ||
| 1527 | native, TUI and browser callers. Daemon and clients must be rebuilt together; | ||
| 1528 | compatibility was explicitly excluded. | ||
| 1529 | |||
| 1530 | The daemon owns Ghostty pins and text extraction, the pump owns correlation and | ||
| 1531 | attachment lifetime, and the GUI receives position and grid together. Opening | ||
| 1532 | inspection needed no separate cleanup. Closing cleanup removed the parallel | ||
| 1533 | protocol and unused state. Independent Terra review approved these boundaries; | ||
| 1534 | Luna handled bounded implementation/fixtures, and root integrated, reviewed and | ||
| 1535 | tested actual behavior. Ghostty owns ordinary page movement and eviction. Its | ||
| 1536 | partial-region row-copy/rotation paths need explicit external-pin movement; | ||
| 1537 | REP now reuses the normal print hook rather than cancelling ordinary redraws. | ||
| 1538 | |||
| 1539 | Review fixed an extraction lifetime bug, pin-allocation cleanup, alternate-screen | ||
| 1540 | allocator reuse, and partial-region endpoint movement. Assertions that a pin | ||
| 1541 | still existed hid wrong coordinates: retained selections now assert both moved | ||
| 1542 | coordinates and extracted distinct text. Validate library behavior against the | ||
| 1543 | pinned implementation and compiler, not assumptions about APIs or privacy. | ||
| 1544 | Re-copy acceptance first replaces the clipboard with a sentinel; unchanged | ||
| 1545 | clipboard text alone proves nothing. Fixture header clicks clear selection, so | ||
| 1546 | neighbour output uses the owned PTY directly. Wait for visible Wayland motion | ||
| 1547 | before a separate FIFO key injection; neither channel orders the other. | ||
| 1548 | |||
| 1549 | Final CI passed all 117 e2e scenarios, 10 agent checks and throughput. Native | ||
| 1550 | units/integration and actual NVIDIA Wayland selection, wheel, scale, foot/tmux | ||
| 1551 | and live tmux counter checks passed. A prior CI run compiled while source was | ||
| 1552 | changing; coordinator action: hold the freeze through the entire CI command, | ||
| 1553 | including later agent/throughput builds, not only its check stage. Early failure | ||
| 1554 | logs are retained alongside the successful final runs. | ||
| 1555 | |||
| 1556 | The raw-output frame gate still misses its existing 20 ms budget: 20.716 ms p99. | ||
| 1557 | Output and reopen progressed; sampled input-to-painted upper bounds were at most | ||
| 1558 | 60.4 ms including polling. Do not claim a performance pass or rerun until green. | ||
| 1559 | The 25-second actual GUI demo and review page remain on localhost, pending user | ||
| 1560 | product acceptance. The owned compositor and fixture apps are stopped; stop only | ||
| 1561 | server PID 257482 after verifying ownership on acceptance. Preserve unrelated | ||
| 1562 | demos. Full evidence and teardown are in the selection-follow sprint plan. | ||
| 1563 | |||
| 1564 | Next owners/triggers: | ||
| 1565 | |||
| 1566 | - Renderer owner: resolve the retained NVIDIA presentation budget variability | ||
| 1567 | using the same isolated raw-output fixture; retain the budget and baseline. | ||
| 1568 | - Selection owner, when continuing this feature: held-drag movement, reflow, | ||
| 1569 | character edits, rectangular margins and ranges crossing scroll-region | ||
| 1570 | boundaries need explicit policies. They currently cancel conservatively; | ||
| 1571 | preserve existing clipboard contents and add coordinate-plus-text oracles. | ||
| 1572 | - GUI input owner: paste and composition remain separate slices; ligatures are | ||
| 1573 | still deferred. Platform owner: Linux evidence does not establish macOS. | ||
| 1574 | - Coordinator, on acceptance: record product review separately from architecture | ||
| 1575 | and tests, verify/stop this demo server, and only then begin an authorized slice. | ||
| 1576 | |||
| 1577 | |||
| 1578 | Selection-follow product sign-off — 2026-09-06: the user approved after reporting | ||
| 1579 | that drag selection remains highlighted when scrolling the wheel and discussing | ||
| 1580 | the held-drag limitation. Clarified viewport movement versus terminal output | ||
| 1581 | moving the underlying rows. Recorded that specific hands-on observation and | ||
| 1582 | approval without treating it as manual coverage of every test. Verified ownership | ||
| 1583 | and stopped localhost demo server PID 257482; port 18778 is closed. Its compositor | ||
| 1584 | and app fixtures had already stopped. Retained the page, 25-second recording and | ||
| 1585 | check evidence, preserved unrelated services, and left the known NVIDIA frame | ||
| 1586 | budget debt open. No next sprint was started. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -288,8 +288,9 @@ const mod_table = [_]ModSpec{ | |||
| 288 | // through. It sits beside webhub for the same reason — both are fronts | 288 | // through. It sits beside webhub for the same reason — both are fronts |
| 289 | // on client's Transport and `client.hosts`' grammar; neither imports the | 289 | // on client's Transport and `client.hosts`' grammar; neither imports the |
| 290 | // other; both resolve a spelling through `client.Target.fromSpec`. | 290 | // other; both resolve a spelling through `client.Target.fromSpec`. |
| 291 | // The interaction loop, the painter, the selector and the prediction | 291 | // The interaction loop, painter and prediction overlay are CHILD FILES of |
| 292 | // overlay are CHILD FILES of this root rather than modules of their own, | 292 | // this root rather than modules of their own. Selection policy is a public |
| 293 | // child of the client root so the native and terminal adapters share it, | ||
| 293 | // so nothing outside src/tui/ can name one: a second module claiming any | 294 | // so nothing outside src/tui/ can name one: a second module claiming any |
| 294 | // of those files is a file-in-multiple-modules compile error. `term` is | 295 | // of those files is a file-in-multiple-modules compile error. `term` is |
| 295 | // the children's as much as the root's: `term.replica` is the keyboard | 296 | // the children's as much as the root's: `term.replica` is the keyboard |
| @@ -1195,8 +1196,28 @@ pub fn build(b: *std.Build) void { | |||
| 1195 | native_theme_config.addArtifactArg(mux_exe); | 1196 | native_theme_config.addArtifactArg(mux_exe); |
| 1196 | native_theme_config.addArtifactArg(muxg_exe); | 1197 | native_theme_config.addArtifactArg(muxg_exe); |
| 1197 | native_theme_config.step.dependOn(&native_fonts.step); | 1198 | native_theme_config.step.dependOn(&native_fonts.step); |
| 1199 | const native_selection = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection.py" }); | ||
| 1200 | native_selection.addArtifactArg(mux_exe); | ||
| 1201 | native_selection.addArtifactArg(muxg_exe); | ||
| 1202 | native_selection.step.dependOn(&native_theme_config.step); | ||
| 1203 | const native_selection_follow = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection_follow.py" }); | ||
| 1204 | native_selection_follow.addArtifactArg(mux_exe); | ||
| 1205 | native_selection_follow.addArtifactArg(muxg_exe); | ||
| 1206 | native_selection_follow.step.dependOn(&native_selection.step); | ||
| 1207 | const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" }); | ||
| 1208 | native_wheel.addArtifactArg(mux_exe); | ||
| 1209 | native_wheel.addArtifactArg(muxg_exe); | ||
| 1210 | native_wheel.step.dependOn(&native_selection_follow.step); | ||
| 1198 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1211 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1199 | native_e2e_step.dependOn(&native_theme_config.step); | 1212 | const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); |
| 1213 | native_mouse.addArtifactArg(mux_exe); | ||
| 1214 | native_mouse.addArtifactArg(muxg_exe); | ||
| 1215 | native_mouse.step.dependOn(&native_wheel.step); | ||
| 1216 | const native_tmux_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_tmux_mouse.py" }); | ||
| 1217 | native_tmux_mouse.addArtifactArg(mux_exe); | ||
| 1218 | native_tmux_mouse.addArtifactArg(muxg_exe); | ||
| 1219 | native_tmux_mouse.step.dependOn(&native_mouse.step); | ||
| 1220 | native_e2e_step.dependOn(&native_tmux_mouse.step); | ||
| 1200 | 1221 | ||
| 1201 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | 1222 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale |
| 1202 | // Debug daemon gives misleading latency numbers under raw terminal output. | 1223 | // Debug daemon gives misleading latency numbers under raw terminal output. |
docs/component-ownership.md
| Old | New | ||
|---|---|---|---|
| @@ -10,7 +10,7 @@ affected owner. | |||
| 10 | | Area | Owns | Focused gate | | 10 | | Area | Owns | Focused gate | |
| 11 | | --- | --- | --- | | 11 | | --- | --- | --- | |
| 12 | | Daemon | Authoritative terminal state and session lifecycle in `src/server/`, daemon engine files `src/engine/engine.zig` and `delta.zig`, and `src/os/server_os*` | `make daemon-test` | | 12 | | Daemon | Authoritative terminal state and session lifecycle in `src/server/`, daemon engine files `src/engine/engine.zig` and `delta.zig`, and `src/os/server_os*` | `make daemon-test` | |
| 13 | | Native GUI | `src/gui/native_core.zig` and its window free children (`workspace.zig`, `runtime.zig`, `picker.zig`, `persistence.zig`, `interaction.zig`), plus `src/gui/native.zig`, `frame.zig`, `bench.zig`, `atlas.zig`, `font.zig`, `quads.zig`, `gl.zig`, and `src/cli/muxg.zig` | `make native-core-test` for policy; `make native` builds `muxg` and runs `native-test` | | 13 | | Native GUI | `src/gui/native_core.zig` and its window free children (`workspace.zig`, `runtime.zig`, `picker.zig`, `persistence.zig`, `interaction.zig`, `font_options.zig`, `config.zig`, `theme.zig`), plus `src/gui/native.zig`, `frame.zig`, `bench.zig`, `atlas.zig`, `font.zig`, `quads.zig`, `gl.zig`, and `src/cli/muxg.zig` | `make native-core-test` for policy; `make native` builds `muxg` and runs `native-test` | |
| 14 | | Shared wire/client | The `term` component (`src/engine/term.zig`, `protocol.zig`, `grid.zig`, `replica.zig`), reusable `src/client/` services, and `src/dial.zig`, `src/link.zig`, `src/quic.zig` | `make client-test`; use `make check` before handoff | | 14 | | Shared wire/client | The `term` component (`src/engine/term.zig`, `protocol.zig`, `grid.zig`, `replica.zig`), reusable `src/client/` services, and `src/dial.zig`, `src/link.zig`, `src/quic.zig` | `make client-test`; use `make check` before handoff | |
| 15 | 15 | ||
| 16 | The daemon is the backend; terminal UI (`src/tui/`, `src/cli/mux_main.zig`), | 16 | The daemon is the backend; terminal UI (`src/tui/`, `src/cli/mux_main.zig`), |
| @@ -29,7 +29,12 @@ dependencies it compiles. | |||
| 29 | 29 | ||
| 30 | The native GUI is split into two build components. `native_core` owns pane | 30 | The native GUI is split into two build components. `native_core` owns pane |
| 31 | identity, attachment lifetime, persistence, picker state, native workspace | 31 | identity, attachment lifetime, persistence, picker state, native workspace |
| 32 | layout, and GUI interaction policy. It imports only `client` and `term`. | 32 | layout, GUI interaction policy, font settings, configuration parsing, and theme |
| 33 | values/merging. | ||
| 34 | It imports only `client` and `term`. Appearance policy tests therefore run without | ||
| 35 | window or font libraries; the native root retains aliases for its callers. | ||
| 36 | `font_options.zig` owns point-size policy. CLI/config parsers delegate to it; | ||
| 37 | `font.zig` owns installed-face validation and rasterization through font libraries. | ||
| 33 | `native` owns the window and painter; `frame.zig` is the only GUI file that | 38 | `native` owns the window and painter; `frame.zig` is the only GUI file that |
| 34 | spells the window library. The native root reaches core through | 39 | spells the window library. The native root reaches core through |
| 35 | `@import("native_core")`, so core files have | 40 | `@import("native_core")`, so core files have |
docs/demos/native-application-mouse.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,43 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>Application mouse & clipboard</title> | ||
| 7 | <style> | ||
| 8 | :root { color-scheme: dark; --bg:#101318; --panel:#181d25; --ink:#edf2f7; --muted:#aab5c2; --accent:#7dd3fc; --line:#303947; } | ||
| 9 | * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 system-ui,sans-serif; } | ||
| 10 | main { max-width:1000px; margin:auto; padding:3rem 1.25rem 5rem; } h1 { margin:0 0 .5rem; font-size:clamp(2rem,5vw,3.5rem); line-height:1.05; } h2 { margin-top:2rem; font-size:1.25rem; color:var(--accent); } p { color:var(--muted); } .lede { font-size:1.15rem; max-width:70ch; } | ||
| 11 | .card { margin-top:2rem; padding:1rem; background:var(--panel); border:1px solid var(--line); border-radius:12px; } video { display:block; width:100%; max-height:70vh; background:#080a0d; border-radius:8px; } a { color:var(--accent); } ul { color:var(--muted); padding-left:1.25rem; } li+li { margin-top:.4rem; } code { color:var(--ink); } | ||
| 12 | .status { display:inline-block; padding:.2rem .55rem; border:1px solid #a87927; border-radius:999px; color:#ffd580; font-size:.85rem; } | ||
| 13 | </style> | ||
| 14 | </head> | ||
| 15 | <body> | ||
| 16 | <main> | ||
| 17 | <span class="status">Product signed off · 6 September 2026</span> | ||
| 18 | <h1>Application mouse & clipboard</h1> | ||
| 19 | <p class="lede">Native mux panes now forward application mouse gestures and desktop clipboard writes while keeping Shift-drag local for text selection.</p> | ||
| 20 | <section class="card"> | ||
| 21 | <video src="demo.mp4" poster="preview.png" controls playsinline preload="metadata"></video> | ||
| 22 | <p><a href="demo.mp4" download>Download the recording</a> · 32.4 seconds · 1100 × 700 · no audio</p> | ||
| 23 | </section> | ||
| 24 | <h2>What the recording shows</h2> | ||
| 25 | <ul> | ||
| 26 | <li>Normal drag forwarding in both directions to applications and tmux, across two mux sessions attached to the same tmux server.</li> | ||
| 27 | <li>Shift held at press keeps the gesture local through release.</li> | ||
| 28 | <li>An application OSC 52 write reaches the desktop clipboard, read independently with <code>wl-paste</code>.</li> | ||
| 29 | <li>Two separate mux daemon sessions attached to the same tmux server, checked with independent <code>wl-paste</code> and <code>tmux show-buffer</code> observations.</li> | ||
| 30 | </ul> | ||
| 31 | <h2>Evidence</h2> | ||
| 32 | <p>Source <code>03138bb</code>; no daemon update. Separate automated checks verify desktop and primary-selection writes (<code>c</code>, <code>p</code>/<code>s</code>). CI, 117 e2e cases, agent and throughput checks, native unit/e2e checks, and real NVIDIA Wayland validation passed. The existing 20 ms stress budget measured 20.6/21.4 ms; the previous wheel run measured 23.1 ms, so this page makes no stress improvement claim.</p> | ||
| 33 | <h2>Input disclosure</h2> | ||
| 34 | <p>Ordinary drags use genuine Wayland pointer input. The Shift modifier and pointer sequence use the ordinary SDL test hook with visible Wayland cursor motion. Fixture shell commands are entered through SDL keys.</p> | ||
| 35 | <h2>Limits</h2> | ||
| 36 | <ul> | ||
| 37 | <li>Local selection following text through scrolling and reflow remains open. Paste, IME, and ligatures remain separate work.</li> | ||
| 38 | <li>macOS behavior is unverified.</li> | ||
| 39 | <li>Recording metadata: continuous capture, no cuts, no audio, 5 fps, NVIDIA at 200% scale.</li> | ||
| 40 | </ul> | ||
| 41 | </main> | ||
| 42 | </body> | ||
| 43 | </html> | ||
docs/demos/native-selection-follow.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,46 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>Selection follow through terminal output</title> | ||
| 7 | <style> | ||
| 8 | :root { color-scheme:dark; --bg:#101318; --panel:#181d25; --ink:#edf2f7; --muted:#aab5c2; --accent:#7dd3fc; --line:#303947; } | ||
| 9 | * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 system-ui,sans-serif; } | ||
| 10 | main { max-width:1000px; margin:auto; padding:3rem 1.25rem 5rem; } h1 { margin:0 0 .5rem; font-size:clamp(2rem,5vw,3.5rem); line-height:1.05; } h2 { margin-top:2rem; font-size:1.25rem; color:var(--accent); } p { color:var(--muted); } .lede { font-size:1.15rem; max-width:70ch; } | ||
| 11 | .card { margin-top:2rem; padding:1rem; background:var(--panel); border:1px solid var(--line); border-radius:12px; } video { display:block; width:100%; max-height:70vh; background:#080a0d; border-radius:8px; } a { color:var(--accent); } ul { color:var(--muted); padding-left:1.25rem; } li+li { margin-top:.4rem; } code { color:var(--ink); } | ||
| 12 | .status { display:inline-block; padding:.2rem .55rem; border:1px solid #a87927; border-radius:999px; color:#ffd580; font-size:.85rem; } | ||
| 13 | </style> | ||
| 14 | </head> | ||
| 15 | <body> | ||
| 16 | <main> | ||
| 17 | <span class="status">Product approved · 6 September 2026</span> | ||
| 18 | <h1>Selection follows terminal output</h1> | ||
| 19 | <p class="lede">A completed selection keeps naming the same terminal occurrence when new output scrolls it upward. The recording uses a duplicate marker and its distinct successor so a stale coordinate or the other duplicate cannot pass the check.</p> | ||
| 20 | <section class="card"> | ||
| 21 | <video src="demo.mp4" poster="preview.png" controls playsinline preload="metadata"></video> | ||
| 22 | <p><a href="demo.mp4" download>Download the recording</a> · 25 seconds · 960 × 600 · Linux NVIDIA Wayland · no audio</p> | ||
| 23 | </section> | ||
| 24 | <h2>What the recording shows</h2> | ||
| 25 | <ul> | ||
| 26 | <li>A notes pane sits on the left, the source pane has a nonzero origin on the right, and a third live pane remains active.</li> | ||
| 27 | <li>An ordinary Wayland pointer drag selects <code>SOURCE-DUPLICATE</code> and its <code>SOURCE-AFTER-006</code> successor while another duplicate appears elsewhere.</li> | ||
| 28 | <li>Three source rows arrive at one-second intervals. The selected occurrence moves with the terminal output, and its original framebuffer position clears.</li> | ||
| 29 | <li>An idle pane writes a clipboard sentinel through OSC 52. Notes reads that sentinel with independent <code>wl-paste</code>; Ctrl+Shift+C in the source pane then restores the followed selection, read back visibly in notes.</li> | ||
| 30 | </ul> | ||
| 31 | <h2>Controls and input</h2> | ||
| 32 | <p>Ordinary drag selects locally when the application has not requested mouse reporting. Hold Shift at press to select locally inside a mouse-reporting application. Ctrl+Shift+C asks the daemon to copy the completed tracked selection. The video uses genuine Wayland pointer input with a visible cursor; fixture output is emitted through fixture-owned PTYs.</p> | ||
| 33 | <h2>Validation</h2> | ||
| 34 | <p>Native unit and integration gates passed. Real NVIDIA Wayland checks passed for selection, scrolling, scale transitions, shared foot/tmux mouse behavior, and live tmux counters in windowed and fullscreen modes. Web ABI: 798 passed, 0 failed. Full CI passed: 117 end-to-end scenarios, 10 agent checks and throughput. Architectural review has no open blocker; product acceptance was given on 6 September 2026.</p> | ||
| 35 | <p>Rebuild both the daemon and client: they share the new selection protocol, with one request/reply path and no compatibility fallback.</p> | ||
| 36 | <h2>Limits and known debt</h2> | ||
| 37 | <ul> | ||
| 38 | <li>Completed selections follow output. Moving the viewport with the wheel preserves the selection. New terminal output moving the underlying rows during a held drag still cancels it; copying during a held drag remains supported. Resize/reflow, screen/mode changes and reset clear selection. Character insertion/deletion, rectangular margins and a range crossing a partially scrolled region also clear it. Paste, IME and ligatures are deferred.</li> | ||
| 39 | <li>The alternate screen and mouse-reporting scenario belongs to separate automation and is not claimed by this video.</li> | ||
| 40 | <li>Evidence is Linux-only. macOS behavior remains unverified.</li> | ||
| 41 | <li>The NVIDIA raw-output frame gate remains red: p99 20.7 ms against 20 ms. Output continued and reopen succeeded; sampled input-to-painted time stayed at or below 60.4 ms (including 5 ms polling). Prior-sprint measurements also missed this budget; no performance improvement is claimed.</li> | ||
| 42 | <li>Recording metadata: continuous capture, no cuts, no audio, 5 fps at 200% scale. The recording is a continuous 25-second capture.</li> | ||
| 43 | </ul> | ||
| 44 | </main> | ||
| 45 | </body> | ||
| 46 | </html> | ||
docs/demos/native-text-selection.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,34 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <meta charset="utf-8"> | ||
| 4 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 5 | <title>mux · Text selection</title> | ||
| 6 | <style> | ||
| 7 | :root{color-scheme:dark;font-family:system-ui,sans-serif;background:#17191d;color:#e8e9ed} | ||
| 8 | *{box-sizing:border-box}body{max-width:960px;margin:auto;padding:36px 22px 64px;line-height:1.65} | ||
| 9 | h1{font-size:clamp(2rem,6vw,3.8rem);line-height:1.1;margin:12px 0 20px;letter-spacing:-.04em}h2{font-size:1.25rem;margin-top:32px} | ||
| 10 | a{color:#99ded6}p{max-width:75ch}.eyebrow{font-size:.8rem;letter-spacing:.13em;text-transform:uppercase;color:#a1b5b3} | ||
| 11 | video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px 0 8px;border:1px solid #393f46} | ||
| 12 | .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:28px 0}.card{padding:18px;border:1px solid #393f46;border-radius:10px}.card strong{display:block}.muted{color:#adb5bf}code{font-size:.9em;background:#272c32;padding:2px 5px;border-radius:4px}li{margin:8px 0}.status{color:#aadbd0} | ||
| 13 | @media(max-width:650px){.cards{grid-template-columns:1fr}body{padding-top:24px}} | ||
| 14 | </style> | ||
| 15 | <p class="eyebrow">mux native · GUI / TUI parity</p> | ||
| 16 | <h1>Select text.<br>Copy on release.</h1> | ||
| 17 | <p>Drag across visible terminal text and copy it to the desktop clipboard. The GUI uses the existing shared gesture model; the daemon extracts the text, preserving Unicode and the difference between wrapped lines and real newlines.</p> | ||
| 18 | <p class="status">Implemented · Automated checks passed · Demo acceptance pending</p> | ||
| 19 | <video controls playsinline preload="metadata" poster="preview.png"><source src="demo.mp4" type="video/mp4">Your browser can <a href="demo.mp4">download the recording</a>.</video> | ||
| 20 | <p class="muted">26.4 seconds, continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> | ||
| 21 | <div class="cards"><div class="card"><strong>One gesture model</strong>The terminal UI and native GUI share click, drag, direction and pane-confinement rules.</div><div class="card"><strong>Correct text ownership</strong>Copy requests use existing daemon extraction, including soft wraps and wide glyphs.</div><div class="card"><strong>Explicit cancellation</strong>Selection is cleared when observed output or geometry changes invalidate its coordinates.</div></div> | ||
| 22 | <h2>What to try</h2> | ||
| 23 | <ul><li>Drag across terminal text in either direction. Release to copy; the highlight remains until cleared. Ctrl+Shift+C also copies the current selection.</li><li>Select in another pane. A press focuses that pane, and the selection stays with its starting session.</li><li>A simple click clears the highlight without replacing the clipboard. Resizing, leaving the window or typing also clears selection. Ctrl+Shift+C with no selection does nothing; plain Ctrl+C still reaches the terminal.</li></ul> | ||
| 24 | <p>The recording copies during a held drag with Ctrl+Shift+C, then reads the desktop clipboard using <code>wl-paste --no-newline</code> in another pane. Pointer input comes through Wayland; the recorded shortcut uses SDL event injection. GUI paste is a later slice.</p> | ||
| 25 | <h2>Validation</h2> | ||
| 26 | <p id="validation">Full CI, native units and integration passed. Real Wayland pointer tests verify desktop clipboard text, delayed replies, cancellation, timeouts and surviving shells. Held selection and fresh copying pass at 100%, 150% and 200%; the retained DPI/resize gate also passed. Core policy tests run without GUI package metadata.</p> | ||
| 27 | <p>The copy shortcut is checked against a real foreground PTY process: Ctrl+Shift+C copies without SIGINT, including with no selection; plain Ctrl+C still delivers SIGINT. Adapter tests cover both sides of Ctrl and Shift.</p> | ||
| 28 | <p>Separately measured NVIDIA responsiveness before the shortcut follow-up: frame p99 <strong>19.267 ms</strong> (20 ms limit); sampled input-to-painted upper bound <strong>65.3 ms</strong> (250 ms limit, up to 5 ms polling overhead). Earlier unexplained frame-budget misses remain a renderer follow-up; this pass is not a speedup claim.</p> | ||
| 29 | <h2>Scope and limits</h2> | ||
| 30 | <p>This slice selects visible text. Wheel scrolling, edge autoscroll, word and line selection, rectangular selection, GUI paste and application mouse forwarding remain pending. Ligatures are deferred.</p> | ||
| 31 | <p>Verified with real Vim and less in the offscreen GUI: keyboard scrolling clears the GUI highlight but keeps text already copied. Scrolling before mouse release cancels that drag. Vim's own visual selection is separate.</p> | ||
| 32 | <p>Observed changes in the selected pane cancel copying. Copying a frozen historical screen while fresh output arrives would need a later protocol change. These checks cover Linux; macOS validation remains open.</p> | ||
| 33 | <p class="muted">Passing automated checks is separate from your demo approval. Feedback requested: selection feel, highlight readability and copy-on-release behavior.</p> | ||
| 34 | </html> | ||
docs/demos/native-wheel-scrolling.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,39 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <meta charset="utf-8"> | ||
| 4 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 5 | <title>mux · Wheel scrolling</title> | ||
| 6 | <style> | ||
| 7 | :root{color-scheme:dark;font-family:system-ui,sans-serif;background:#17191d;color:#e8e9ed} | ||
| 8 | *{box-sizing:border-box}body{max-width:960px;margin:auto;padding:36px 22px 64px;line-height:1.65} | ||
| 9 | h1{font-size:clamp(2rem,6vw,3.8rem);line-height:1.1;margin:12px 0 20px;letter-spacing:-.04em}h2{font-size:1.25rem;margin-top:32px} | ||
| 10 | a{color:#99ded6}p{max-width:75ch}.eyebrow{font-size:.8rem;letter-spacing:.13em;text-transform:uppercase;color:#a1b5b3} | ||
| 11 | video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px 0 8px;border:1px solid #393f46} | ||
| 12 | .cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:28px 0}.card{padding:18px;border:1px solid #393f46;border-radius:10px}.card strong{display:block}.muted{color:#adb5bf}code{font-size:.9em;background:#272c32;padding:2px 5px;border-radius:4px}li{margin:8px 0}.status{color:#aadbd0} | ||
| 13 | @media(max-width:650px){.cards{grid-template-columns:1fr}body{padding-top:24px}} | ||
| 14 | </style> | ||
| 15 | <p class="eyebrow">mux native · GUI / TUI parity</p> | ||
| 16 | <h1>Scroll the pane<br>under your pointer.</h1> | ||
| 17 | <p>Use the wheel to browse shell history, scroll alternate-screen applications such as less, and send wheel reports to applications that request mouse input. Each pane keeps its own scroll position and fractional movement. Keyboard focus stays where you left it.</p> | ||
| 18 | <p class="status">Implemented · Final checks passed · Demo acceptance pending</p> | ||
| 19 | <video controls playsinline preload="metadata" poster="preview.png"><source src="demo.mp4" type="video/mp4">Your browser can <a href="demo.mp4">download the recording</a>.</video> | ||
| 20 | <p class="muted">27-second continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> | ||
| 21 | <div class="cards"><div class="card"><strong>Shell history</strong>Three rows per notch. Scroll down to live output, or type to return immediately.</div><div class="card"><strong>Application input</strong>Alternate-screen arrows honor cursor-key mode; mouse-aware apps receive the encoding they request.</div><div class="card"><strong>History selection</strong>Drag to copy the displayed history rows. The highlight follows its text when you scroll, including away and back.</div></div> | ||
| 22 | <h2>What the recording shows</h2> | ||
| 23 | <p class="muted">The recording below predates the selection-preservation fix. The highlight also now survives live counter updates and redraws. These fixes are ready for hands-on review; no replacement recording was requested.</p> | ||
| 24 | <p>Three panes on two daemons. Scroll an unfocused shell pane, select a history row, read its text with a separate desktop clipboard client, return to live output, then scroll a real less process. Pointer motion, selection and wheel events go through the Wayland compositor. Setup commands use the ordinary SDL keyboard event path.</p> | ||
| 25 | <h2>Controls</h2> | ||
| 26 | <ul><li>Wheel over terminal content to scroll that pane without moving keyboard focus.</li><li>At a shell prompt, wheel up browses history and wheel down returns toward live output. Typing into that pane returns it to live.</li><li>Drag across displayed text and release to copy; Ctrl+Shift+C also copies the current selection.</li><li>Menus, headers, dividers and command mode consume wheel events. Horizontal wheel behavior remains outside this slice.</li></ul> | ||
| 27 | <h2>Validation</h2> | ||
| 28 | <p id="validation">Full CI, native integration, focused client/native tests and real offscreen/Wayland acceptance passed. Core policy tests also run without GUI library metadata.</p> | ||
| 29 | <p>Independent PTY readers check exact arrow sequences and X10, UTF-8, SGR, URXVT and SGR-pixel wheel reports. Pixel reports and real PTY sizes pass at 200%, 100%, 150% and 200%. Clipboard checks use a separate Wayland client. Deterministic wire tests cover delayed replies, same-origin cancellation, resize, resync, timeout and mode frames queued before wheel input.</p> | ||
| 30 | <h2>Hardware timing and earlier misses</h2> | ||
| 31 | <p>The final separate raw-output stress run passed: frame p99 <strong>18.745 ms</strong> against the 20 ms limit, and sampled input-to-painted response at most <strong>65.3 ms</strong> against 250 ms (5 ms polling). Earlier runs missed the frame limit. The old release also failed under the same isolated NVIDIA Wayland conditions at 60 Hz and 200% scale. These observations do not establish that wheel scrolling improves or worsens rendering performance; the final pass does not explain the earlier misses.</p> | ||
| 32 | <ul><li>Initial wheel build: frame p99 20.199 ms.</li><li>Previous release comparison: frame p99 21.449 ms.</li><li>Wheel build comparison: frame p99 22.244 ms.</li></ul> | ||
| 33 | <p>Most of the measured frame time was in draw/swap. Recordings and other GUI test fixtures were stopped during measurements. All failed logs and independent input-to-painted samples are retained with the sprint evidence. The renderer follow-up still owns the earlier unexplained timing misses and offscreen-growth artifacts.</p> | ||
| 34 | <h2>Design and limits</h2> | ||
| 35 | <p>The daemon still owns terminal parsing and history extraction. The native client reuses the existing protocol and keeps a separate history grid for display. One outstanding fetch is retained until its reply arrives; cancelled replies cannot replace a newer view. History refreshes as output arrives, keeping the same distance from live. While a refresh is pending, stale text cannot be copied.</p> | ||
| 36 | <p>Continuous output can delay a safe history refresh or a wheel waiting behind pending mode frames. The wire has no source-version precondition, so an atomic historical-frame copy during concurrent output is not promised. Tests cover Linux; macOS validation remains open.</p> | ||
| 37 | <p>Application click/drag forwarding and application clipboard requests are next. <strong>Shift+drag to force native selection is required for that slice.</strong> GUI paste, edge autoscroll, word/line/rectangular selection and ligatures remain deferred.</p> | ||
| 38 | <p class="muted">Passing checks is separate from demo approval. Feedback requested: scrolling speed, pane targeting and history selection. After acceptance, this demo's owned server will be stopped; its page and recording will be retained.</p> | ||
| 39 | </html> | ||
docs/native-sprint-workflow.md
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,8 @@ the [delivery plan](superpowers/plans/2026-09-05-native-tiling.md) breaks it int | |||
| 10 | packages. Keep one sprint active, ending in a working demo and explicit acceptance. | 10 | packages. Keep one sprint active, ending in a working demo and explicit acceptance. |
| 11 | 11 | ||
| 12 | 1. **Review and scope.** Read the previous sprint's actions in [RETRO.md](../RETRO.md). | 12 | 1. **Review and scope.** Read the previous sprint's actions in [RETRO.md](../RETRO.md). |
| 13 | Produce the reuse and ownership map required by the shared skill, using the | ||
| 14 | [component briefs](component-ownership.md) and actual code/callers as evidence. | ||
| 13 | Define the smallest useful deliverable, its acceptance scenarios, and what | 15 | Define the smallest useful deliverable, its acceptance scenarios, and what |
| 14 | belongs to later sprints. Use existing agreements for material scope and | 16 | belongs to later sprints. Use existing agreements for material scope and |
| 15 | destructive-action semantics; clarify only unresolved points before | 17 | destructive-action semantics; clarify only unresolved points before |
| @@ -43,10 +45,11 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 43 | 4. **Build a functional slice.** Establish the smallest model/interface contract, | 45 | 4. **Build a functional slice.** Establish the smallest model/interface contract, |
| 44 | then connect actual input, rendering, and daemon behavior. Keep changes small | 46 | then connect actual input, rendering, and daemon behavior. Keep changes small |
| 45 | enough to review. Avoid expanding into later sprint features. | 47 | enough to review. Avoid expanding into later sprint features. |
| 46 | 5. **Resolve concrete review findings.** The reviewer supplies a failing scenario, | 48 | 5. **Resolve concrete review findings.** Apply the shared skill's correctness and |
| 47 | consequence, and expected result directly to the implementer. The implementer | 49 | structural review criteria. The reviewer supplies a scenario or structural |
| 48 | returns a fix and evidence, or a reason the finding does not apply. Root | 50 | finding, consequence, and expected result directly to the implementer. The |
| 49 | adjudicates disagreement against requirements and observed behavior. Agreement | 51 | implementer returns a fix and evidence, or a reason the finding does not apply. |
| 52 | Root adjudicates disagreement against requirements and observed behavior. Agreement | ||
| 50 | is not a substitute for independent validation. | 53 | is not a substitute for independent validation. |
| 51 | 6. **Validate through real boundaries.** Drive ordinary SDL events into an isolated | 54 | 6. **Validate through real boundaries.** Drive ordinary SDL events into an isolated |
| 52 | GUI using `test/native_tiling.py`'s Rig. Compare actual framebuffer pixels and | 55 | GUI using `test/native_tiling.py`'s Rig. Compare actual framebuffer pixels and |
| @@ -108,7 +111,10 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 108 | make native-stress # Linux raw-output responsiveness | 111 | make native-stress # Linux raw-output responsiveness |
| 109 | ``` | 112 | ``` |
| 110 | 113 | ||
| 111 | `make ci` excludes native tests. Use the pinned compiler. | 114 | `make ci` excludes native tests. Use the pinned compiler. Do not overlap |
| 115 | other builds in the same checkout with its `make check` stage: `test/bans.sh` | ||
| 116 | temporarily plants invalid source files to verify the source rules. Wait for | ||
| 117 | that stage to finish before starting native builds or integration tests. | ||
| 112 | 118 | ||
| 113 | The focused `make native-core-test` gate compiles workspace, runtime, picker, | 119 | The focused `make native-core-test` gate compiles workspace, runtime, picker, |
| 114 | persistence and interaction without window/font libraries. `native-test` | 120 | persistence and interaction without window/font libraries. `native-test` |
| @@ -120,7 +126,7 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 120 | A sandbox denial is not a product failure: run required local socket/PTY checks with the appropriate | 126 | A sandbox denial is not a product failure: run required local socket/PTY checks with the appropriate |
| 121 | authorized permissions. Rerun affected gates after meaningful changes; do not | 127 | authorized permissions. Rerun affected gates after meaningful changes; do not |
| 122 | repeatedly run broad suites without a new reason. | 128 | repeatedly run broad suites without a new reason. |
| 123 | 9. **Record and share the actual demo.** Use Rig with `MUXG_VIDEODRIVER=wayland` | 129 | 9. **Record and serve the local demo.** Use Rig with `MUXG_VIDEODRIVER=wayland` |
| 124 | and the current compositor environment. Create fixtures, focus the owned GUI, | 130 | and the current compositor environment. Create fixtures, focus the owned GUI, |
| 125 | and drive the same user controls being delivered. Capture the actual compositor | 131 | and drive the same user controls being delivered. Capture the actual compositor |
| 126 | window continuously; on Sway, find its rectangle by PID with `swaymsg -t get_tree` | 132 | window continuously; on Sway, find its rectangle by PID with `swaymsg -t get_tree` |
| @@ -143,15 +149,11 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 143 | 149 | ||
| 144 | Keep the page usable on desktop and mobile. Retain its source beside the | 150 | Keep the page usable on desktop and mobile. Retain its source beside the |
| 145 | sprint records and its video outside temporary fixture directories. Verify | 151 | sprint records and its video outside temporary fixture directories. Verify |
| 146 | page loading, video metadata and seeking before sharing. Use the established | 152 | page loading, video metadata and seeking. Serve the page and its assets on |
| 147 | private sharing route within the user's authorized scope; preserve existing | 153 | localhost by default, using a loopback HTTP server with byte-range support. |
| 148 | pages and services. Give the user the webpage URL in the final handoff, with | 154 | Give the user the local webpage URL, with the direct video link on the page. |
| 149 | the direct video link available from the page. | 155 | Publishing is optional and happens only when explicitly requested; do not |
| 150 | 156 | ask about publishing during routine sprint delivery. Preserve unrelated services. | |
| 151 | The user has authorised sharing mux sprint review pages and their demo assets | ||
| 152 | privately on their own Tailscale so they can review from all their devices. | ||
| 153 | Treat that as standing authorization for this handoff; use the existing | ||
| 154 | private Serve host and preserve unrelated routes and services. | ||
| 155 | 157 | ||
| 156 | Build both demo binaries together in an isolated release prefix: | 158 | Build both demo binaries together in an isolated release prefix: |
| 157 | 159 | ||
| @@ -176,18 +178,9 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 176 | path. Cursor-warp commands can move the visible cursor without delivering | 178 | path. Cursor-warp commands can move the visible cursor without delivering |
| 177 | held-button motion. Verify actual divider movement and release, then check PTYs. | 179 | held-button motion. Verify actual divider movement and release, then check PTYs. |
| 178 | 180 | ||
| 179 | For a user on the same tailnet, serve only the demo page/video through a loopback | 181 | Verify playback and HTTP range responses. Retain the recording outside |
| 180 | HTTP server with byte-range support, then use Tailscale Serve: | 182 | temporary fixture directories and record the server's teardown command. |
| 181 | 183 | ||
| 182 | ```sh | ||
| 183 | tailscale serve status | ||
| 184 | tailscale serve --bg http://127.0.0.1:18765 | ||
| 185 | ``` | ||
| 186 | |||
| 187 | Check existing routes before changing them; preserve unrelated services. Use | ||
| 188 | the private HTTPS URL printed by Serve. Verify playback/HTTP range responses, | ||
| 189 | retain the recording outside temporary fixture directories, and record the | ||
| 190 | server's teardown command. Funnel and audio are unnecessary for this workflow. | ||
| 191 | 10. **Close honestly.** Inspect the final diff, commit validated work, and provide | 184 | 10. **Close honestly.** Inspect the final diff, commit validated work, and provide |
| 192 | the review webpage with the demo, controls, test results, and material limits. | 185 | the review webpage with the demo, controls, test results, and material limits. |
| 193 | User demo acceptance is | 186 | User demo acceptance is |
| @@ -195,8 +188,8 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 195 | or a hands-on trial, and capture any requested ergonomic adjustments separately. | 188 | or a hands-on trial, and capture any requested ergonomic adjustments separately. |
| 196 | Update the spec/plan status and RETRO.md with resolved findings and checkable | 189 | Update the spec/plan status and RETRO.md with resolved findings and checkable |
| 197 | next-sprint actions. Mark acceptance only when given; a recorded-demo approval | 190 | next-sprint actions. Mark acceptance only when given; a recorded-demo approval |
| 198 | does not imply hands-on feedback. Then begin the next authorized sprint with | 191 | does not imply hands-on feedback. On acceptance, stop that demo's owned |
| 199 | step 1. | 192 | server and remaining GUI, recorder or compositor fixtures; remove any sharing |
| 200 | 193 | route created at the user's request. Recheck process ownership and verify | |
| 201 | Sprint 2 established the recorded Sway/private Serve demo route. Sprint 3 adds the | 194 | teardown. Preserve unrelated services and retain the page, recording and |
| 202 | opening cleanup pass by user request; keep the closing cleanup as well. | 195 | validation evidence. Then begin the next authorized sprint with step 1. |
docs/skills/sprint-delivery/SKILL.md
| Old | New | ||
|---|---|---|---|
| @@ -41,28 +41,66 @@ commit IDs, or temporary service URLs in this skill. | |||
| 41 | 41 | ||
| 42 | ## Run a bounded sprint | 42 | ## Run a bounded sprint |
| 43 | 43 | ||
| 44 | 1. **Scope and acceptance.** Choose the smallest functional deliverable within | 44 | Planner, implementer and reviewer are responsibilities with required outputs. |
| 45 | the user's authorized plan. Define observable acceptance scenarios and carry | 45 | Assign them to people or agents using the pairing and fallback below; no separate |
| 46 | forward applicable retrospective actions. Clarify material unknowns early; | 46 | persona or additional agent is required for each step. Prefer less maintained |
| 47 | continue independent work while waiting. Existing authorization still applies. | 47 | code, clear ownership and small interfaces. Each new abstraction must earn its |
| 48 | place by removing duplication or simplifying dependencies. | ||
| 49 | |||
| 50 | 1. **Plan from existing code.** Choose the smallest functional deliverable within | ||
| 51 | the user's authorized plan. Before proposing new code, search relevant modules | ||
| 52 | and callers, verify what their APIs actually do, and identify reusable behavior, | ||
| 53 | duplicated rules and refactors that would simplify the slice. Record a short | ||
| 54 | **reuse and ownership map** in the plan: | ||
| 55 | |||
| 56 | | Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted | | ||
| 57 | | --- | --- | --- | --- | | ||
| 58 | |||
| 59 | Cite actual file/symbol evidence. If nothing suitable exists, record where you | ||
| 60 | searched. Explain why an existing owner cannot take any proposed new module's | ||
| 61 | responsibility. Keep domain rules with their owner; input, rendering, storage | ||
| 62 | and transport adapters should use those rules. Assess whether separating policy | ||
| 63 | from external dependencies would help this slice; use the smallest justified | ||
| 64 | boundary. Define observable acceptance scenarios and carry forward applicable | ||
| 65 | retrospective actions. Clarify material unknowns early and continue independent | ||
| 66 | work while waiting. Existing authorization still applies. | ||
| 48 | 2. **Opening cleanup.** Inspect the touched code for small refactors that help | 67 | 2. **Opening cleanup.** Inspect the touched code for small refactors that help |
| 49 | the slice. Review, validate, and commit these separately within existing | 68 | the slice. Review, validate, and commit these separately within existing |
| 50 | authorization. If the code is ready, say so; there is no cleanup quota. | 69 | authorization. If the code is ready, say so; there is no cleanup quota. |
| 51 | 3. **Delegate implementation and review.** Use the pairing below. Deliver through | 70 | 3. **Delegate implementation and review.** Use the pairing below. Deliver through |
| 52 | actual user interactions and system boundaries, then integrate the result. | 71 | actual user interactions and system boundaries. The implementer follows the |
| 53 | 4. **Closing cleanup.** Explicitly review duplication, obsolete paths, temporary | 72 | ownership map, reuses existing behavior and removes superseded paths. Report |
| 54 | scaffolding, and unnecessary state. Refactor what the slice exposed. Record | 73 | discoveries that change the plan and update the map before extending scope. |
| 55 | retained debt with its location, consequence, and next owner or trigger. | 74 | 4. **Review correctness and structure.** The reviewer checks behavior and failure |
| 75 | paths, duplicated rules, misplaced responsibilities, unnecessary state, | ||
| 76 | excessive dependencies, obsolete code and opportunities to delete code. Supply | ||
| 77 | concrete evidence and resolve findings through the exchange below. Review net | ||
| 78 | production-code and dependency changes; explain growth and its benefit rather | ||
| 79 | than treating line count as a quota. Complete closing cleanup and integrate the | ||
| 80 | result. Record retained debt with its location, consequence, and next owner or | ||
| 81 | trigger. "Architecture reviewed" alone is not evidence: identify the rule, | ||
| 82 | owner, dependency or removable path and its disposition. | ||
| 56 | 5. **Validate and demonstrate.** Run required repository gates and meaningful | 83 | 5. **Validate and demonstrate.** Run required repository gates and meaningful |
| 57 | checks for the change. Independently verify real behavior beyond agent | 84 | checks for the change. Independently verify real behavior beyond agent |
| 58 | agreement. Freeze source before final checks; rerun affected checks after | 85 | agreement. Prove claimed architectural boundaries with an appropriate check, |
| 59 | meaningful changes. Demonstrate the working result, including relevant failure | 86 | such as policy tests running without the external library or service that its |
| 60 | paths. A recording must show the actual application, not a slideshow. | 87 | adapter uses; moving files alone does not establish isolation. Freeze source |
| 88 | before final checks; rerun affected checks after meaningful changes. Demonstrate | ||
| 89 | the working result, including relevant failure paths. A recording must show | ||
| 90 | the actual application, not a slideshow. Serve the review page and assets on | ||
| 91 | localhost by default. Publish only when the user explicitly requests it; | ||
| 92 | do not prompt for optional publishing during routine delivery. | ||
| 61 | 6. **Close and hand off.** Commit validated work within existing authorization. | 93 | 6. **Close and hand off.** Commit validated work within existing authorization. |
| 62 | Update the spec/plan status and `RETRO.md`: results, lessons, retained debt, | 94 | Update the spec/plan and ownership map to match the delivered code. Record |
| 63 | and checkable next-sprint actions. Record demo acceptance only when given; | 95 | results, removed duplication/paths, justified retained debt, lessons, and |
| 64 | test success does not imply user acceptance. Begin the next sprint only when | 96 | checkable next-sprint actions in `RETRO.md`; give each open item an owner or |
| 65 | authorized, preserving any acceptance requirement in the agreed plan. | 97 | concrete trigger. Record demo acceptance only when given; test success does not |
| 98 | imply user acceptance. Once accepted, tear down that demo's sharing route and | ||
| 99 | owned servers/fixtures without asking again, unless the user requests continued | ||
| 100 | availability. Verify teardown and record it; retain the page source, recording | ||
| 101 | and validation evidence. Preserve unrelated services and demos awaiting review. | ||
| 102 | Begin the next sprint only when authorized, preserving | ||
| 103 | any acceptance requirement in the agreed plan. | ||
| 66 | 104 | ||
| 67 | ## Model and effort routing | 105 | ## Model and effort routing |
| 68 | 106 | ||
| @@ -103,9 +141,9 @@ above; it is not required for every slice. | |||
| 103 | constraints, acceptance checks, and its partner's name. They should read only | 141 | constraints, acceptance checks, and its partner's name. They should read only |
| 104 | necessary code. The main session coordinates, integrates, and independently | 142 | necessary code. The main session coordinates, integrates, and independently |
| 105 | validates; assign each implementation or investigation one owner. | 143 | validates; assign each implementation or investigation one owner. |
| 106 | - The reviewer sends concrete findings directly to the implementer: trigger, | 144 | - The reviewer sends concrete findings directly to the implementer: trigger or |
| 107 | consequence, and expected behavior. The implementer returns a fix and evidence | 145 | structural evidence, consequence, and expected result. The implementer returns |
| 108 | or a reason the finding does not apply. Continue focused exchanges until | 146 | a fix and evidence or a reason the finding does not apply. Continue focused exchanges until |
| 109 | findings are resolved; root adjudicates persistent disagreement against the | 147 | findings are resolved; root adjudicates persistent disagreement against the |
| 110 | spec and observations. Re-review changed parts and unresolved findings rather | 148 | spec and observations. Re-review changed parts and unresolved findings rather |
| 111 | than repeating a broad review each round. | 149 | than repeating a broad review each round. |
| @@ -124,5 +162,4 @@ separately from frame timing, and state polling overhead or other material limit | |||
| 124 | 162 | ||
| 125 | Keep platform-specific gates and demo mechanics in project documentation. In mux, | 163 | Keep platform-specific gates and demo mechanics in project documentation. In mux, |
| 126 | the documented native gates complement `make ci`; the raw-output gate builds both | 164 | the documented native gates complement `make ci`; the raw-output gate builds both |
| 127 | the daemon and GUI in release mode. Preserve existing services when sharing a | 165 | the daemon and GUI in release mode. Preserve existing services when serving a demo. |
| 128 | demo, and use only the sharing scope the user authorized. | ||
docs/superpowers/plans/2026-09-06-native-application-mouse.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,150 @@ | |||
| 1 | # Native application mouse and clipboard writes | ||
| 2 | |||
| 3 | Status: product signed off on 2026-09-06 after review of the published demo. | ||
| 4 | CI and functional gates pass; the existing frame-time budget miss remains an | ||
| 5 | open follow-up. No additional hands-on testing is claimed. | ||
| 6 | |||
| 7 | Goal: normal mouse gestures reach applications requesting mouse reporting, | ||
| 8 | including tmux, while Shift+drag remains a native selection. Application OSC 52 | ||
| 9 | writes reach the desktop clipboard through the existing daemon protocol. | ||
| 10 | The initial handoff was a hands-on binary; the user subsequently requested and | ||
| 11 | accepted a published recording. | ||
| 12 | |||
| 13 | ## Reuse and ownership map | ||
| 14 | |||
| 15 | | Behavior | Existing implementation | Owner | Change | | ||
| 16 | | --- | --- | --- | --- | | ||
| 17 | | Mouse wire formats | client/keymap.zig encodeWheel | shared client | Extract encodeMouse; retain wheel wrapper, delete duplicate encoding need | | ||
| 18 | | Negotiated modes, ordered transport | session_pump.zig routeWheel, ClientCore.terminal_modes | pump | Stamp gestures with connection/mode identity; encode at transport boundary | | ||
| 19 | | Pane hit testing and attachment lifetime | interaction.Controller, runtime.Runtime | native core | Latch gesture source and owner; clamp app coordinates to original pane | | ||
| 20 | | Local selection/extraction | client.selection.Drag, pump selection request | existing owners | Reuse unchanged for Shift override | | ||
| 21 | | Clipboard validation | client_core.validClipboard, term_event | shared client | Add bounded text decoding; retain TUI validation contract | | ||
| 22 | | Platform clipboard | frame.zig SDL_SetClipboardText | SDL adapter | One writer for local selection and decoded app effects; p/s use primary | | ||
| 23 | |||
| 24 | No new module, VT parser, daemon change, or wire format is needed. The GUI owns | ||
| 25 | pane geometry and gesture choice; the pump owns negotiated protocol and wire | ||
| 26 | lifetime. A press uses the latest received modes under the pump mutex. Pending | ||
| 27 | presses whose mode/connection identity changed before transmission are discarded; | ||
| 28 | an already transmitted press is released in its original format on cancellation. | ||
| 29 | Mouse ownership remains fixed through modifier changes and crossing another pane. | ||
| 30 | |||
| 31 | ## Acceptance | ||
| 32 | |||
| 33 | - Mouse off: existing local drag/copy remains. Alternate screen alone does not | ||
| 34 | grant applications mouse ownership. | ||
| 35 | - Modes 9/1000/1002/1003: press-only / press-release / held motion / hover; | ||
| 36 | negotiated X10, UTF8, SGR, URXVT and SGR pixels retain coordinate/modifier rules. | ||
| 37 | - Shift at press forces local selection for the entire gesture, with no app | ||
| 38 | press, motion or release. Changing Shift mid-gesture does not switch owner. | ||
| 39 | - Nonzero pane origin, high DPI, edge crossing and release all use original pane | ||
| 40 | coordinates. Modals/dividers retain priority; source replacement receives no | ||
| 41 | stale gesture. Focus loss, geometry or mode changes cancel held app buttons. | ||
| 42 | - Two clients attached to one tmux: ordinary drag in either client enters tmux | ||
| 43 | selection, visible in both. Inspect tmux buffer independently; Shift+drag in | ||
| 44 | muxg affects only the desktop selection, not tmux's buffer. | ||
| 45 | - Valid OSC52 c writes desktop clipboard; p/s write primary. Invalid base64, | ||
| 46 | NUL, invalid UTF8, empty/oversized and unsupported targets preserve clipboard. | ||
| 47 | OSC52 reads remain refused. Inspect clipboard independently of GUI state. | ||
| 48 | |||
| 49 | ## Validation and follow-ups | ||
| 50 | |||
| 51 | Use pinned Ghostty source and installed foot as behavioral references; no | ||
| 52 | Ghostty executable is installed. Keep exact protocol tests and actual PTY/GUI | ||
| 53 | checks, then repository CI and native gates. Preserve unrelated running demos. | ||
| 54 | |||
| 55 | Selection following text through terminal scroll/reflow/eviction remains the | ||
| 56 | next identity slice. GUI paste, IME, ligatures and OSC52 reads are outside this | ||
| 57 | slice. Existing continuous-output wheel scheduling debt remains separately | ||
| 58 | tracked; this change must not introduce unbounded input deferral. | ||
| 59 | |||
| 60 | ## Implementation and review evidence | ||
| 61 | |||
| 62 | Opening commit `93a94c8` extracts the encoder and updates its wheel caller. | ||
| 63 | The first check found a Zig runtime character type error and incorrect fixture | ||
| 64 | expectations; those were corrected. The subsequent opening check was interrupted | ||
| 65 | by concurrent interaction formatting edits, so it is not recorded as green. | ||
| 66 | The integrated required check subsequently passed inside CI. Keep opening work | ||
| 67 | frozen through the complete check before committing in future sprints. | ||
| 68 | |||
| 69 | The controller now retains a source attachment and pump token for an application | ||
| 70 | gesture; a local-held flag prevents a Shift gesture from turning into hover | ||
| 71 | reports when its highlight is invalidated. The pump owns cancellation generation, | ||
| 72 | last transmitted button and negotiated format. SDL supplies input and clipboard | ||
| 73 | IO. `mouseFormat` serves wheel and pointer encoding; `validClipboardText` is the | ||
| 74 | single text safety predicate for decoded app writes and local clipboard copies. | ||
| 75 | There are no new modules, dependencies, daemon changes or protocol fields. | ||
| 76 | |||
| 77 | Independent review corrected double-counted pixel origins, partial terminal | ||
| 78 | cells at pane edges, wrong-button release, attachment replacement, and local | ||
| 79 | outside-coordinate handling. Actual Wayland checks pass exact press/motion/ | ||
| 80 | release bytes, middle/right routing, mode-change cancellation, Shift ownership, | ||
| 81 | cross-pane clamping, and desktop/primary targets. The two-client foot/tmux check | ||
| 82 | passes shared selection and buffer changes in both directions, and local Shift | ||
| 83 | selection leaves tmux untouched. Evidence is under `dist/application-mouse/`. | ||
| 84 | |||
| 85 | Only one physical button gesture is captured at a time; additional button | ||
| 86 | presses are ignored until release. Chorded multi-button application input can be | ||
| 87 | a later input-owner extension when required. Numbered/secondary OSC52 targets | ||
| 88 | have no native platform adapter and are ignored. Clipboard writes from attached | ||
| 89 | panes are delivered regardless of focus, consistent with session-scoped effects; | ||
| 90 | focus never changes the originating selection request or its attachment. | ||
| 91 | |||
| 92 | The default offscreen native gate skips the foot test explicitly. Real Wayland | ||
| 93 | and NVIDIA evidence is separate; none of these checks establishes macOS behavior. | ||
| 94 | |||
| 95 | ## Final validation and handoff | ||
| 96 | |||
| 97 | - Full `make ci`: PASS, including required check, all 117 e2e scenarios, agent | ||
| 98 | and throughput. `ci.log` retains the complete output. | ||
| 99 | - Final `make check` after closing cleanup: PASS (`check-final.log`). | ||
| 100 | - ReleaseSafe native-core/native unit tests: PASS (`native-units-final.log`). | ||
| 101 | - Full `make native-e2e`: PASS (`native-e2e.log`); its offscreen foot skip is | ||
| 102 | explicit, rather than reported as real-client evidence. | ||
| 103 | - Final ReleaseSafe NVIDIA Wayland: direct mouse/clipboard, shared foot/tmux, | ||
| 104 | ten wheel/scale and eleven selection checkpoints PASS | ||
| 105 | (`final-wayland-{mouse,tmux_mouse,wheel,selection}.log`). | ||
| 106 | - `make native-stress`: frame budget FAIL. Initial p99 20.597 ms exceeded the | ||
| 107 | 20 ms limit. A controlled comparison also failed on the previous wheel | ||
| 108 | release (23.134 ms) and this release (21.374 ms); presentation dominated. | ||
| 109 | Input-to-painted observations stayed below 61 ms, including polling overhead, | ||
| 110 | while output continued and reopen/lifecycle checks succeeded. Retain all three | ||
| 111 | logs; no stable frame-budget pass or performance improvement is claimed. | ||
| 112 | |||
| 113 | The retained rendering-budget issue belongs to the renderer owner, with the | ||
| 114 | same isolated NVIDIA raw-output fixture as its next acceptance check. It is not | ||
| 115 | silently waived by this feature's passing functional tests. | ||
| 116 | |||
| 117 | Release binaries are in `dist/native-mouse-release/bin/`. Launch `muxg` there | ||
| 118 | with the usual connection arguments; no remote daemon update is required. | ||
| 119 | The owned compositor PID 49292 was stopped and verified (`cleanup.json`); | ||
| 120 | all test rigs closed their sessions/daemons. Existing pending demos remain. | ||
| 121 | This initial handoff preceded the recording and sign-off documented below. | ||
| 122 | |||
| 123 | ## Requested recording and publication | ||
| 124 | |||
| 125 | The user subsequently requested a demo and publication. A 32.4-second continuous | ||
| 126 | recording now demonstrates two mux sessions attached to one tmux server: shared | ||
| 127 | normal dragging in both directions, Shift-local copying, independent tmux buffer | ||
| 128 | and desktop clipboard reads, and an application OSC52 desktop write. Primary | ||
| 129 | selection remains separate automated coverage, not a claim about the recording. | ||
| 130 | |||
| 131 | The actual NVIDIA Wayland GUI was captured at 1100×700, 5 fps, without audio or | ||
| 132 | restart cuts. Ordinary drags use real Wayland pointer events; Shift and its | ||
| 133 | pointer sequence use the ordinary SDL hook with visible cursor motion. The | ||
| 134 | page discloses this. A mistyped base64 fixture in the first attempt produced the | ||
| 135 | wrong expected word; the corrected complete recording is the published asset. | ||
| 136 | |||
| 137 | Review page source: `docs/demos/native-application-mouse.html`. Retained assets: | ||
| 138 | `dist/application-mouse/demo/`. Private publication (explicitly requested): | ||
| 139 | https://charizard.folk-amberjack.ts.net/application-mouse/ . HTTPS page and video | ||
| 140 | byte-range seeking were verified (200/206). Review server PID 99795 listened on | ||
| 141 | 127.0.0.1:18777. `dist/application-mouse/server.json` records ownership and route. | ||
| 142 | |||
| 143 | Recording compositor PID 95465 and fixture processes were stopped after capture. | ||
| 144 | On 2026-09-06 the user gave product sign-off: "yep looks good to me. Product sign off". | ||
| 145 | Removed only `/application-mouse`, verified and stopped its server PID 99795, | ||
| 146 | and confirmed port 18777 no longer accepts connections. The existing | ||
| 147 | text-selection route was preserved. The recording, page and validation evidence | ||
| 148 | remain retained; `dist/application-mouse/accepted-demo-teardown.json` records the | ||
| 149 | verified cleanup. This closes the application mouse/clipboard slice; selection | ||
| 150 | identity, paste/IME and the frame-time budget remain separate follow-ups. | ||
docs/superpowers/plans/2026-09-06-native-selection-follow.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,134 @@ | |||
| 1 | # Native selection follows output | ||
| 2 | |||
| 3 | Status: implemented, functionally validated and architecturally reviewed. | ||
| 4 | Product accepted on 2026-09-06; existing NVIDIA frame-time debt remains open. | ||
| 5 | |||
| 6 | Goal: a completed local selection follows the same terminal text as output | ||
| 7 | scrolls. This includes Shift+drag in an application using mouse reporting. | ||
| 8 | The existing desktop clipboard stays unchanged until another copy; copying | ||
| 9 | again resolves the tracked text, rather than its former screen position. | ||
| 10 | |||
| 11 | ## Scope and ownership | ||
| 12 | |||
| 13 | | Behavior | Reuse | Owner | | ||
| 14 | | --- | --- | --- | | ||
| 15 | | Terminal identity | Pinned Ghostty Selection.track and PageList pins | Engine wrapper, one owned selection per attached server client | | ||
| 16 | | Text extraction | Engine.extractSelection and existing formatter | Engine | | ||
| 17 | | Correlation and compatibility | Framed protocol, server client slots | Shared protocol and server | | ||
| 18 | | Position and clipboard delivery | Session pump mutex, request tickets and attachment lifetime | Shared client | | ||
| 19 | | Drag and highlight | Existing selection.Drag and pane-relative hit testing | Native interaction | | ||
| 20 | |||
| 21 | No second terminal parser, text matching, or global Ghostty Screen.selection. | ||
| 22 | Opening inspection found no independent cleanup needed; the engine adapter | ||
| 23 | belongs to the feature. Source stays frozen throughout validation before commit. | ||
| 24 | |||
| 25 | The existing selection request/reply now carries a gesture ID, copy request ID, | ||
| 26 | terminal sequence and authoritative source token. Registering against a stale | ||
| 27 | source is refused. Source tokens must advance even when identical rows produce | ||
| 28 | no visual delta. Positions are delivered after the matching terminal frame; | ||
| 29 | the renderer hides an unmatched position until its corresponding state arrives. | ||
| 30 | Copy uses the daemon's tracked endpoints. The user explicitly excluded backward | ||
| 31 | compatibility: clients require the updated daemon. One action field selects | ||
| 32 | extract/start/copy/clear; TUI and browser extraction use the same exchange. | ||
| 33 | There is no parallel legacy request or capability fallback. | ||
| 34 | |||
| 35 | The released range becomes the durable selection. Ctrl+Shift+C during a held | ||
| 36 | drag still copies the current range; later copies and release register their | ||
| 37 | current endpoints. Output moving during a held drag | ||
| 38 | retains conservative cancellation; tracking an unfinished gesture is a later | ||
| 39 | slice. Resize/reflow, screen replacement, disconnection and discarded endpoints | ||
| 40 | invalidate selection and preserve the clipboard. GUI paste and ligatures remain | ||
| 41 | outside this slice. A remote daemon needs rebuilding alongside the client. | ||
| 42 | Character insertion/deletion and rectangular margins retire local selection; | ||
| 43 | a linear range crossing a partially scrolled region also retires rather than | ||
| 44 | claiming to represent unchanged text. | ||
| 45 | |||
| 46 | ## Acceptance | ||
| 47 | |||
| 48 | - Select a line in a pane with nonzero origin; append output until it moves up. | ||
| 49 | Its highlight follows it, and copying again returns the intended text. | ||
| 50 | - Exercise primary history and alternate-screen vertical scrolling, including | ||
| 51 | repeated identical rows and a scroll region. Position follows engine identity. | ||
| 52 | - Shift-local selection sends no application mouse gesture and follows output. | ||
| 53 | - Two client selections remain independent; another pane's output has no effect. | ||
| 54 | - Discarded endpoints, resize, screen switch and reconnect clear the highlight | ||
| 55 | without replacing the clipboard. Late requests cannot select unrelated text. | ||
| 56 | - Retain existing viewport-wheel selection, soft-wrap extraction and app mouse | ||
| 57 | behavior. Verify real GUI pixels and clipboard, plus engine/wire boundaries. | ||
| 58 | |||
| 59 | ## Delivery | ||
| 60 | |||
| 61 | Use the repository sprint gates and a localhost review page with an actual GUI | ||
| 62 | recording. Publishing is optional when requested. Product acceptance remains | ||
| 63 | separate from tests and architectural review. Carry forward the existing NVIDIA | ||
| 64 | frame-time budget miss; retain evidence rather than rerunning until green. | ||
| 65 | |||
| 66 | ## Integrated architecture and review | ||
| 67 | |||
| 68 | The engine adapter owns one pair of Ghostty tracked pins per attached client. | ||
| 69 | Ghostty owns page growth, history eviction, Unicode width and wrapping. Partial | ||
| 70 | vertical operations that copy/rotate row contents need explicit endpoint moves; | ||
| 71 | the adapter uses parsed actions and rebinds the existing pins after those moves. | ||
| 72 | REP delegates each repeated character to the existing print hook using Ghostty's | ||
| 73 | previous character. No client parses VT and no duplicate terminal state exists. | ||
| 74 | |||
| 75 | The server owns tracker teardown, source validation and copy extraction. The | ||
| 76 | pump owns queued/sent correlation, attachment cancellation and matching position | ||
| 77 | metadata to the displayed snapshot or history chunk. The renderer receives grid, | ||
| 78 | origin and position together. During a sequence gap it hides unmatched geometry | ||
| 79 | without destroying the gesture. Clipboard delivery remains separate from | ||
| 80 | unsolicited position updates. | ||
| 81 | |||
| 82 | Closing cleanup consolidated selection into one action-based wire exchange and | ||
| 83 | removed unused follow-state bookkeeping. Direct TUI/browser extraction reuses | ||
| 84 | the same codecs and formatter. Review resolved extraction lifetime, tracked-pin | ||
| 85 | allocation cleanup, alternate-screen allocator reuse, discarded-row tracking, | ||
| 86 | partial-region movement and held-copy regressions. The independent Terra review | ||
| 87 | has no remaining architectural blocker; final gate results remain separate. | ||
| 88 | |||
| 89 | ## Final evidence and handoff | ||
| 90 | |||
| 91 | - `dist/selection-follow/ci-final.log`: full CI passed, including check, | ||
| 92 | 117 e2e scenarios / 38 convergence points, 10 agent checks and throughput. | ||
| 93 | - `native-final-rerun.log`: ReleaseSafe native-core/native tests and the full | ||
| 94 | native integration chain passed. Its offscreen tmux skip is supplemented by | ||
| 95 | the separate actual Wayland fixture below. | ||
| 96 | - `wayland-final.log`: selection, follow-selection, wheel and real | ||
| 97 | 200%/100%/150%/200% scale checks passed with the matched release binaries. | ||
| 98 | - `wayland-tmux-final.log`: three shared foot/tmux mouse checkpoints passed. | ||
| 99 | A later counter assertion exposed ordering between real Wayland motion and | ||
| 100 | the FIFO key hook; the fixture now observes the extended highlight first. | ||
| 101 | `wayland-tmux-counter-final.log` passes held/released copying through tmux in | ||
| 102 | both windowed and fullscreen modes with that independent pixel observation. | ||
| 103 | - `stress-final.log` and retained `stress-result.json`: raw output continued | ||
| 104 | for 30.1 seconds (120.5 MB read), reopen succeeded, and 186 sampled input-to- | ||
| 105 | painted observations stayed at or below 60.4 ms including 5 ms polling. | ||
| 106 | The frame gate remains **red**: p99 20.576/20.716 ms against 20 ms. This is the | ||
| 107 | previously recorded NVIDIA presentation debt, not a performance pass. | ||
| 108 | - Earlier failing logs remain. They include an existing PTY exit timing flake, | ||
| 109 | actual implementation failures fixed before delivery, fixture mistakes, and | ||
| 110 | a CI agent compile caught during a source change. Final CI is a separate, | ||
| 111 | successful frozen-source run. No red result was overwritten as a pass. | ||
| 112 | |||
| 113 | Matched demo binaries were built with `make install | ||
| 114 | INSTDIR=dist/native-follow-release BINDIR=dist/native-follow-bin`; daemon and | ||
| 115 | GUI both use ReleaseSafe. The maintained page is | ||
| 116 | [the selection-follow review page](../../demos/native-selection-follow.html). | ||
| 117 | Its 25-second, 960×600 H.264 recording is continuous at 5 fps, no audio, on | ||
| 118 | NVIDIA Wayland at 200% scale. It shows a real pointer drag, three appended rows, | ||
| 119 | neighbour output and independently read clipboard replacement/recopy. | ||
| 120 | Video decoding, page loading and HTTP byte ranges were verified. | ||
| 121 | |||
| 122 | Product sign-off — 2026-09-06: the user approved after confirming that wheel | ||
| 123 | scrolling the viewport preserves the highlight. The held-drag limitation concerns | ||
| 124 | terminal output moving the underlying coordinate source, not merely looking at | ||
| 125 | other existing rows. This records the reported hands-on wheel behavior and | ||
| 126 | explicit approval, not hands-on verification of every automated scenario. | ||
| 127 | |||
| 128 | The former localhost review URL on port 18778 is retired. No publication was | ||
| 129 | created. Verified server PID 257482 named this worktree's | ||
| 130 | `dist/selection-follow/serve-page.py`, stopped it, and verified port 18778 closed. | ||
| 131 | `dist/selection-follow/accepted-demo-teardown.json` records the cleanup. | ||
| 132 | Recording and test rigs had already closed; owned compositor PID 141229 had | ||
| 133 | already been stopped. Page, recording and validation evidence remain; unrelated | ||
| 134 | services were preserved. No next sprint was started by this approval. | ||
docs/superpowers/plans/2026-09-06-native-text-selection.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,294 @@ | |||
| 1 | # Native parity — text selection | ||
| 2 | |||
| 3 | Status: implemented, independently reviewed and validated; actual GUI demo | ||
| 4 | recorded. User demo acceptance is pending. User selected text selection as the next | ||
| 5 | sprint on 2026-09-06; wheel scrolling remains unimplemented and deferred. | ||
| 6 | Worktree branch: `gui-text-selection` (existing worktree directory retained). | ||
| 7 | |||
| 8 | ## Goal and trial behavior | ||
| 9 | |||
| 10 | Drag across visible terminal text, see the selected cells, and copy the daemon's | ||
| 11 | text to the desktop clipboard on release or with Ctrl+Shift+C. Reuse the TUI's single-click versus | ||
| 12 | drag semantics and daemon extraction. A plain click continues to focus a pane. | ||
| 13 | The selection belongs to its starting pane; crossing another pane must never | ||
| 14 | select or copy that pane's text. A new press clears the previous highlight. | ||
| 15 | |||
| 16 | This slice covers visible text, copy on release and the copy shortcut. Wheel/history navigation, | ||
| 17 | edge autoscroll, double/triple click, rectangular selection, clipboard paste, | ||
| 18 | application mouse forwarding and ligatures remain separate work. The existing | ||
| 19 | GUI does not forward application mouse clicks; this slice preserves that scope. | ||
| 20 | |||
| 21 | ## Reuse and ownership map | ||
| 22 | |||
| 23 | | Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted | | ||
| 24 | | --- | --- | --- | --- | | ||
| 25 | | Click/drag distinction, direction, pane confinement, inclusive row spans | `src/tui/select.zig`: `Drag`, `Range.span`; `interact.zig` and `wallview.zig` callers | Shared `client.selection` | Move the pure module and its tests to `src/client/selection.zig`; delete the TUI source and update imports. No second drag state machine. | | ||
| 26 | | Text extraction, soft wraps, hard newlines, UTF-8/graphemes and wide cells | `Engine.extractSelection`, `Server.onSelectionReq`, protocol `SelectionReq`/`SelectionReply` | Daemon engine and existing wire | Reuse unchanged. Never reconstruct copied text from native grid cells. | | ||
| 27 | | Request ID correlation and reply validation | `ClientCore.beginSelection`/`receiveSelectionReply`; TUI `Core.requestSelection`/`selectionCopy` | Shared client core and session pump | Reuse core matching. Add bounded mailbox request/result ownership to `session_pump`; release borrowed frame text before the frame is freed by copying or transferring into owned storage. Do not import TUI `Core`. | | ||
| 28 | | Pane identity, lifetime and frozen displayed grid | `runtime.Live.capture`, `Runtime.accepts`, `workspace.Attachment` | Native runtime | Expose selection-relevant snapshot metadata alongside the grid through the existing boundary; associate requests with attachment and displayed coordinates. Avoid new unlocked reads of pump internals. | | ||
| 29 | | Pointer hit testing, modal routing and cancellation | `interaction.Controller.pointerDown`/`pointerMove`, `focusLost`, `updateGeometry` | Window-free native controller | Use shared drag state with native geometry; add release/copy intent and cancellation. Keep divider drag distinct from text drag. | | ||
| 30 | | Logical-to-physical input, mouse capture, clipboard IO | `frame.Events`, `physicalPoint`, `physicalSignedAxis`, existing pointer hooks | SDL frame adapter | Extend ordinary mouse routing and use SDL clipboard writing. SDL remains confined to frame. Test hooks observe actual clipboard state and inject existing SDL events. | | ||
| 31 | | Selection highlight | `quads.rowInstances`, existing themed cell colors | Native painter | Consume a supplied row span; select complete wide glyph cells and retain readable colors. Never mutate the authoritative grid for highlighting. | | ||
| 32 | | Copy shortcut from hands-on feedback | `frame.interactionKey`, `Controller.keyDown`, selection request in `pointerUp`, `Runtime.requestSelection` | SDL maps the chord; native controller owns copy intent | Extract the existing request action for both release and shortcut. Consume Ctrl+Shift+C even without selection; preserve plain Ctrl+C. No second text cache or clipboard implementation. | | ||
| 33 | |||
| 34 | The existing pure selection module already supplies the needed domain rule; a | ||
| 35 | new selection framework is unnecessary. Moving it to the shared client owner | ||
| 36 | allows both frontends to consume it without importing either window or terminal | ||
| 37 | UI libraries. The TUI's OSC 52 size cap is an adapter limit and must not become | ||
| 38 | the desktop clipboard's cap; the existing wire's 1 MiB bound still applies. | ||
| 39 | |||
| 40 | ## Observable acceptance | ||
| 41 | |||
| 42 | - Three panes across two real daemons, including off-origin panes: forward and | ||
| 43 | reverse drags highlight and copy only the starting pane. Headers, dividers, | ||
| 44 | blank padding and modal dialogs cannot start a terminal selection. | ||
| 45 | - A press/release in one cell changes focus without changing clipboard text. | ||
| 46 | Copy on release uses exact daemon text for soft wraps, hard newlines, trailing | ||
| 47 | spaces, non-ASCII text and wide-cell continuations. A selected wide glyph is | ||
| 48 | visibly complete. Other panes retain their text, PTYs and input behavior. | ||
| 49 | - Ctrl+Shift+C copies the current fresh range without clearing the highlight or | ||
| 50 | sending Ctrl+C to the application. With no selection it leaves the clipboard | ||
| 51 | and application untouched. Plain Ctrl+C still interrupts an actual foreground | ||
| 52 | PTY process. Repeat/release and modal routing retain their existing ownership. | ||
| 53 | - A new selection, cancellation, resize/DPI transition, focus loss, attachment | ||
| 54 | replacement/reconnect or detach cannot allow a delayed reply to overwrite the | ||
| 55 | clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior | ||
| 56 | contents intact; errors that need action get a bounded notice. | ||
| 57 | - Live redraws retain the selected range, including when selected text is | ||
| 58 | overwritten; copying extracts its current text, following Ghostty. Geometry, | ||
| 59 | screen/connection changes and history-watermark changes still invalidate it. | ||
| 60 | Cached-history selections retain exact source freshness. | ||
| 61 | - The painter highlights without changing replica cells. Core policy tests run | ||
| 62 | without GUI libraries. Real clipboard reads and actual framebuffer samples | ||
| 63 | are the independent integration oracles. | ||
| 64 | - Real NVIDIA Wayland drag/release at 200%, retained scale transitions, and | ||
| 65 | separate raw-output responsiveness gates. Linux evidence does not imply Mac | ||
| 66 | acceptance. | ||
| 67 | |||
| 68 | ## Delivery and retained actions | ||
| 69 | |||
| 70 | Luna owns opening shared-selection extraction and feature implementation; | ||
| 71 | Terra independently reviews correctness and structure. Root owns planning, | ||
| 72 | integration oracles, final gates, demonstration, documentation and commits. | ||
| 73 | Opening cleanup is reviewed, checked and committed before feature edits. | ||
| 74 | |||
| 75 | Follow `docs/native-sprint-workflow.md`, serializing builds against the source | ||
| 76 | ban probes in `make check`. Retain logs for full CI, native core/build/units, | ||
| 77 | native integration, NVIDIA scale and stress. Review production growth and remove | ||
| 78 | superseded paths before freezing source. Existing NVIDIA frame-time misses and | ||
| 79 | offscreen growth artifacts remain unresolved renderer follow-ups. | ||
| 80 | |||
| 81 | Assess the existing passive frame hooks as they are touched; add only observation | ||
| 82 | needed for clipboard evidence. Any broader hook extraction requires a concrete | ||
| 83 | benefit rather than an arbitrary file split. Reuse the existing Rig and recorder | ||
| 84 | assets with isolated XDG state and compositor addresses. | ||
| 85 | |||
| 86 | Deliver a localhost review page with achievements, actual GUI recording, | ||
| 87 | controls, checks and limitations. Preserve demos awaiting review; tear down accepted | ||
| 88 | demo routes and owned processes while retaining their artifacts. Record demo approval | ||
| 89 | only after the user gives it. | ||
| 90 | |||
| 91 | |||
| 92 | ## Delivered structure and closing review | ||
| 93 | |||
| 94 | `client.selection` is the single gesture model, consumed by both frontends. | ||
| 95 | `Drag.buttonHeld()` distinguishes capture lifetime from a retained highlight; | ||
| 96 | the native painter consumes the shared `Span` directly. `SelectionVersion` in | ||
| 97 | `session_pump` carries sequence, history, session epoch and a revision for | ||
| 98 | connection, geometry and mode changes. The runtime captures that version under | ||
| 99 | the same lock as the independent displayed grid. The controller retains the | ||
| 100 | press version and attachment; it never refreshes an old drag into a new frame. | ||
| 101 | |||
| 102 | The pump owns queued-request tickets, cancellation, the deadline and at most one | ||
| 103 | owned result. It validates the version before sending, while decoding a reply | ||
| 104 | and when transferring the result. Its single invalidation path clears semantic | ||
| 105 | correlation and owned text, including requests cancelled before mailbox service. | ||
| 106 | The SDL adapter writes clipboard text and reports failures; it preserves the | ||
| 107 | previous clipboard for empty or unrepresentable NUL-containing replies. | ||
| 108 | |||
| 109 | Review resolved history-coordinate overflow, neighbour-history subtraction, | ||
| 110 | held mouse capture, release-position resize regression, stale press versions, | ||
| 111 | mode/connection invalidation, unlocked timeout access, allocation-failure | ||
| 112 | ownership, logical-only geometry changes and empty-copy behavior. Root completed | ||
| 113 | the pump ownership cleanup and focused tests after the first integration pass; | ||
| 114 | Terra independently reviewed the final version. No new library or wire message | ||
| 115 | was added. Production growth connects the existing policy and wire to native | ||
| 116 | input, rendering and clipboard adapters; test code supplies independent failure | ||
| 117 | and ownership evidence. | ||
| 118 | |||
| 119 | The existing protocol has no source-sequence precondition at the daemon. | ||
| 120 | Observed client-side changes cancel selection conservatively; copying an atomic | ||
| 121 | historical frame is not claimed. A wire-level version precondition belongs to | ||
| 122 | any future sprint that strengthens this contract, across all frontend callers. | ||
| 123 | |||
| 124 | |||
| 125 | ## Validation and demo evidence | ||
| 126 | |||
| 127 | Final source passed full `make ci`, client/native units and native build, | ||
| 128 | `make native-e2e`, and native core tests with GUI package metadata hidden. | ||
| 129 | Real offscreen and NVIDIA Wayland selection fixtures cover three panes on two | ||
| 130 | daemons, daemon-extracted Unicode/wraps, actual framebuffer highlights, | ||
| 131 | clipboard reads, other-pane/selected-pane output, delayed requests, blank copies, | ||
| 132 | timeouts and detach without ending the shell. Wayland tests additionally use | ||
| 133 | real virtual-pointer input and held selection across 100/150/200% transitions. | ||
| 134 | The retained `native_scale.py` gate also passed. No Mac evidence is claimed. | ||
| 135 | |||
| 136 | `make native-stress` passed separately from recording and other GUI fixtures: | ||
| 137 | frame p99 19,267 us (20,000 limit), sampled input-to-painted upper bound 65.3 ms | ||
| 138 | (250 limit; up to 5 ms polling overhead). Earlier unexplained NVIDIA misses stay | ||
| 139 | open; this is one acceptance run, not a speedup claim. | ||
| 140 | |||
| 141 | Logs, fixture paths, paired release build evidence, helper source/protocol, | ||
| 142 | verified agent models/cumulative counters and recording scripts are retained in | ||
| 143 | `dist/text-selection/`. The maintained `test/wayland_pointer.py` adapts the | ||
| 144 | retained helper's line protocol; it requires one isolated headless Sway output. | ||
| 145 | Set `MUXG_TEST_POINTER` to the absolute `dist/text-selection/pointer` binary when | ||
| 146 | running `test/native_selection.py` on Wayland, and optionally set | ||
| 147 | `MUXG_TEST_SCALE_OUTPUT=HEADLESS-1` for its held-selection scale scenarios. | ||
| 148 | The helper sources, generated Wayland protocol and binary live with these | ||
| 149 | artifacts; compile `pointer.c` and `pointer-protocol.c` with wayland-client if | ||
| 150 | rebuilding the fixture. SDL-injected events remain the offscreen input path. | ||
| 151 | |||
| 152 | The first Wayland test exposed a harness limitation: injected SDL mouse events | ||
| 153 | cannot provide the input serial needed for compositor clipboard ownership. | ||
| 154 | The final Wayland gate uses real pointer events and waits for clipboard offers | ||
| 155 | to reach a separate `wl-paste` client. Initial failed logs are retained. The | ||
| 156 | first demo's cleanup was interrupted by the recorder's minimum-length check; | ||
| 157 | root stopped only its three owned GUI/daemon processes and fixed unconditional | ||
| 158 | fixture cleanup before the successful recording. | ||
| 159 | |||
| 160 | Review page source: `docs/demos/native-text-selection.html`. The 24.4-second | ||
| 161 | continuous NVIDIA 200% recording shows real drags and independent `wl-paste` | ||
| 162 | output, with no cuts or audio. Private handoff route: | ||
| 163 | https://charizard.folk-amberjack.ts.net/text-selection/ . The accepted appearance | ||
| 164 | routes were subsequently removed; their artifacts remain. | ||
| 165 | Route-specific teardown: `tailscale serve --https=443 --set-path /text-selection off`, | ||
| 166 | then stop only the page server identified by `dist/text-selection/server.json`. | ||
| 167 | |||
| 168 | |||
| 169 | Private page verification passed HTTPS/byte ranges, desktop/mobile layout, | ||
| 170 | playback and seeking; all three earlier appearance pages remain reachable. | ||
| 171 | Only the allowlisted page server remains (PID 3384909, port 18774); owned | ||
| 172 | browser/compositor fixtures are stopped. The final offscreen path was rechecked | ||
| 173 | after sharing the Wayland pointer adapter. The retained recording script now | ||
| 174 | explicitly focuses its owned window before its first pane click. | ||
| 175 | |||
| 176 | ## Hands-on copy-shortcut feedback | ||
| 177 | |||
| 178 | The user found Ctrl+Shift+C was forwarded as terminal Ctrl+C. The SDL adapter now | ||
| 179 | maps that chord to copy intent, and the native controller consumes it before | ||
| 180 | terminal input. Both mouse release and keyboard copy share the existing request | ||
| 181 | queue, ID and freshness handling; clipboard writing and daemon extraction are | ||
| 182 | unchanged. No new module, dependency or protocol was needed. Pure modifiers keep | ||
| 183 | the selection; plain Ctrl+C still goes to the PTY. Existing modal/prefix handling | ||
| 184 | retains precedence. Luna implemented, Terra reviewed, root added the real-PTY | ||
| 185 | oracle and removed a redundant forwarding helper. | ||
| 186 | |||
| 187 | The regression uses a real foreground Python process recording SIGINT to a file. | ||
| 188 | It verifies copying during a held drag, retaining the released highlight, copying | ||
| 189 | with no selection, and ordinary Ctrl+C delivery. Adapter tests cover left/right | ||
| 190 | modifier combinations and excluded Alt/GUI/Mode modifiers. Fixture output and | ||
| 191 | keyboard events wait for actual highlighted pixels where compositor input and | ||
| 192 | the passive hook arrive on separate connections. Early race failures are retained | ||
| 193 | beside final results in `dist/text-selection/copy-*`. | ||
| 194 | |||
| 195 | Full CI and native integration passed after the shortcut change, alongside | ||
| 196 | native/core units and offscreen/Wayland selection checks. The review GUI was | ||
| 197 | restarted with the same explicit appearance flags; the user confirmed copying. | ||
| 198 | Updated footage is 26.4 seconds and includes copy before release (SDL-injected | ||
| 199 | shortcut, real Wayland pointer, independent desktop clipboard read). Previous | ||
| 200 | performance measurements predate this keyboard follow-up. | ||
| 201 | |||
| 202 | The user's application-selection example was Claude Code, not Neovim. Follow-up | ||
| 203 | parity must distinguish alternate-screen mode from requested mouse reporting: | ||
| 204 | TUI `Core.forward` already uses `TermModes.appMouse()`. Native application mouse | ||
| 205 | forwarding and application clipboard effects are both missing; the pump currently | ||
| 206 | skips `clipboard_set`. GUI paste stays deferred by the user's clarification. | ||
| 207 | |||
| 208 | ## Required follow-up: application mouse and clipboard behavior | ||
| 209 | |||
| 210 | Shift+drag is part of the overall mouse feature, as confirmed by the user; it is | ||
| 211 | required when adding application mouse forwarding, not an optional later polish. | ||
| 212 | Wheel scrolling is implemented. The active follow-up is | ||
| 213 | [application mouse and clipboard writes](2026-09-06-native-application-mouse.md). | ||
| 214 | The following application mouse/clipboard slice must demonstrate: | ||
| 215 | |||
| 216 | - Normal click/drag reaches applications that request mouse reporting, using | ||
| 217 | coordinates relative to the starting pane. Alternate-screen mode alone does | ||
| 218 | not decide mouse ownership. | ||
| 219 | - Shift+drag forces mux's native selection and copy even when the application | ||
| 220 | requests mouse reporting. No part of that drag reaches the application. | ||
| 221 | - A gesture keeps its chosen owner through motion and release, including modifier | ||
| 222 | changes and crossing another pane; cancellation cannot leave an application | ||
| 223 | with a stuck button or copy text from a different pane. | ||
| 224 | - Attach foot and muxg to the same tmux session with mouse reporting enabled. | ||
| 225 | Normal drag in either client must enter tmux's selection/copy path and render | ||
| 226 | its selection in both clients; verify tmux's paste buffer independently. | ||
| 227 | Shift+drag in muxg remains a local highlight and desktop copy, with no tmux | ||
| 228 | mouse reports or paste-buffer mutation. Verify desktop clipboard separately | ||
| 229 | from tmux's buffer; a visible shared highlight alone does not prove copying. | ||
| 230 | - Application-issued clipboard writes reach the desktop through the existing | ||
| 231 | validated clipboard-effect path. Verify actual PTY mouse reports and an | ||
| 232 | independent clipboard reader, plus a real application's selection/copy flow. | ||
| 233 | |||
| 234 | Owner: the planner and implementer of the application mouse/clipboard slice under | ||
| 235 | parity issue `8b16e26b`. Include the Shift override in that slice's demo and tests | ||
| 236 | before declaring application mouse parity complete. GUI paste remains deferred. | ||
| 237 | |||
| 238 | |||
| 239 | ### Active-output feedback | ||
| 240 | |||
| 241 | A Claude counter running inside tmux made selection unusable. The apparent | ||
| 242 | fullscreen dependency was actually the counter pausing in the smaller window. | ||
| 243 | A real tmux counter reproduced the cancellation, with ordinary redraws advancing | ||
| 244 | the replica sequence during a held drag. | ||
| 245 | |||
| 246 | Following the user's Ghostty reference, live selection now retains its range | ||
| 247 | through redraws and copies current daemon text. `SelectionVersion.history` | ||
| 248 | records whether the selection originated in a cached history view; those | ||
| 249 | selections still require the exact source sequence, including after returning | ||
| 250 | live. This supersedes the initial policy of cancelling on every output frame. | ||
| 251 | The pump remains the sole validity owner at poll, release, send, reply and take. | ||
| 252 | No renderer, mouse-coordinate, daemon or protocol changes are needed. | ||
| 253 | |||
| 254 | Ghostty reference (our pinned revision): | ||
| 255 | [tracked selections](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Selection.zig) | ||
| 256 | and [screen-owned selection](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Screen.zig). | ||
| 257 | Its page pins belong to the daemon's terminal engine; the client has flattened | ||
| 258 | rows. Existing daemon extraction already uses Ghostty. We follow the redraw | ||
| 259 | behavior without adding another terminal or per-cell watcher to the client. | ||
| 260 | |||
| 261 | The current protocol cannot pin an atomic source snapshot for a remote copy, | ||
| 262 | or identify scrollback eviction when the history count remains at its cap. | ||
| 263 | Preserving selection across history eviction/reflow still needs an explicit | ||
| 264 | source-identity design; it is not claimed by this fix. Hands-on review replaces | ||
| 265 | a new recording at the user's request. | ||
| 266 | |||
| 267 | Validation: full `make ci`, client/native units, full native integration and | ||
| 268 | actual NVIDIA Wayland selection/wheel checks passed. The counter runs inside | ||
| 269 | real tmux for the Wayland selection check; eleven selection and ten wheel | ||
| 270 | checkpoints pass. Evidence: `dist/wheel-scrolling/active-selection-*.log`. | ||
| 271 | |||
| 272 | |||
| 273 | ### Outstanding feedback: selection must follow moving text | ||
| 274 | |||
| 275 | The user expects a selected line and its highlight to move together when output | ||
| 276 | scrolls that line upward. Commit `7897f7d` only preserves a live coordinate range | ||
| 277 | through redraws; it does not track row identity through scrolling inside an | ||
| 278 | application. Wheel movement of the client's viewport is already handled, but | ||
| 279 | terminal scroll-region movement, insert/delete lines, reflow and capped-history | ||
| 280 | eviction require a distinct identity/transform solution. This gap remains open. | ||
| 281 | |||
| 282 | Acceptance: select a unique line, let genuine terminal output scroll it upward, | ||
| 283 | and verify its highlight follows that same line. Text already copied must remain | ||
| 284 | unchanged; copying again must still address the originally selected line. Cover | ||
| 285 | normal screen/history and alternate-screen scroll regions, plus offscreen return, | ||
| 286 | without guessing identity by matching strings (duplicate lines are valid). | ||
| 287 | Owner: the next selection-tracking slice, using Ghostty's existing engine-owned | ||
| 288 | pins/scroll semantics and an explicit client/daemon contract where required. | ||
| 289 | |||
| 290 | The user's foot/tmux comparison also exposes the already planned application | ||
| 291 | mouse gap: normal muxg drag is currently local, so tmux cannot render that | ||
| 292 | selection to other clients. This does not imply that local clipboard extraction | ||
| 293 | failed. Cross-client visibility, tmux paste buffers, and the desktop clipboard | ||
| 294 | must be tested as separate outcomes in the application mouse slice above. | ||
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,12 @@ | |||
| 1 | # Native parity — wheel scrolling | 1 | # Native parity — wheel scrolling |
| 2 | 2 | ||
| 3 | Status: scoped after the appearance branch review and merge; implementation has | 3 | Status: implemented and independently reviewed; CI and functional checks passed. |
| 4 | not started. The user authorized a fresh worktree to begin closing GUI/TUI gaps. | 4 | Final NVIDIA timing check passed; earlier timing misses remain documented. |
| 5 | User acceptance is pending. | ||
| 6 | Authorized by the user's next-sprint continuation on 2026-09-06. | ||
| 7 | Opening architecture cleanup and visible-text selection are | ||
| 8 | implemented and validated; whole text-selection demo acceptance remains pending. | ||
| 9 | Continue in the existing parity worktree on branch `gui-text-selection`. | ||
| 5 | This is the first functional slice of git-collab issue `8b16e26b`. | 10 | This is the first functional slice of git-collab issue `8b16e26b`. |
| 6 | 11 | ||
| 7 | ## Sprint goal | 12 | ## Sprint goal |
| @@ -11,6 +16,15 @@ arrow input in an alternate-screen application without mouse reporting, and | |||
| 11 | terminal mouse reports when an application requests them. Match existing TUI | 16 | terminal mouse reports when an application requests them. Match existing TUI |
| 12 | behavior while keeping each pane independent. | 17 | behavior while keeping each pane independent. |
| 13 | 18 | ||
| 19 | ## Order within the overall mouse feature | ||
| 20 | |||
| 21 | After visible-text selection, this wheel slice is the next planned sprint item. | ||
| 22 | Application click/drag forwarding and application clipboard writes follow it. | ||
| 23 | That slice includes Shift+drag as the required native-selection override; see | ||
| 24 | the [application mouse acceptance criteria](2026-09-06-native-text-selection.md#required-follow-up-application-mouse-and-clipboard-behavior). | ||
| 25 | Wheel delivery alone does not complete the overall mouse feature. Preserve native | ||
| 26 | selection and copy throughout these slices; GUI paste remains deferred. | ||
| 27 | |||
| 14 | ## Observable acceptance | 28 | ## Observable acceptance |
| 15 | 29 | ||
| 16 | - Three panes on two daemons, including off-origin and unfocused panes. Wheel | 30 | - Three panes on two daemons, including off-origin and unfocused panes. Wheel |
| @@ -33,12 +47,36 @@ behavior while keeping each pane independent. | |||
| 33 | - Demonstrate real NVIDIA Wayland wheel input at 200% and across the retained | 47 | - Demonstrate real NVIDIA Wayland wheel input at 200% and across the retained |
| 34 | scale transitions; verify other panes, focus, PTY sizes and shell identities. | 48 | scale transitions; verify other panes, focus, PTY sizes and shell identities. |
| 35 | 49 | ||
| 36 | Selection, clipboard copy/paste, general app click/drag forwarding, ligatures, | 50 | Visible-text selection and clipboard copy are delivered by the separate |
| 37 | font fallback and terminal-wall retirement are later scope. Horizontal wheel | 51 | text-selection sprint; wheel work must preserve them. Clipboard paste, general |
| 52 | app click/drag forwarding, ligatures, font fallback and terminal-wall retirement | ||
| 53 | remain outside this wheel slice. Horizontal wheel | ||
| 38 | behavior is outside this initial vertical scrolling slice. | 54 | behavior is outside this initial vertical scrolling slice. |
| 39 | 55 | ||
| 40 | ## Opening architecture findings and ownership | 56 | ## Opening architecture findings and ownership |
| 41 | 57 | ||
| 58 | ### Reuse and ownership map | ||
| 59 | |||
| 60 | | Behavior or rule | Existing implementation and callers | Intended owner | Reuse, refactor or add; what can be deleted | | ||
| 61 | | --- | --- | --- | --- | | ||
| 62 | | SDL coordinates and event injection | `frame.physicalPoint`, ordinary pointer events and `parseHook` | SDL adapter | Extend with wheel events; no separate test-only scrolling path | | ||
| 63 | | Pane targeting, modal exclusion, selection cancellation | `interaction.Controller` pointer handlers and shared `client.selection.Drag` | Window-free controller | Reuse content hit testing and attachment checks; per-pane fractional remainder | | ||
| 64 | | History positioning and row decoding | `Replica.scrollStart`, protocol scrollback request/chunk codecs; TUI and wasm consumers | Shared client pump | Reuse existing wire and grid rows; add bounded request/result ownership without a second replica | | ||
| 65 | | Displayed view and selection coordinates | `runtime.Live.capture`, `SelectionVersion`, controller selection range | Pump/runtime snapshot boundary | Keep live replica independent; snapshot carries the displayed history origin and freshness | | ||
| 66 | | Alternate-screen arrow encoding | `keymap.encode`, TUI `sendAltScroll` | Shared client input policy | Reuse key encoding; keep frozen TUI behavior unchanged | | ||
| 67 | | Application wheel ownership and encoding | `TermModes.appMouse`, `mouse_modes`; TUI forwards existing terminal bytes | Pump and pure client encoder | Decide using admitted mode frames; SDL supplies semantic intent, so a terminal byte parser is unnecessary | | ||
| 68 | | Real input and independent acceptance | `SelectionRig`, `LifecycleRig`, `wayland_pointer.Pointer` | Retained integration fixtures | Extend established plural-pane fixtures and virtual pointer axis input | | ||
| 69 | |||
| 70 | The earlier font-settings cleanup already supplies the useful opening refactor; | ||
| 71 | the current inspection found no additional cleanup needed before this feature. | ||
| 72 | Application mouse encodings are checked against the | ||
| 73 | [xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking). | ||
| 74 | Selection stays attached to its source text when the viewport moves, including | ||
| 75 | scrolling offscreen and back or returning through live view. A held drag keeps | ||
| 76 | its original text anchor and extends using the current pane coordinates. Live redraws preserve the selected range; geometry, screen/connection and | ||
| 77 | history-watermark changes still invalidate it. Cached history keeps exact | ||
| 78 | source freshness. These are part of wheel acceptance, not later polish. | ||
| 79 | |||
| 42 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through | 80 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through |
| 43 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; | 81 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; |
| 44 | keep these decisions window-free so native-core tests can cover them. | 82 | keep these decisions window-free so native-core tests can cover them. |
| @@ -74,6 +112,154 @@ client/core tests, real plural-pane wheel integration, full CI/native gates and | |||
| 74 | isolated NVIDIA stress. Build both binaries in ReleaseSafe. Preserve earlier | 112 | isolated NVIDIA stress. Build both binaries in ReleaseSafe. Preserve earlier |
| 75 | NVIDIA timing failures and the offscreen-growth limitation; neither is waived. | 113 | NVIDIA timing failures and the offscreen-growth limitation; neither is waived. |
| 76 | 114 | ||
| 77 | Finish with a private Tailscale webpage containing achievements, an actual GUI | 115 | Finish with a localhost review webpage containing achievements, an actual GUI |
| 78 | video, controls, evidence and limits. Record user demo approval separately from | 116 | video, controls, evidence and limits. Record user demo approval separately from |
| 79 | checks. Reuse the existing page workflow while preserving previous routes. | 117 | checks. Publish only if explicitly requested; preserve unrelated services. |
| 118 | |||
| 119 | |||
| 120 | ## Architecture review before implementation | ||
| 121 | |||
| 122 | The user asked explicitly for duplication, hexagonal architecture opportunities | ||
| 123 | and refactoring. Review decisions: | ||
| 124 | |||
| 125 | - Give font settings one owner in `font_options.zig`; both CLI and config | ||
| 126 | parsing call its point-size policy. Entry adapters retain their diagnostic | ||
| 127 | mapping, and the font backend retains installed-face validation. Sharing a | ||
| 128 | helper from `config.zig` alone would remove duplication but put domain policy | ||
| 129 | in an input adapter. | ||
| 130 | - Move font settings and config/theme source ownership to `native_core`, with public aliases in | ||
| 131 | `native`. The renderer consumes theme values; window/font libraries are not | ||
| 132 | needed to test config parsing, precedence and theme derivation. | ||
| 133 | - Preserve the existing SDL → controller → runtime → session-pump boundary. | ||
| 134 | Add semantic wheel intent at the transport mailbox and interpret it using | ||
| 135 | the pump-owned terminal modes, after consuming available frames. Reading modes | ||
| 136 | in the GUI and queueing pre-encoded input could race an incoming mode change. | ||
| 137 | Verify the ordering with real mode frames and independently observed PTY bytes. | ||
| 138 | - Keep live/history snapshots behind the pump/runtime boundary. Wheel work should | ||
| 139 | narrow snapshot access rather than introduce new GUI reads of pump internals; | ||
| 140 | request correlation and view ownership belong in its implementation contract. | ||
| 141 | - Do not copy the TUI interaction core or mouse byte parser into the GUI. Reuse | ||
| 142 | protocol/replica behavior and share only pure semantic encoding when an actual | ||
| 143 | second caller needs it. The frozen wall keeps its behavior. | ||
| 144 | - Extend the existing test input adapter with a real SDL wheel event. A separate | ||
| 145 | test-only scroll path would leave actual event routing untested. Broader hook | ||
| 146 | extraction is a later package unless this slice exposes a concrete need. | ||
| 147 | |||
| 148 | Config and theme line readers look similar but intentionally differ: unknown | ||
| 149 | config keys are fatal, unknown theme keys warn, missing config is allowed, and a | ||
| 150 | missing selected theme is fatal. Their shared color/palette parsing is already | ||
| 151 | centralized. A generic parser framework or filesystem interface is not justified | ||
| 152 | by the current callers; retain the existing pure parse/merge functions and small | ||
| 153 | file-loading adapters. | ||
| 154 | |||
| 155 | |||
| 156 | ### Opening cleanup evidence | ||
| 157 | |||
| 158 | `font_options.zig` now owns the point-size parser and validity rule. Config and | ||
| 159 | CLI adapters preserve their existing errors; installed-face checks remain with | ||
| 160 | the font backend. The native core owns font settings, config and theme, and the | ||
| 161 | native rendering module retains public aliases. Removed the redundant empty | ||
| 162 | family check after the text parser had already rejected empty values. | ||
| 163 | |||
| 164 | Independent review found no behavior/API issue; it caught and removed a stale | ||
| 165 | point-size comment left above palette parsing. The module split is checked by | ||
| 166 | actual dependency probes: with `PKG_CONFIG_LIBDIR` set to an empty directory and | ||
| 167 | `PKG_CONFIG_PATH` empty, `native-core-test` passed all 29 tests, while the full | ||
| 168 | native build failed on missing SDL3 metadata as expected. This is a build | ||
| 169 | boundary check, not merely a directory rename. | ||
| 170 | |||
| 171 | Logs are in `dist/wheel-opening-cleanup/`. Native build, native unit/core tests | ||
| 172 | and formatting passed. Full CI (check, terminal integration, agent, throughput) | ||
| 173 | and final native integration passed with no gate changes. The first native integration launch overlapped CI's temporary | ||
| 174 | source-ban probe and stopped before compiling; the retry started after the | ||
| 175 | probe was removed. The original failed log is retained, and the workflow now | ||
| 176 | calls out this build-serialization requirement. | ||
| 177 | |||
| 178 | This refactor preserves appearance behavior and uses the approved appearance | ||
| 179 | recordings as its visual baseline; no new visible feature or performance claim | ||
| 180 | is made. The following functional wheel delivery supplies its own real-input | ||
| 181 | recording and local review page. | ||
| 182 | |||
| 183 | ## Wheel implementation and closing review | ||
| 184 | |||
| 185 | `frame` translates SDL wheel coordinates and modifiers into controller input. | ||
| 186 | The controller excludes non-content regions and modes, retains fractional | ||
| 187 | notches by pane attachment, and passes semantic intent through `Runtime.wheel`. | ||
| 188 | The pump drains available mode frames before interpreting queued wheel input. | ||
| 189 | Existing keyboard/resize work before a deferred wheel can run between bounded | ||
| 190 | receive batches; the wheel and following messages retain FIFO order. | ||
| 191 | |||
| 192 | Shell scrolling uses the existing scrollback request and grid decoder. The pump | ||
| 193 | owns one optional history grid and one outstanding request; its cancelled | ||
| 194 | request remains a tombstone until the reply is drained. New transport attachment | ||
| 195 | clears that tombstone, but same-wire resync retains it. A two-second timeout | ||
| 196 | reconnects rather than risking correlation with a later reply. View origin and | ||
| 197 | grid are captured under the same lock; all selection hit/paint coordinates use | ||
| 198 | that origin. The live replica remains untouched by history decoding. | ||
| 199 | |||
| 200 | The selected distance from live stays constant as output arrives. Output or mode | ||
| 201 | changes invalidate copying and schedule a new history fetch. The previous view | ||
| 202 | can remain painted while a reply is pending, but cannot be copied as if current. | ||
| 203 | This handles ring pruning even when the wire's history-row count remains flat. | ||
| 204 | Returning to live, resizing, reconnecting and replacing an attachment invalidate | ||
| 205 | the old view. As before, the wire does not provide an atomic source-version | ||
| 206 | precondition at the daemon. | ||
| 207 | |||
| 208 | Pure wheel encoding lives in `keymap`, reusing its arrow encoder and Zig's UTF-8 | ||
| 209 | encoder. SGR pixels, SGR cells, URXVT, UTF-8 and legacy X10 are supported with | ||
| 210 | pane-relative coordinates. No runtime dependency, protocol message or terminal | ||
| 211 | wall feature was added. Closing cleanup removed the hand-written UTF-8 helper | ||
| 212 | and the old duplicated receive-loop body. Independent review checked ownership, | ||
| 213 | request cancellation, pending input-buffer transfer, coordinate bounds and | ||
| 214 | same-wire resync. The existing QUIC batching regression caught a receive-loop | ||
| 215 | change that delayed mailbox work; the final implementation preserves its | ||
| 216 | 64-frame batches and passes that regression. | ||
| 217 | |||
| 218 | A continuously readable stream can delay a wheel waiting for its pending mode | ||
| 219 | frames and the input ordered after that wheel. A continuously changing history | ||
| 220 | can likewise postpone a safe selectable refresh. The next shared-client | ||
| 221 | scheduling change owns investigating a finite protocol ordering boundary if | ||
| 222 | this is encountered; dropping input or guessing a newer mode is not the fix. | ||
| 223 | |||
| 224 | |||
| 225 | ### Delivery evidence | ||
| 226 | |||
| 227 | Final CI, client/native tests, full native integration, real Wayland wheel and | ||
| 228 | clipboard acceptance, and desktop/mobile local video playback/seek checks passed. | ||
| 229 | The final isolated NVIDIA run passed with frame p99 18.745 ms and sampled | ||
| 230 | input-to-painted upper bound 65.3 ms (5 ms polling). Earlier current/baseline | ||
| 231 | runs missed the frame gate; retained evidence does not establish the cause. | ||
| 232 | The continuous recording is 27 seconds with real Wayland pointer/wheel input, | ||
| 233 | three panes on two daemons, a separate clipboard reader and real less. | ||
| 234 | |||
| 235 | Local review page: http://127.0.0.1:18776/wheel-scrolling/ . The loopback server | ||
| 236 | is recorded in `dist/wheel-scrolling/server.json`; stop it after acceptance. | ||
| 237 | No remote publication is required or pending. Publishing is opt-in at the user's | ||
| 238 | request. Sources, recording and all validation logs remain in the worktree. | ||
| 239 | |||
| 240 | |||
| 241 | ### Hands-on feedback: keep the highlight while scrolling | ||
| 242 | |||
| 243 | The user expects a completed selection to remain attached to its source text, | ||
| 244 | including scrolling it offscreen and back. `Pump` now preserves selection | ||
| 245 | freshness through local viewport requests, history replies and return-to-live. | ||
| 246 | Input explicitly invalidates selection at the mailbox boundary; terminal output, | ||
| 247 | modes, geometry and attachment changes retain their existing guards. Existing | ||
| 248 | absolute selection rows and `Live.view_origin` already provide correct painting | ||
| 249 | and hit testing, so no new production state or abstraction is needed. | ||
| 250 | |||
| 251 | A held drag retains its original anchor; subsequent motion or release resolves | ||
| 252 | its endpoint against the current viewport. The real Wayland regression covers | ||
| 253 | both a release on the moved source row and extending across newly visible rows. | ||
| 254 | The user requested hands-on review instead of a new demo recording for this fix. | ||
| 255 | |||
| 256 | Client/native units, the full native integration suite and real NVIDIA Wayland | ||
| 257 | wheel/copy regressions passed. The repository check gate passed; full CI stopped | ||
| 258 | on an existing nested-agent exit timeout, which passed on targeted rerun without | ||
| 259 | changes. Full-run CI success is therefore not claimed for this feedback commit. | ||
| 260 | Evidence: `dist/wheel-scrolling/selection-preserve-*.log`; details in `RETRO.md`. | ||
| 261 | |||
| 262 | |||
| 263 | Active-output follow-up: live selection now survives counter/redraw frames, | ||
| 264 | following Ghostty; a cached-history origin remains strict even after returning | ||
| 265 | live. See the text-selection plan's active-output entry for semantics and limits. | ||
src/cli/muxg.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,9 +13,7 @@ const hosts = client.hosts; | |||
| 13 | const PointSize = struct { | 13 | const PointSize = struct { |
| 14 | value: f64, | 14 | value: f64, |
| 15 | pub fn parseCLI(text: []const u8) !PointSize { | 15 | pub fn parseCLI(text: []const u8) !PointSize { |
| 16 | const value = std.fmt.parseFloat(f64, text) catch return error.Invalid; | 16 | return .{ .value = native.font_options.parsePointSize(text) catch return error.Invalid }; |
| 17 | if (!std.math.isFinite(value) or value < 1 or value > 192) return error.Invalid; | ||
| 18 | return .{ .value = value }; | ||
| 19 | } | 17 | } |
| 20 | }; | 18 | }; |
| 21 | const Color = struct { | 19 | const Color = struct { |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -33,6 +33,7 @@ pub const layoutfile = @import("layoutfile.zig"); | |||
| 33 | pub const keymap = @import("keymap.zig"); | 33 | pub const keymap = @import("keymap.zig"); |
| 34 | pub const askpass = @import("askpass.zig"); | 34 | pub const askpass = @import("askpass.zig"); |
| 35 | pub const core = @import("client_core.zig"); | 35 | pub const core = @import("client_core.zig"); |
| 36 | pub const selection = @import("selection.zig"); | ||
| 36 | pub const discovery = @import("discovery.zig"); | 37 | pub const discovery = @import("discovery.zig"); |
| 37 | pub const resolver = @import("resolver.zig"); | 38 | pub const resolver = @import("resolver.zig"); |
| 38 | const open_wait = @import("open_wait.zig"); | 39 | const open_wait = @import("open_wait.zig"); |
src/client/client_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,9 +1,9 @@ | |||
| 1 | //! What a client does with a daemon frame besides paint it: the terminal | 1 | //! What a client does with a daemon frame besides paint it: the terminal |
| 2 | //! modes a session set, a clipboard write, a bell, the answer to a selection | 2 | //! modes a session set, a clipboard write, a bell, the answer to a selection |
| 3 | //! request. One decoder with no transport and no terminal under it, so the | 3 | //! request. One decoder with no transport and no terminal under it, so the |
| 4 | //! CLI client and the browser core read the same frame the same way, and | 4 | //! CLI client and the browser core read the same frame the same way. Receive |
| 5 | //! `pending_selection_id` is the whole of its state. Every result BORROWS | 5 | //! results borrow the payload; the optional clipboard text adapter explicitly |
| 6 | //! the payload; nothing here allocates. | 6 | //! allocates an owned decoded copy. |
| 7 | const std = @import("std"); | 7 | const std = @import("std"); |
| 8 | const proto = @import("term").protocol; | 8 | const proto = @import("term").protocol; |
| 9 | 9 | ||
| @@ -12,6 +12,26 @@ pub const ClipboardSet = struct { | |||
| 12 | target: u8, | 12 | target: u8, |
| 13 | base64: []const u8, | 13 | base64: []const u8, |
| 14 | }; | 14 | }; |
| 15 | pub const ClipboardText = struct { primary: bool, text: []u8 }; | ||
| 16 | |||
| 17 | /// Text clipboard adapters must not silently truncate NUL or invalid UTF-8. | ||
| 18 | pub fn validClipboardText(text: []const u8) bool { | ||
| 19 | return text.len != 0 and std.unicode.utf8ValidateSlice(text) and std.mem.indexOfScalar(u8, text, 0) == null; | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Decode a supported clipboard target into owned, validated text. Targets | ||
| 23 | /// addressed to numbered buffers or the secondary selection are ignored. | ||
| 24 | pub fn decodeClipboard(alloc: std.mem.Allocator, target: u8, base64: []const u8) !?ClipboardText { | ||
| 25 | if (target != 'c' and target != 'p' and target != 's') return null; | ||
| 26 | if (!validClipboard(target, base64)) return error.InvalidClipboard; | ||
| 27 | const size = std.base64.standard.Decoder.calcSizeForSlice(base64) catch return error.InvalidClipboard; | ||
| 28 | if (size == 0) return error.InvalidClipboard; | ||
| 29 | const text = try alloc.alloc(u8, size); | ||
| 30 | errdefer alloc.free(text); | ||
| 31 | std.base64.standard.Decoder.decode(text, base64) catch return error.InvalidClipboard; | ||
| 32 | if (!validClipboardText(text)) return error.InvalidClipboard; | ||
| 33 | return .{ .primary = target == 'p' or target == 's', .text = text }; | ||
| 34 | } | ||
| 15 | 35 | ||
| 16 | pub const State = union(enum) { | 36 | pub const State = union(enum) { |
| 17 | terminal_modes: proto.TermModes, | 37 | terminal_modes: proto.TermModes, |
| @@ -165,6 +185,21 @@ test "client core accepts clipboard and bell events" { | |||
| 165 | } | 185 | } |
| 166 | } | 186 | } |
| 167 | 187 | ||
| 188 | test "decodeClipboard decodes supported targets and rejects unsafe text" { | ||
| 189 | const a = std.testing.allocator; | ||
| 190 | const decoded = (try decodeClipboard(a, 'c', "aMOp")) orelse return error.ExpectedClipboard; | ||
| 191 | defer a.free(decoded.text); | ||
| 192 | try std.testing.expect(!decoded.primary); | ||
| 193 | try std.testing.expectEqualStrings("hé", decoded.text); | ||
| 194 | const primary = (try decodeClipboard(a, 's', "aGk=")) orelse return error.ExpectedClipboard; | ||
| 195 | defer a.free(primary.text); | ||
| 196 | try std.testing.expect(primary.primary); | ||
| 197 | try std.testing.expect((try decodeClipboard(a, 'q', "aGk=")) == null); | ||
| 198 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "aGk")); | ||
| 199 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "AA==")); | ||
| 200 | try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "//8=")); | ||
| 201 | } | ||
| 202 | |||
| 168 | test "client core accepts every clipboard target boundary" { | 203 | test "client core accepts every clipboard target boundary" { |
| 169 | var core = ClientCore{}; | 204 | var core = ClientCore{}; |
| 170 | const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' }; | 205 | const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' }; |
| @@ -277,12 +312,13 @@ test "client core begins selection with exact request bytes" { | |||
| 277 | }; | 312 | }; |
| 278 | 313 | ||
| 279 | const encoded = core.beginSelection(req); | 314 | const encoded = core.beginSelection(req); |
| 280 | try std.testing.expectEqualSlices(u8, &.{ | 315 | const expected = [_]u8{ |
| 281 | 0x12, 0x34, 0x56, 0x78, | 316 | 0x12, 0x34, 0x56, 0x78, |
| 282 | 0x11, 0x22, 0x33, 0x44, | 317 | 0x11, 0x22, 0x33, 0x44, |
| 283 | 0x55, 0x66, 0x77, 0x88, | 318 | 0x55, 0x66, 0x77, 0x88, |
| 284 | 0x99, 0xaa, 0xbb, 0xcc, | 319 | 0x99, 0xaa, 0xbb, 0xcc, |
| 285 | }, &encoded); | 320 | } ++ ([_]u8{0} ** 21); |
| 321 | try std.testing.expectEqualSlices(u8, &expected, &encoded); | ||
| 286 | try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); | 322 | try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); |
| 287 | try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); | 323 | try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); |
| 288 | } | 324 | } |
| @@ -294,8 +330,15 @@ test "client core ignores stale selection reply then accepts matching reply once | |||
| 294 | .anchor = .{ .row = 1, .col = 2 }, | 330 | .anchor = .{ .row = 1, .col = 2 }, |
| 295 | .active = .{ .row = 3, .col = 4 }, | 331 | .active = .{ .row = 3, .col = 4 }, |
| 296 | }); | 332 | }); |
| 297 | const stale = [_]u8{ 21, 0, 0, 0, 0, 0, 0, 0, 0, 'n', 'o' }; | 333 | var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); |
| 298 | const matching = [_]u8{ 22, 0, 0, 0, 0, 8, 0, 0, 0, 'o', 'k' }; | 334 | std.mem.writeInt(u32, stale[0..4], 21, .little); |
| 335 | stale[proto.selection_reply_prefix_len..][0] = 'n'; | ||
| 336 | stale[proto.selection_reply_prefix_len..][1] = 'o'; | ||
| 337 | var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); | ||
| 338 | std.mem.writeInt(u32, matching[0..4], 22, .little); | ||
| 339 | std.mem.writeInt(u32, matching[5..9], 8, .little); | ||
| 340 | matching[proto.selection_reply_prefix_len..][0] = 'o'; | ||
| 341 | matching[proto.selection_reply_prefix_len..][1] = 'k'; | ||
| 299 | 342 | ||
| 300 | try expectIgnored(core.receive(.selection_reply, &stale)); | 343 | try expectIgnored(core.receive(.selection_reply, &stale)); |
| 301 | try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); | 344 | try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); |
| @@ -317,9 +360,15 @@ test "client core latest selection begin replaces the older pending id" { | |||
| 317 | .active = .{ .row = 4, .col = 5 }, | 360 | .active = .{ .row = 4, .col = 5 }, |
| 318 | }); | 361 | }); |
| 319 | 362 | ||
| 320 | try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 0, 0, 0, 0, 'x' })); | 363 | var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 364 | std.mem.writeInt(u32, stale[0..4], 7, .little); | ||
| 365 | stale[proto.selection_reply_prefix_len] = 'x'; | ||
| 366 | try expectIgnored(core.receive(.selection_reply, &stale)); | ||
| 321 | try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); | 367 | try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); |
| 322 | try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 0, 0, 0, 0, 'y' }), 8, .ok, 0, "y"); | 368 | var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 369 | std.mem.writeInt(u32, matching[0..4], 8, .little); | ||
| 370 | matching[proto.selection_reply_prefix_len] = 'y'; | ||
| 371 | try expectSelection(core.receive(.selection_reply, &matching), 8, .ok, 0, "y"); | ||
| 323 | } | 372 | } |
| 324 | 373 | ||
| 325 | test "client core malformed matching selection reply preserves pending request" { | 374 | test "client core malformed matching selection reply preserves pending request" { |
| @@ -330,9 +379,15 @@ test "client core malformed matching selection reply preserves pending request" | |||
| 330 | .active = .{ .row = 0, .col = 0 }, | 379 | .active = .{ .row = 0, .col = 0 }, |
| 331 | }); | 380 | }); |
| 332 | 381 | ||
| 333 | try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); | 382 | var malformed = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 383 | std.mem.writeInt(u32, malformed[0..4], 9, .little); | ||
| 384 | malformed[4] = @intFromEnum(proto.SelectionStatus.invalid); | ||
| 385 | malformed[proto.selection_reply_prefix_len] = 'x'; | ||
| 386 | try expectIgnored(core.receive(.selection_reply, &malformed)); | ||
| 334 | try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); | 387 | try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); |
| 335 | try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0, 0, 0, 0, 0 }), 9, .ok, 0, ""); | 388 | var matching = [_]u8{0} ** proto.selection_reply_prefix_len; |
| 389 | std.mem.writeInt(u32, matching[0..4], 9, .little); | ||
| 390 | try expectSelection(core.receive(.selection_reply, &matching), 9, .ok, 0, ""); | ||
| 336 | } | 391 | } |
| 337 | 392 | ||
| 338 | test "client core delivers every matching non-ok selection status with empty text" { | 393 | test "client core delivers every matching non-ok selection status with empty text" { |
| @@ -345,8 +400,9 @@ test "client core delivers every matching non-ok selection status with empty tex | |||
| 345 | .anchor = .{ .row = 0, .col = 0 }, | 400 | .anchor = .{ .row = 0, .col = 0 }, |
| 346 | .active = .{ .row = 0, .col = 0 }, | 401 | .active = .{ .row = 0, .col = 0 }, |
| 347 | }); | 402 | }); |
| 348 | var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status), 0, 0, 0, 0 }; | 403 | var payload = [_]u8{0} ** proto.selection_reply_prefix_len; |
| 349 | std.mem.writeInt(u32, payload[0..4], id, .little); | 404 | std.mem.writeInt(u32, payload[0..4], id, .little); |
| 405 | payload[4] = @intFromEnum(status); | ||
| 350 | 406 | ||
| 351 | try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, ""); | 407 | try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, ""); |
| 352 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); | 408 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); |
| @@ -360,7 +416,11 @@ test "client core selection reply text borrows the frame payload" { | |||
| 360 | .anchor = .{ .row = 0, .col = 0 }, | 416 | .anchor = .{ .row = 0, .col = 0 }, |
| 361 | .active = .{ .row = 0, .col = 0 }, | 417 | .active = .{ .row = 0, .col = 0 }, |
| 362 | }); | 418 | }); |
| 363 | var payload = [_]u8{ 1, 0, 0, 0, 0, 4, 0, 0, 0, 'h', 'i' }; | 419 | var payload = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); |
| 420 | std.mem.writeInt(u32, payload[0..4], 1, .little); | ||
| 421 | std.mem.writeInt(u32, payload[5..9], 4, .little); | ||
| 422 | payload[proto.selection_reply_prefix_len..][0] = 'h'; | ||
| 423 | payload[proto.selection_reply_prefix_len..][1] = 'i'; | ||
| 364 | 424 | ||
| 365 | const result = core.receive(.selection_reply, &payload); | 425 | const result = core.receive(.selection_reply, &payload); |
| 366 | payload[proto.selection_reply_prefix_len] = 'H'; | 426 | payload[proto.selection_reply_prefix_len] = 'H'; |
src/client/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -76,6 +76,72 @@ pub const Event = struct { | |||
| 76 | /// the widest today is the 7-byte modified tilde CSI; the slack is | 76 | /// the widest today is the 7-byte modified tilde CSI; the slack is |
| 77 | /// headroom for forms not in the table yet. | 77 | /// headroom for forms not in the table yet. |
| 78 | pub const max_seq_len = 16; | 78 | pub const max_seq_len = 16; |
| 79 | pub const MouseFormat = enum { x10, utf8, sgr, urxvt, sgr_pixels }; | ||
| 80 | pub const mouse_max_seq_len = 64; | ||
| 81 | |||
| 82 | /// Encode one semantic mouse event. Coordinates are one-based on the wire. | ||
| 83 | pub fn encodeMouse(format: MouseFormat, button_base: u8, release: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 84 | std.debug.assert(buf.len >= mouse_max_seq_len); | ||
| 85 | const base: u16 = if (release and format != .sgr and format != .sgr_pixels) 3 else button_base; | ||
| 86 | const button: u16 = base + @as(u16, @intFromBool(mods.shift)) * 4 + @as(u16, @intFromBool(mods.alt)) * 8 + @as(u16, @intFromBool(mods.ctrl)) * 16; | ||
| 87 | const x: u32 = if (format == .sgr_pixels) pixel_x else cell_x; | ||
| 88 | const y: u32 = if (format == .sgr_pixels) pixel_y else cell_y; | ||
| 89 | const bx = x +| 1; | ||
| 90 | const by = y +| 1; | ||
| 91 | switch (format) { | ||
| 92 | .sgr, .sgr_pixels => return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}{c}", .{ button, bx, by, @as(u8, if (release) 'm' else 'M') }) catch unreachable, | ||
| 93 | .urxvt => return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ button + 32, bx, by }) catch unreachable, | ||
| 94 | .x10 => { | ||
| 95 | if (bx > 223 or by > 223) return buf[0..0]; | ||
| 96 | buf[0] = 0x1b; | ||
| 97 | buf[1] = '['; | ||
| 98 | buf[2] = 'M'; | ||
| 99 | buf[3] = @intCast(button + 32); | ||
| 100 | buf[4] = @intCast(bx + 32); | ||
| 101 | buf[5] = @intCast(by + 32); | ||
| 102 | return buf[0..6]; | ||
| 103 | }, | ||
| 104 | .utf8 => { | ||
| 105 | if (bx > 2015 or by > 2015) return buf[0..0]; | ||
| 106 | buf[0] = 0x1b; | ||
| 107 | buf[1] = '['; | ||
| 108 | buf[2] = 'M'; | ||
| 109 | var n: usize = 3; | ||
| 110 | for ([_]u21{ button + 32, @intCast(bx + 32), @intCast(by + 32) }) |cp| { | ||
| 111 | n += std.unicode.utf8Encode(cp, buf[n..]) catch unreachable; | ||
| 112 | } | ||
| 113 | return buf[0..n]; | ||
| 114 | }, | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | pub fn encodeWheel(format: MouseFormat, up: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 119 | return encodeMouse(format, if (up) 64 else 65, false, cell_x, cell_y, pixel_x, pixel_y, mods, buf); | ||
| 120 | } | ||
| 121 | |||
| 122 | pub fn encodeWheelArrow(up: bool, cursor_keys: bool, buf: []u8) []const u8 { | ||
| 123 | const bytes = encode(.{ .key = if (up) .up else .down }, buf); | ||
| 124 | if (cursor_keys) buf[1] = 'O'; | ||
| 125 | return bytes; | ||
| 126 | } | ||
| 127 | |||
| 128 | test "wheel encodes negotiated SGR and legacy forms" { | ||
| 129 | var buf: [mouse_max_seq_len]u8 = undefined; | ||
| 130 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 131 | try std.testing.expectEqualStrings("\x1b[97;4;6M", encodeWheel(.urxvt, false, 3, 5, 0, 0, .{}, &buf)); | ||
| 132 | try std.testing.expectEqualStrings("\x1bOA", encodeWheelArrow(true, true, &buf)); | ||
| 133 | try std.testing.expectEqualStrings("\x1b[B", encodeWheelArrow(false, false, &buf)); | ||
| 134 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 255, 255 }, encodeWheel(.x10, true, 222, 222, 0, 0, .{}, &buf)); | ||
| 135 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.x10, true, 223, 0, 0, 0, .{}, &buf).len); | ||
| 136 | try std.testing.expectEqualStrings("\x1b[<64;225;226M", encodeWheel(.sgr_pixels, true, 0, 0, 224, 225, .{}, &buf)); | ||
| 137 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 0xdf, 0xbf, 0xdf, 0xbf }, encodeWheel(.utf8, true, 2014, 2014, 0, 0, .{}, &buf)); | ||
| 138 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.utf8, true, 2015, 0, 0, 0, .{}, &buf).len); | ||
| 139 | try std.testing.expectEqualStrings("\x1b[<93;1;1M", encodeWheel(.sgr, false, 0, 0, 0, 0, .{ .shift = true, .alt = true, .ctrl = true }, &buf)); | ||
| 140 | try std.testing.expectEqualStrings("\x1b[<1;4;6m", encodeMouse(.sgr, 1, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 141 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 51, 33, 33 }, encodeMouse(.x10, 0, true, 0, 0, 0, 0, .{ .ctrl = true }, &buf)); | ||
| 142 | try std.testing.expectEqualStrings("\x1b[35;4;6M", encodeMouse(.urxvt, 2, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 143 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 144 | } | ||
| 79 | 145 | ||
| 80 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the | 146 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the |
| 81 | /// slice written. An event this table has no bytes for — a bare modifier, | 147 | /// slice written. An event this table has no bytes for — a bare modifier, |
src/client/selection.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,367 @@ | |||
| 1 | //! The drag behind text selection, as pure state: press, motion, release, and | ||
| 2 | //! the span of columns highlighted on one line of one session. | ||
| 3 | //! | ||
| 4 | //! No tty, no transport, no engine, no allocation beyond its own struct — a | ||
| 5 | //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of | ||
| 6 | //! those means, which is what lets two drivers in different layers share one | ||
| 7 | //! meaning. Resolving a terminal row to a session line stays with the layout. | ||
| 8 | //! | ||
| 9 | //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag | ||
| 10 | //! held while the session scrolls must keep naming the lines it started over, | ||
| 11 | //! and a terminal row renames itself the moment output moves the window. | ||
| 12 | const std = @import("std"); | ||
| 13 | |||
| 14 | /// One place in one session, as a driver has resolved a report. | ||
| 15 | pub const Hit = struct { | ||
| 16 | tile: usize, | ||
| 17 | /// Absolute row: counted from the oldest row the daemon still retains. | ||
| 18 | row: u32, | ||
| 19 | /// Grid column, zero-based. Terminal column is the same number — | ||
| 20 | /// every painter emits from column 1 with no x-offset. | ||
| 21 | col: u16, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER | ||
| 25 | /// is, not what is under it. The two part company when the session scrolls, and | ||
| 26 | /// telling a click from a drag is the pointer's question. | ||
| 27 | pub const Cell = struct { row: u16, col: u16 }; | ||
| 28 | |||
| 29 | /// Inclusive columns, the same convention `engine.extractSelection` and | ||
| 30 | /// ghostty's `Selection` use at the other end of the wire. | ||
| 31 | pub const Span = struct { from: u16, to: u16 }; | ||
| 32 | |||
| 33 | /// A completed selection, normalized: `from` is at or before `to` in | ||
| 34 | /// reading order, whichever way the hand moved. | ||
| 35 | pub const Range = struct { | ||
| 36 | from: Hit, | ||
| 37 | to: Hit, | ||
| 38 | |||
| 39 | /// On `Range` rather than `Drag` because two get compared: a drag that moved | ||
| 40 | /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the | ||
| 41 | /// clamp is not decoration — a grid narrower than its terminal has columns a | ||
| 42 | /// pointer can reach with no cell behind them. | ||
| 43 | pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span { | ||
| 44 | if (self.from.tile != tile) return null; | ||
| 45 | if (row < self.from.row or row > self.to.row) return null; | ||
| 46 | const from = if (row == self.from.row) self.from.col else 0; | ||
| 47 | const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1); | ||
| 48 | // A selection whose whole width fell off the grid's right edge | ||
| 49 | // highlights nothing on this row, rather than one clamped cell at | ||
| 50 | // the edge that nobody pointed at. | ||
| 51 | if (from > to) return null; | ||
| 52 | return .{ .from = from, .to = to }; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | /// What a button coming up meant. | ||
| 57 | pub const Release = union(enum) { | ||
| 58 | /// Nothing was down, or the press landed on nothing selectable. | ||
| 59 | nothing, | ||
| 60 | /// Press and release in one cell. Not a selection — tmux's | ||
| 61 | /// `MouseDown1Pane`, which a driver answers by moving its own | ||
| 62 | /// selection to the thing clicked. | ||
| 63 | click: Hit, | ||
| 64 | /// A drag that ended. The highlight STANDS after this: it is what a | ||
| 65 | /// copy is taken from, and what the next press or `clear` drops. | ||
| 66 | selection: Range, | ||
| 67 | }; | ||
| 68 | |||
| 69 | /// One button's worth of drag. Four states, because a press is not yet a | ||
| 70 | /// selection and a release is not the end of one: `.down` may still turn out to | ||
| 71 | /// be a click, and `.held` is a finished selection still on screen. | ||
| 72 | pub const Drag = struct { | ||
| 73 | const Phase = enum { idle, down, dragging, held }; | ||
| 74 | |||
| 75 | phase: Phase = .idle, | ||
| 76 | /// The cell the press was on, which is what a later motion is | ||
| 77 | /// compared against to tell a drag from a tremor. | ||
| 78 | at: Cell = .{ .row = 0, .col = 0 }, | ||
| 79 | anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 80 | active: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 81 | |||
| 82 | /// Whatever was held is dropped however the press turns out: a new | ||
| 83 | /// press is a new selection, and a press on nothing (a label bar) is | ||
| 84 | /// the user putting the old one away. | ||
| 85 | pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 86 | const h = hit orelse { | ||
| 87 | self.* = .{}; | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling | ||
| 94 | /// inside one cell still points at one line. Once it IS a drag it stays one. | ||
| 95 | /// Confined to its starting tile, or it would ask the wrong session. | ||
| 96 | pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 97 | switch (self.phase) { | ||
| 98 | .idle, .held => return, | ||
| 99 | .down => { | ||
| 100 | if (cell.row == self.at.row and cell.col == self.at.col) return; | ||
| 101 | self.phase = .dragging; | ||
| 102 | }, | ||
| 103 | .dragging => {}, | ||
| 104 | } | ||
| 105 | const h = hit orelse return; | ||
| 106 | if (h.tile != self.anchor.tile) return; | ||
| 107 | self.active = h; | ||
| 108 | } | ||
| 109 | |||
| 110 | /// The button came up. See `Release` for what the three answers mean. | ||
| 111 | pub fn release(self: *Drag) Release { | ||
| 112 | switch (self.phase) { | ||
| 113 | .idle, .held => return .nothing, | ||
| 114 | .down => { | ||
| 115 | const hit = self.anchor; | ||
| 116 | self.* = .{}; | ||
| 117 | return .{ .click = hit }; | ||
| 118 | }, | ||
| 119 | .dragging => { | ||
| 120 | self.phase = .held; | ||
| 121 | return .{ .selection = self.rangeLocked() }; | ||
| 122 | }, | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Coordinates stopped meaning what they meant. | ||
| 127 | pub fn clear(self: *Drag) void { | ||
| 128 | self.* = .{}; | ||
| 129 | } | ||
| 130 | |||
| 131 | /// A press that has not moved yet counts; `range`, by contrast, | ||
| 132 | /// answers only about what is on screen. | ||
| 133 | pub fn on(self: *const Drag) ?usize { | ||
| 134 | return if (self.phase == .idle) null else self.anchor.tile; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// Whether the pointer button remains down and motion should be captured. | ||
| 138 | pub fn buttonHeld(self: *const Drag) bool { | ||
| 139 | return self.phase == .down or self.phase == .dragging; | ||
| 140 | } | ||
| 141 | |||
| 142 | /// The selection as an ordered pair, or null while there is none. | ||
| 143 | pub fn range(self: *const Drag) ?Range { | ||
| 144 | return switch (self.phase) { | ||
| 145 | .idle, .down => null, | ||
| 146 | .dragging, .held => self.rangeLocked(), | ||
| 147 | }; | ||
| 148 | } | ||
| 149 | |||
| 150 | fn rangeLocked(self: *const Drag) Range { | ||
| 151 | const a = self.anchor; | ||
| 152 | const b = self.active; | ||
| 153 | const forward = b.row > a.row or (b.row == a.row and b.col >= a.col); | ||
| 154 | return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a }; | ||
| 155 | } | ||
| 156 | |||
| 157 | /// `Range.span` for the live selection, or null while there is none. | ||
| 158 | pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span { | ||
| 159 | const r = self.range() orelse return null; | ||
| 160 | return r.span(tile, row, cols); | ||
| 161 | } | ||
| 162 | }; | ||
| 163 | |||
| 164 | test "select: a press alone is a click, and highlights nothing on its way" { | ||
| 165 | var d: Drag = .{}; | ||
| 166 | const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 }; | ||
| 167 | d.press(.{ .row = 12, .col = 7 }, hit); | ||
| 168 | // Nothing is highlighted while the button is merely down: a press that | ||
| 169 | // turns out to be a click must never flicker an inversion on its way. | ||
| 170 | try std.testing.expect(d.span(1, 40, 80) == null); | ||
| 171 | try std.testing.expect(d.range() == null); | ||
| 172 | const r = d.release(); | ||
| 173 | try std.testing.expectEqual(@as(usize, 1), r.click.tile); | ||
| 174 | try std.testing.expectEqual(@as(u32, 40), r.click.row); | ||
| 175 | try std.testing.expectEqual(@as(u16, 7), r.click.col); | ||
| 176 | // ...and the click leaves nothing behind to paint. | ||
| 177 | try std.testing.expect(d.range() == null); | ||
| 178 | } | ||
| 179 | |||
| 180 | test "select: absolute history rows do not narrow the pointer cell" { | ||
| 181 | var d: Drag = .{}; | ||
| 182 | const origin: Hit = .{ .tile = 7, .row = 70_000, .col = 3 }; | ||
| 183 | d.press(.{ .row = 2, .col = 3 }, origin); | ||
| 184 | try std.testing.expect(d.buttonHeld()); | ||
| 185 | d.motion(.{ .row = 3, .col = 3 }, .{ .tile = 7, .row = 70_001, .col = 3 }); | ||
| 186 | const release = d.release(); | ||
| 187 | try std.testing.expectEqual(@as(u32, 70_001), release.selection.to.row); | ||
| 188 | try std.testing.expect(!d.buttonHeld()); | ||
| 189 | try std.testing.expectEqual(@as(u16, 3), (d.range() orelse unreachable).from.col); | ||
| 190 | } | ||
| 191 | |||
| 192 | test "select: a drag names its tile from the press, before it is a selection" { | ||
| 193 | var d: Drag = .{}; | ||
| 194 | try std.testing.expect(d.on() == null); | ||
| 195 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 }); | ||
| 196 | // Pressed, not yet dragged: nothing to paint, and still a tile whose | ||
| 197 | // coordinates a caller may have to drop. | ||
| 198 | try std.testing.expect(d.range() == null); | ||
| 199 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 200 | d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 }); | ||
| 201 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 202 | _ = d.release(); | ||
| 203 | // Still held after the button came up, which is what a highlight is. | ||
| 204 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 205 | d.clear(); | ||
| 206 | try std.testing.expect(d.on() == null); | ||
| 207 | } | ||
| 208 | |||
| 209 | test "select: a press on nothing selectable is nothing at all" { | ||
| 210 | var d: Drag = .{}; | ||
| 211 | d.press(.{ .row = 0, .col = 3 }, null); | ||
| 212 | d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 }); | ||
| 213 | try std.testing.expect(d.range() == null); | ||
| 214 | try std.testing.expect(d.release() == .nothing); | ||
| 215 | } | ||
| 216 | |||
| 217 | test "select: either drag direction yields the same ordered pair" { | ||
| 218 | const top: Hit = .{ .tile = 0, .row = 100, .col = 4 }; | ||
| 219 | const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 }; | ||
| 220 | |||
| 221 | var down: Drag = .{}; | ||
| 222 | down.press(.{ .row = 2, .col = 4 }, top); | ||
| 223 | down.motion(.{ .row = 5, .col = 12 }, bot); | ||
| 224 | const a = down.release().selection; | ||
| 225 | |||
| 226 | var up: Drag = .{}; | ||
| 227 | up.press(.{ .row = 5, .col = 12 }, bot); | ||
| 228 | up.motion(.{ .row = 2, .col = 4 }, top); | ||
| 229 | const b = up.release().selection; | ||
| 230 | |||
| 231 | try std.testing.expectEqual(a.from, b.from); | ||
| 232 | try std.testing.expectEqual(a.to, b.to); | ||
| 233 | try std.testing.expectEqual(@as(u32, 100), a.from.row); | ||
| 234 | try std.testing.expectEqual(@as(u16, 4), a.from.col); | ||
| 235 | try std.testing.expectEqual(@as(u32, 103), a.to.row); | ||
| 236 | try std.testing.expectEqual(@as(u16, 12), a.to.col); | ||
| 237 | } | ||
| 238 | |||
| 239 | test "select: a one-row drag reads left to right whichever way the hand moved" { | ||
| 240 | var d: Drag = .{}; | ||
| 241 | d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 }); | ||
| 242 | d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 }); | ||
| 243 | const s = d.span(0, 50, 80).?; | ||
| 244 | try std.testing.expectEqual(@as(u16, 2), s.from); | ||
| 245 | try std.testing.expectEqual(@as(u16, 9), s.to); | ||
| 246 | // One row, so neither neighbour is in it. | ||
| 247 | try std.testing.expect(d.span(0, 49, 80) == null); | ||
| 248 | try std.testing.expect(d.span(0, 51, 80) == null); | ||
| 249 | } | ||
| 250 | |||
| 251 | test "select: the ends are clipped at the anchors, and the middle is the full width" { | ||
| 252 | var d: Drag = .{}; | ||
| 253 | d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 }); | ||
| 254 | d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 }); | ||
| 255 | |||
| 256 | const first = d.span(2, 7, 80).?; | ||
| 257 | try std.testing.expectEqual(@as(u16, 30), first.from); | ||
| 258 | try std.testing.expectEqual(@as(u16, 79), first.to); | ||
| 259 | const middle = d.span(2, 8, 80).?; | ||
| 260 | try std.testing.expectEqual(@as(u16, 0), middle.from); | ||
| 261 | try std.testing.expectEqual(@as(u16, 79), middle.to); | ||
| 262 | const last = d.span(2, 10, 80).?; | ||
| 263 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 264 | try std.testing.expectEqual(@as(u16, 6), last.to); | ||
| 265 | // Outside the range on both sides. | ||
| 266 | try std.testing.expect(d.span(2, 6, 80) == null); | ||
| 267 | try std.testing.expect(d.span(2, 11, 80) == null); | ||
| 268 | } | ||
| 269 | |||
| 270 | test "select: the highlight is one tile's, and the drag cannot leave it" { | ||
| 271 | var d: Drag = .{}; | ||
| 272 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 }); | ||
| 273 | // A drag onto the neighbouring stripe: it IS a drag, and the active | ||
| 274 | // end stays on the last line of the tile it started in. | ||
| 275 | d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 }); | ||
| 276 | const s = d.span(0, 5, 80).?; | ||
| 277 | try std.testing.expectEqual(@as(u16, 1), s.from); | ||
| 278 | try std.testing.expectEqual(@as(u16, 1), s.to); | ||
| 279 | // Neither the row it strayed onto nor the tile it strayed into. | ||
| 280 | try std.testing.expect(d.span(1, 200, 80) == null); | ||
| 281 | try std.testing.expect(d.span(0, 200, 80) == null); | ||
| 282 | // The same rows, asked for as somebody else's tile, are not the answer. | ||
| 283 | try std.testing.expect(d.span(1, 5, 80) == null); | ||
| 284 | } | ||
| 285 | |||
| 286 | test "select: a pointer that never leaves the press cell has not dragged" { | ||
| 287 | var d: Drag = .{}; | ||
| 288 | d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 289 | // `?1002h` reports on a cell change, but a terminal repeating the cell | ||
| 290 | // must not turn a click into an empty selection. | ||
| 291 | d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 292 | try std.testing.expect(d.range() == null); | ||
| 293 | try std.testing.expect(d.release() == .click); | ||
| 294 | } | ||
| 295 | |||
| 296 | test "select: a drag that comes back to where it started is still a drag" { | ||
| 297 | var d: Drag = .{}; | ||
| 298 | const home: Hit = .{ .tile = 0, .row = 6, .col = 20 }; | ||
| 299 | d.press(.{ .row = 6, .col = 20 }, home); | ||
| 300 | d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 }); | ||
| 301 | d.motion(.{ .row = 6, .col = 20 }, home); | ||
| 302 | try std.testing.expect(d.release() == .selection); | ||
| 303 | // One cell selected, and it is still on screen after the button is up. | ||
| 304 | const s = d.span(0, 6, 80).?; | ||
| 305 | try std.testing.expectEqual(@as(u16, 20), s.from); | ||
| 306 | try std.testing.expectEqual(@as(u16, 20), s.to); | ||
| 307 | } | ||
| 308 | |||
| 309 | test "select: the highlight outlives the release, and dies on clear" { | ||
| 310 | var d: Drag = .{}; | ||
| 311 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 312 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 313 | _ = d.release(); | ||
| 314 | try std.testing.expect(d.span(0, 3, 80) != null); | ||
| 315 | // A motion after the button is up is somebody else's pointer moving | ||
| 316 | // over a selection that is finished. | ||
| 317 | d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 }); | ||
| 318 | try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row); | ||
| 319 | try std.testing.expect(d.release() == .nothing); | ||
| 320 | d.clear(); | ||
| 321 | try std.testing.expect(d.range() == null); | ||
| 322 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 323 | } | ||
| 324 | |||
| 325 | test "select: a new press drops the selection the last one left" { | ||
| 326 | var d: Drag = .{}; | ||
| 327 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 328 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 329 | _ = d.release(); | ||
| 330 | d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 }); | ||
| 331 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 332 | try std.testing.expect(d.range() == null); | ||
| 333 | // ...and a press on nothing drops it just as thoroughly. | ||
| 334 | d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 }); | ||
| 335 | try std.testing.expect(d.range() != null); | ||
| 336 | d.press(.{ .row = 0, .col = 0 }, null); | ||
| 337 | try std.testing.expect(d.range() == null); | ||
| 338 | } | ||
| 339 | |||
| 340 | test "select: a motion with no button down selects nothing" { | ||
| 341 | var d: Drag = .{}; | ||
| 342 | d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 }); | ||
| 343 | try std.testing.expect(d.range() == null); | ||
| 344 | try std.testing.expect(d.release() == .nothing); | ||
| 345 | } | ||
| 346 | |||
| 347 | test "select: a span past the grid's right edge is clipped, not painted at the edge" { | ||
| 348 | // The tty is wider than the grid — latest-wins leaves that shape | ||
| 349 | // routinely — so a pointer can reach columns with no cell behind them. | ||
| 350 | var d: Drag = .{}; | ||
| 351 | d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 }); | ||
| 352 | d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 }); | ||
| 353 | // The first row's anchor is off the grid entirely: nothing to invert, | ||
| 354 | // and NOT one cell at column 39. | ||
| 355 | try std.testing.expect(d.span(0, 2, 40) == null); | ||
| 356 | // The last row runs from the left edge to the grid's own right edge. | ||
| 357 | const last = d.span(0, 3, 40).?; | ||
| 358 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 359 | try std.testing.expectEqual(@as(u16, 39), last.to); | ||
| 360 | } | ||
| 361 | |||
| 362 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 363 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 364 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 365 | test { | ||
| 366 | std.testing.refAllDeclsRecursive(@This()); | ||
| 367 | } | ||
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -8,7 +8,68 @@ const term = @import("term"); | |||
| 8 | const proto = term.protocol; | 8 | const proto = term.protocol; |
| 9 | const Wire = @import("buffered_wire.zig").Wire; | 9 | const Wire = @import("buffered_wire.zig").Wire; |
| 10 | 10 | ||
| 11 | pub const Say = union(enum) { input: []const u8, resize: proto.Size, end: struct { request: u64, force: bool = false }, detach, quit }; | 11 | /// Identity of the displayed terminal state, copied with its grid under mu. |
| 12 | /// Live selection keeps its range through redraws and copies current text. | ||
| 13 | /// History views retain exact source freshness because their cells are cached. | ||
| 14 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. | ||
| 15 | pub const SelectionVersion = struct { seq: u64, source: u64 = 0, history_rows: u32, epoch: u64, revision: u64, history: bool = false }; | ||
| 16 | pub const SelectionRequest = struct { | ||
| 17 | id: u32, | ||
| 18 | gesture: u32 = 0, | ||
| 19 | anchor: proto.SelectionPoint, | ||
| 20 | active: proto.SelectionPoint, | ||
| 21 | version: SelectionVersion, | ||
| 22 | ticket: u64 = 0, // stamped by say; cancellation also invalidates queued work | ||
| 23 | }; | ||
| 24 | pub const Say = union(enum) { | ||
| 25 | input: []const u8, | ||
| 26 | wheel: Wheel, | ||
| 27 | mouse: Mouse, | ||
| 28 | resize: proto.Size, | ||
| 29 | selection: SelectionRequest, | ||
| 30 | end: struct { request: u64, force: bool = false }, | ||
| 31 | detach, | ||
| 32 | quit, | ||
| 33 | }; | ||
| 34 | pub const Wheel = struct { | ||
| 35 | notches: i32, | ||
| 36 | col: u16, | ||
| 37 | row: u16, | ||
| 38 | pixel_x: u32, | ||
| 39 | pixel_y: u32, | ||
| 40 | mods: client.keymap.Mods = .{}, | ||
| 41 | }; | ||
| 42 | /// A GUI gesture belongs to the modes and wire admitted at its press. | ||
| 43 | pub const MouseToken = struct { generation: u64, modes: proto.TermModes }; | ||
| 44 | pub const Mouse = struct { | ||
| 45 | token: MouseToken, | ||
| 46 | kind: enum { press, motion, release }, | ||
| 47 | button: u8 = 0, | ||
| 48 | col: u16, | ||
| 49 | row: u16, | ||
| 50 | pixel_x: u32, | ||
| 51 | pixel_y: u32, | ||
| 52 | mods: client.keymap.Mods = .{}, | ||
| 53 | }; | ||
| 54 | fn mouseFormat(modes: proto.TermModes) client.keymap.MouseFormat { | ||
| 55 | return if (modes.mouse_sgr_pixels) .sgr_pixels else if (modes.mouse_sgr) .sgr else if (modes.mouse_urxvt) .urxvt else if (modes.mouse_utf8) .utf8 else .x10; | ||
| 56 | } | ||
| 57 | const HistoryRequest = struct { | ||
| 58 | start: u32, | ||
| 59 | size: proto.Size, | ||
| 60 | revision: u64, | ||
| 61 | until: i64, | ||
| 62 | }; | ||
| 63 | pub const SelectionResult = struct { id: u32, gesture: u32 = 0, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; | ||
| 64 | pub const FollowPosition = struct { | ||
| 65 | id: u32, | ||
| 66 | seq: u64, | ||
| 67 | source: u64, | ||
| 68 | history_rows: u32, | ||
| 69 | status: proto.SelectionStatus, | ||
| 70 | anchor: proto.SelectionPoint, | ||
| 71 | active: proto.SelectionPoint, | ||
| 72 | }; | ||
| 12 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; | 73 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; |
| 13 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; | 74 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; |
| 14 | pub const EndState = struct { | 75 | pub const EndState = struct { |
| @@ -44,6 +105,7 @@ pub const Options = struct { | |||
| 44 | retry_initial: bool = false, | 105 | retry_initial: bool = false, |
| 45 | open_timeout_ms: u32 = 15000, | 106 | open_timeout_ms: u32 = 15000, |
| 46 | end_timeout_ms: u32 = 2000, | 107 | end_timeout_ms: u32 = 2000, |
| 108 | selection_timeout_ms: u32 = 2000, | ||
| 47 | wake: ?*const fn (?*anyopaque) void = null, | 109 | wake: ?*const fn (?*anyopaque) void = null, |
| 48 | wake_ctx: ?*anyopaque = null, | 110 | wake_ctx: ?*anyopaque = null, |
| 49 | }; | 111 | }; |
| @@ -66,6 +128,30 @@ pub const Pump = struct { | |||
| 66 | thread: ?std.Thread = null, | 128 | thread: ?std.Thread = null, |
| 67 | admitted: bool = false, | 129 | admitted: bool = false, |
| 68 | end_until: i64 = 0, // guarded by mu with status.ending | 130 | end_until: i64 = 0, // guarded by mu with status.ending |
| 131 | selection_pending: ?SelectionRequest = null, | ||
| 132 | selection_pending_copy: bool = false, | ||
| 133 | selection_revision: u64 = 0, // mu: connection, geometry and terminal modes | ||
| 134 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation | ||
| 135 | selection_until: i64 = 0, | ||
| 136 | selection_result: ?SelectionResult = null, | ||
| 137 | selection_gesture: u32 = 0, | ||
| 138 | selection_clear: ?u32 = null, | ||
| 139 | follow_position: ?FollowPosition = null, | ||
| 140 | follow_source: u64 = 0, | ||
| 141 | follow_seq: u64 = 0, | ||
| 142 | history_waiting_metadata: bool = false, | ||
| 143 | scroll_rows: u32 = 0, // mu: requested distance from live output | ||
| 144 | history: ?*term.grid.Grid = null, | ||
| 145 | history_start: u32 = 0, | ||
| 146 | history_version: SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 147 | history_revision: u64 = 0, | ||
| 148 | history_dirty: bool = false, | ||
| 149 | // A cancelled request stays here until its reply is drained. The wire has | ||
| 150 | // no request ID, so replacing it could accept an old same-origin reply. | ||
| 151 | history_pending: ?HistoryRequest = null, | ||
| 152 | mouse_generation: u64 = 0, // mu: wire, geometry and mode cancellation | ||
| 153 | mouse_active: ?Mouse = null, // transport thread only, last transmitted report | ||
| 154 | clipboard: [2]?[]u8 = .{ null, null }, // mu: latest write per desktop target | ||
| 69 | 155 | ||
| 70 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | 156 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { |
| 71 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; | 157 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; |
| @@ -89,6 +175,17 @@ pub const Pump = struct { | |||
| 89 | defer self.mailbox_mu.unlock(); | 175 | defer self.mailbox_mu.unlock(); |
| 90 | if (self.closing.load(.acquire)) return; | 176 | if (self.closing.load(.acquire)) return; |
| 91 | switch (msg) { | 177 | switch (msg) { |
| 178 | .selection => |req| { | ||
| 179 | self.mu.lock(); | ||
| 180 | defer self.mu.unlock(); | ||
| 181 | self.cancelPendingSelectionLocked(); | ||
| 182 | var owned = req; | ||
| 183 | if (owned.gesture == 0) owned.gesture = owned.id; | ||
| 184 | owned.ticket = self.selection_ticket; | ||
| 185 | try self.mailbox.append(self.alloc, .{ .selection = owned }); | ||
| 186 | self.selection_pending = owned; | ||
| 187 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; | ||
| 188 | }, | ||
| 92 | .end => |req| { | 189 | .end => |req| { |
| 93 | self.mu.lock(); | 190 | self.mu.lock(); |
| 94 | defer self.mu.unlock(); | 191 | defer self.mu.unlock(); |
| @@ -125,6 +222,9 @@ pub const Pump = struct { | |||
| 125 | self.say(.quit) catch unreachable; | 222 | self.say(.quit) catch unreachable; |
| 126 | if (self.thread) |thread| thread.join(); | 223 | if (self.thread) |thread| thread.join(); |
| 127 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 224 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 225 | if (self.selection_result) |result| self.alloc.free(result.text); | ||
| 226 | if (self.history) |g| g.deinit(); | ||
| 227 | for (self.clipboard) |text| if (text) |t| self.alloc.free(t); | ||
| 128 | self.mailbox.deinit(self.alloc); | 228 | self.mailbox.deinit(self.alloc); |
| 129 | closePipe(self.wake_pipe); | 229 | closePipe(self.wake_pipe); |
| 130 | closePipe(self.cancel_pipe); | 230 | closePipe(self.cancel_pipe); |
| @@ -132,6 +232,209 @@ pub const Pump = struct { | |||
| 132 | self.alloc.destroy(self); | 232 | self.alloc.destroy(self); |
| 133 | } | 233 | } |
| 134 | 234 | ||
| 235 | pub fn mouseToken(self: *Pump) ?MouseToken { | ||
| 236 | self.mu.lock(); | ||
| 237 | defer self.mu.unlock(); | ||
| 238 | if (!self.admitted or self.status.phase != .attached or self.closing.load(.acquire)) return null; | ||
| 239 | return .{ .generation = self.mouse_generation, .modes = self.core.terminal_modes }; | ||
| 240 | } | ||
| 241 | pub fn mouseFresh(self: *Pump, token: MouseToken) bool { | ||
| 242 | const current = self.mouseToken() orelse return false; | ||
| 243 | return current.generation == token.generation; | ||
| 244 | } | ||
| 245 | /// Cancellation cannot allocate or wait for a GUI-thread transport write. | ||
| 246 | pub fn cancelMouse(self: *Pump, token: MouseToken) void { | ||
| 247 | self.mu.lock(); | ||
| 248 | if (self.mouse_generation == token.generation) self.mouse_generation +%= 1; | ||
| 249 | self.mu.unlock(); | ||
| 250 | ring(self.wake_pipe[1], 1); | ||
| 251 | } | ||
| 252 | pub fn takeClipboard(self: *Pump, primary: bool) ?[]u8 { | ||
| 253 | self.mu.lock(); | ||
| 254 | defer self.mu.unlock(); | ||
| 255 | const index: usize = @intFromBool(primary); | ||
| 256 | const text = self.clipboard[index]; | ||
| 257 | self.clipboard[index] = null; | ||
| 258 | return text; | ||
| 259 | } | ||
| 260 | fn clearClipboardLocked(self: *Pump) void { | ||
| 261 | for (&self.clipboard) |*text| { | ||
| 262 | if (text.*) |t| self.alloc.free(t); | ||
| 263 | text.* = null; | ||
| 264 | } | ||
| 265 | } | ||
| 266 | |||
| 267 | /// Caller holds mu while copying both this version and the displayed grid. | ||
| 268 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { | ||
| 269 | if (self.history != null) return self.history_version; | ||
| 270 | return .{ .seq = self.replica.last_seq, .source = if (self.follow_seq == self.replica.last_seq) self.follow_source else 0, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | ||
| 271 | } | ||
| 272 | pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid { | ||
| 273 | return self.history orelse self.grid; | ||
| 274 | } | ||
| 275 | pub fn viewOriginLocked(self: *const Pump) u32 { | ||
| 276 | return if (self.history != null) self.history_start else self.replica.history_rows; | ||
| 277 | } | ||
| 278 | pub fn followPositionLocked(self: *const Pump) ?FollowPosition { | ||
| 279 | const position = self.follow_position orelse return null; | ||
| 280 | if (position.id != self.selection_gesture or position.status != .ok or position.seq != self.replica.last_seq) return null; | ||
| 281 | if (self.history != null and (self.history_version.seq != position.seq or | ||
| 282 | self.history_version.source != position.source)) return null; | ||
| 283 | return position; | ||
| 284 | } | ||
| 285 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { | ||
| 286 | return self.selectionAliveLocked(version) and | ||
| 287 | version.history_rows == self.replica.history_rows and | ||
| 288 | version.source != 0 and version.source == self.follow_source and | ||
| 289 | (!version.history or version.seq == self.replica.last_seq); | ||
| 290 | } | ||
| 291 | fn selectionAliveLocked(self: *const Pump, version: SelectionVersion) bool { | ||
| 292 | return !self.closing.load(.acquire) and self.status.phase == .attached and | ||
| 293 | self.replica.state_since_attach and version.epoch == self.replica.session_epoch and | ||
| 294 | version.revision == self.selection_revision; | ||
| 295 | } | ||
| 296 | fn returnLiveLocked(self: *Pump) void { | ||
| 297 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; | ||
| 298 | self.scroll_rows = 0; | ||
| 299 | self.history_dirty = false; | ||
| 300 | self.history_revision +%= 1; | ||
| 301 | if (self.history) |g| g.deinit(); | ||
| 302 | self.history = null; | ||
| 303 | self.history_waiting_metadata = false; | ||
| 304 | } | ||
| 305 | fn requestHistory(self: *Pump, wire: *Wire) !void { | ||
| 306 | self.mu.lock(); | ||
| 307 | const pending = self.history_pending; | ||
| 308 | if (pending) |p| { | ||
| 309 | self.mu.unlock(); | ||
| 310 | // Reconnect rather than reusing an uncorrelated stream after timeout. | ||
| 311 | if (std.time.milliTimestamp() >= p.until) return error.ConnectionTimedOut; | ||
| 312 | return; | ||
| 313 | } | ||
| 314 | if (!self.history_dirty or self.scroll_rows == 0 or !self.admitted) { | ||
| 315 | self.mu.unlock(); | ||
| 316 | return; | ||
| 317 | } | ||
| 318 | const req: HistoryRequest = .{ .start = self.replica.scrollStart(self.scroll_rows), .size = .{ .cols = self.opts.cols, .rows = self.opts.rows }, .revision = self.history_revision, .until = std.time.milliTimestamp() + 2000 }; | ||
| 319 | self.history_pending = req; | ||
| 320 | self.history_dirty = false; | ||
| 321 | self.mu.unlock(); | ||
| 322 | const bytes = proto.encodeScrollbackReq(req.start, req.size.rows); | ||
| 323 | try wire.send(.fetch_scrollback, &bytes); | ||
| 324 | } | ||
| 325 | fn historyReplyLocked(self: *Pump, payload: []const u8) !Action { | ||
| 326 | const req = self.history_pending orelse return .skip; | ||
| 327 | self.history_pending = null; | ||
| 328 | if (req.revision != self.history_revision or self.scroll_rows == 0) return .skip; | ||
| 329 | if (payload.len < 6) return error.BadPayload; | ||
| 330 | const origin = std.mem.readInt(u32, payload[0..4], .little); | ||
| 331 | const count = std.mem.readInt(u16, payload[4..6], .little); | ||
| 332 | if (count > req.size.rows or origin > self.replica.history_rows) return error.BadPayload; | ||
| 333 | const view = try term.grid.Grid.init(self.alloc, req.size.cols, req.size.rows); | ||
| 334 | errdefer view.deinit(); | ||
| 335 | var bytes = payload[6..]; | ||
| 336 | for (view.lines[0..count]) |*row| bytes = try term.grid.decodeRow(self.alloc, row, bytes, req.size.cols); | ||
| 337 | if (bytes.len != 0) return error.BadPayload; | ||
| 338 | view.cursor = .{ .x = req.size.cols, .y = req.size.rows }; | ||
| 339 | if (self.history) |old| old.deinit(); | ||
| 340 | self.history = view; | ||
| 341 | self.history_start = origin; | ||
| 342 | self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision, .history = true }; | ||
| 343 | self.history_waiting_metadata = true; | ||
| 344 | return .changed; | ||
| 345 | } | ||
| 346 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { | ||
| 347 | self.mu.lock(); | ||
| 348 | defer self.mu.unlock(); | ||
| 349 | return self.selectionFreshLocked(version); | ||
| 350 | } | ||
| 351 | pub fn selectionAlive(self: *Pump, gesture: u32, version: SelectionVersion) bool { | ||
| 352 | self.mu.lock(); | ||
| 353 | defer self.mu.unlock(); | ||
| 354 | if (!self.selectionAliveLocked(version)) return false; | ||
| 355 | if (self.selection_pending) |pending| { | ||
| 356 | if ((if (pending.gesture == 0) pending.id else pending.gesture) == gesture) return true; | ||
| 357 | } | ||
| 358 | if (self.selection_result) |result| if (result.gesture == gesture) return true; | ||
| 359 | if (self.follow_position) |position| { | ||
| 360 | if (self.selection_gesture == gesture and position.id == gesture and position.status == .ok) return true; | ||
| 361 | } | ||
| 362 | return false; | ||
| 363 | } | ||
| 364 | /// Validate again under the same lock that transfers ownership. A newer | ||
| 365 | /// frame after reply decoding must not leave an old copy ready for the UI. | ||
| 366 | pub fn takeSelection(self: *Pump) ?SelectionResult { | ||
| 367 | self.mu.lock(); | ||
| 368 | defer self.mu.unlock(); | ||
| 369 | const result = self.selection_result orelse return null; | ||
| 370 | self.selection_result = null; | ||
| 371 | if (!self.selectionAliveLocked(result.version)) { | ||
| 372 | self.alloc.free(result.text); | ||
| 373 | return null; | ||
| 374 | } | ||
| 375 | return result; | ||
| 376 | } | ||
| 377 | fn invalidateSelectionLocked(self: *Pump) void { | ||
| 378 | if (self.selection_gesture != 0) self.selection_clear = self.selection_gesture; | ||
| 379 | self.selection_gesture = 0; | ||
| 380 | self.follow_position = null; | ||
| 381 | self.cancelPendingSelectionLocked(); | ||
| 382 | } | ||
| 383 | fn cancelPendingSelectionLocked(self: *Pump) void { | ||
| 384 | self.selection_ticket +%= 1; | ||
| 385 | self.selection_pending = null; | ||
| 386 | self.selection_pending_copy = false; | ||
| 387 | self.selection_until = 0; | ||
| 388 | self.core.pending_selection_id = null; | ||
| 389 | if (self.selection_result) |result| self.alloc.free(result.text); | ||
| 390 | self.selection_result = null; | ||
| 391 | } | ||
| 392 | pub fn cancelSelection(self: *Pump) void { | ||
| 393 | self.mu.lock(); | ||
| 394 | self.invalidateSelectionLocked(); | ||
| 395 | self.mu.unlock(); | ||
| 396 | ring(self.wake_pipe[1], 1); | ||
| 397 | } | ||
| 398 | fn flushSelectionClear(self: *Pump, wire: *Wire) !void { | ||
| 399 | self.mu.lock(); | ||
| 400 | const gesture = self.selection_clear; | ||
| 401 | self.selection_clear = null; | ||
| 402 | self.mu.unlock(); | ||
| 403 | if (gesture) |id| { | ||
| 404 | const clear = proto.encodeSelectionReq(.{ | ||
| 405 | .action = .clear, | ||
| 406 | .id = 0, | ||
| 407 | .gesture = id, | ||
| 408 | .epoch = 0, | ||
| 409 | .source = 0, | ||
| 410 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 411 | .active = .{ .row = 0, .col = 0 }, | ||
| 412 | }); | ||
| 413 | try wire.send(.selection_req, &clear); | ||
| 414 | } | ||
| 415 | } | ||
| 416 | fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { | ||
| 417 | const gesture = if (req.gesture == 0) req.id else req.gesture; | ||
| 418 | const action: @FieldType(proto.SelectionReq, "action") = | ||
| 419 | if (self.selection_gesture == gesture) .copy else .start; | ||
| 420 | if (req.ticket != self.selection_ticket or | ||
| 421 | (if (action == .copy) !self.selectionAliveLocked(req.version) else !self.selectionFreshLocked(req.version))) return null; | ||
| 422 | self.selection_gesture = gesture; | ||
| 423 | if (action == .start) self.follow_position = null; | ||
| 424 | self.selection_pending = req; | ||
| 425 | self.selection_pending_copy = action == .copy; | ||
| 426 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; | ||
| 427 | return proto.encodeSelectionReq(.{ | ||
| 428 | .action = action, | ||
| 429 | .id = req.id, | ||
| 430 | .gesture = gesture, | ||
| 431 | .epoch = req.version.epoch, | ||
| 432 | .source = req.version.source, | ||
| 433 | .anchor = req.anchor, | ||
| 434 | .active = req.active, | ||
| 435 | }); | ||
| 436 | } | ||
| 437 | |||
| 135 | fn wake(self: *Pump) void { | 438 | fn wake(self: *Pump) void { |
| 136 | if (self.opts.wake) |f| f(self.opts.wake_ctx); | 439 | if (self.opts.wake) |f| f(self.opts.wake_ctx); |
| 137 | } | 440 | } |
| @@ -144,6 +447,12 @@ pub const Pump = struct { | |||
| 144 | } | 447 | } |
| 145 | 448 | ||
| 146 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | 449 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { |
| 450 | if (phase != .attached) { | ||
| 451 | self.mouse_generation +%= 1; | ||
| 452 | self.clearClipboardLocked(); | ||
| 453 | self.invalidateSelectionLocked(); | ||
| 454 | self.returnLiveLocked(); | ||
| 455 | } | ||
| 147 | self.status.phase = phase; | 456 | self.status.phase = phase; |
| 148 | self.status.exit_code = code; | 457 | self.status.exit_code = code; |
| 149 | self.status.reason_len = @min(reason.len, self.status.reason.len); | 458 | self.status.reason_len = @min(reason.len, self.status.reason.len); |
| @@ -171,6 +480,7 @@ pub const Pump = struct { | |||
| 171 | while (!self.closing.load(.acquire)) { | 480 | while (!self.closing.load(.acquire)) { |
| 172 | var dial: client.handoff.Dial = .{}; | 481 | var dial: client.handoff.Dial = .{}; |
| 173 | self.expireEnd(); | 482 | self.expireEnd(); |
| 483 | self.expireSelection(); | ||
| 174 | var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, std.time.milliTimestamp() + self.opts.open_timeout_ms) catch |err| { | 484 | var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, std.time.milliTimestamp() + self.opts.open_timeout_ms) catch |err| { |
| 175 | if (self.closing.load(.acquire)) return; | 485 | if (self.closing.load(.acquire)) return; |
| 176 | var buf: [1024]u8 = undefined; | 486 | var buf: [1024]u8 = undefined; |
| @@ -205,58 +515,280 @@ pub const Pump = struct { | |||
| 205 | } | 515 | } |
| 206 | 516 | ||
| 207 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { | 517 | fn attach(self: *Pump, wire: *Wire, fresh: bool) !void { |
| 518 | if (fresh) try self.releaseMouse(wire) else self.mouse_active = null; | ||
| 208 | self.mu.lock(); | 519 | self.mu.lock(); |
| 520 | self.mouse_generation +%= 1; | ||
| 521 | self.clearClipboardLocked(); | ||
| 522 | self.invalidateSelectionLocked(); | ||
| 523 | self.selection_revision +%= 1; | ||
| 524 | self.follow_position = null; | ||
| 525 | self.follow_source = 0; | ||
| 526 | self.follow_seq = 0; | ||
| 527 | self.history_waiting_metadata = false; | ||
| 528 | self.selection_gesture = 0; | ||
| 529 | self.selection_clear = null; | ||
| 530 | self.returnLiveLocked(); | ||
| 531 | if (!fresh) self.history_pending = null; | ||
| 209 | const args = self.replica.attachArgs(); | 532 | const args = self.replica.attachArgs(); |
| 210 | self.replica.state_since_attach = false; | 533 | self.replica.state_since_attach = false; |
| 211 | self.mu.unlock(); | ||
| 212 | self.admitted = false; | 534 | self.admitted = false; |
| 535 | self.mu.unlock(); | ||
| 213 | var buf: [proto.attach_max_len]u8 = undefined; | 536 | var buf: [proto.attach_max_len]u8 = undefined; |
| 214 | try wire.send(.attach, proto.encodeAttachNamed(&buf, if (self.opts.existing_only) 0 else self.opts.cols, if (self.opts.existing_only) 0 else self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); | 537 | try wire.send(.attach, proto.encodeAttachNamed(&buf, if (self.opts.existing_only) 0 else self.opts.cols, if (self.opts.existing_only) 0 else self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); |
| 215 | } | 538 | } |
| 216 | 539 | ||
| 217 | fn mail(self: *Pump, wire: *Wire) !void { | 540 | fn mail(self: *Pump, wire: *Wire, allow_wheel: bool) !void { |
| 218 | drain(self.wake_pipe[0]); | 541 | drain(self.wake_pipe[0]); |
| 219 | self.mailbox_mu.lock(); | 542 | self.mailbox_mu.lock(); |
| 220 | var messages = self.mailbox; | 543 | var messages = self.mailbox; |
| 221 | self.mailbox = .empty; | 544 | self.mailbox = .empty; |
| 222 | self.mailbox_mu.unlock(); | 545 | self.mailbox_mu.unlock(); |
| 546 | var consumed = messages.items.len; | ||
| 223 | defer { | 547 | defer { |
| 224 | for (messages.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 548 | for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 225 | messages.deinit(self.alloc); | 549 | messages.deinit(self.alloc); |
| 226 | } | 550 | } |
| 227 | for (messages.items) |msg| switch (msg) { | 551 | try self.flushSelectionClear(wire); |
| 228 | .end => |req| { | 552 | for (messages.items, 0..) |msg, i| { |
| 229 | self.expireEnd(); | 553 | if (msg == .wheel and !allow_wheel) { |
| 554 | // Keep the wheel and following input ordered, while allowing | ||
| 555 | // earlier resize/key/end work between bounded receive batches. | ||
| 556 | self.mailbox_mu.lock(); | ||
| 557 | defer self.mailbox_mu.unlock(); | ||
| 558 | try self.mailbox.insertSlice(self.alloc, 0, messages.items[i..]); | ||
| 559 | consumed = i; | ||
| 560 | ring(self.wake_pipe[1], 1); | ||
| 561 | break; | ||
| 562 | } | ||
| 563 | switch (msg) { | ||
| 564 | .end => |req| { | ||
| 565 | self.expireEnd(); | ||
| 566 | self.mu.lock(); | ||
| 567 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; | ||
| 568 | self.mu.unlock(); | ||
| 569 | if (!pending) continue; | ||
| 570 | self.mu.lock(); | ||
| 571 | const admitted = self.admitted; | ||
| 572 | self.mu.unlock(); | ||
| 573 | if (!admitted) { | ||
| 574 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); | ||
| 575 | continue; | ||
| 576 | } | ||
| 577 | var buf: [proto.end_req_max_len]u8 = undefined; | ||
| 578 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); | ||
| 579 | }, | ||
| 580 | .input => |bytes| { | ||
| 581 | self.mu.lock(); | ||
| 582 | const scrolled = self.scroll_rows != 0; | ||
| 583 | self.selection_revision +%= 1; | ||
| 584 | self.invalidateSelectionLocked(); | ||
| 585 | self.returnLiveLocked(); | ||
| 586 | self.mu.unlock(); | ||
| 587 | if (scrolled) self.wake(); | ||
| 588 | try wire.send(.input, bytes); | ||
| 589 | }, | ||
| 590 | .wheel => |wheel| try self.routeWheel(wire, wheel), | ||
| 591 | .mouse => |mouse| try self.routeMouse(wire, mouse), | ||
| 592 | .selection => |req| { | ||
| 593 | self.mu.lock(); | ||
| 594 | const payload = self.beginSelectionLocked(req); | ||
| 595 | self.mu.unlock(); | ||
| 596 | if (payload) |bytes| try wire.send(.selection_req, &bytes); | ||
| 597 | }, | ||
| 598 | .resize => |size| { | ||
| 599 | try self.releaseMouse(wire); | ||
| 600 | self.mu.lock(); | ||
| 601 | self.mouse_generation +%= 1; | ||
| 602 | self.invalidateSelectionLocked(); | ||
| 603 | self.selection_revision +%= 1; | ||
| 604 | self.returnLiveLocked(); | ||
| 605 | self.mu.unlock(); | ||
| 606 | self.opts.cols = size.cols; | ||
| 607 | self.opts.rows = size.rows; | ||
| 608 | self.mu.lock(); | ||
| 609 | const admitted = self.admitted; | ||
| 610 | self.mu.unlock(); | ||
| 611 | if (!self.opts.existing_only or admitted) { | ||
| 612 | const buf = proto.encodeSize(size.cols, size.rows); | ||
| 613 | try wire.send(.resize, &buf); | ||
| 614 | } | ||
| 615 | }, | ||
| 616 | .quit, .detach => unreachable, | ||
| 617 | } | ||
| 618 | } | ||
| 619 | // Input/resize can retire a tracker while this batch is being sent. | ||
| 620 | // Flush after the batch as well so a quiet connection still receives | ||
| 621 | // the bounded clear request. | ||
| 622 | try self.flushSelectionClear(wire); | ||
| 623 | } | ||
| 624 | |||
| 625 | fn writeMouse(wire: *Wire, event: Mouse) !bool { | ||
| 626 | const modes = event.token.modes; | ||
| 627 | if (event.kind == .release and modes.mouse_x10 and !modes.mouse_normal and !modes.mouse_button and !modes.mouse_any) return false; | ||
| 628 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | ||
| 629 | const bytes = client.keymap.encodeMouse(mouseFormat(modes), event.button + @as(u8, if (event.kind == .motion) 32 else 0), event.kind == .release, event.col, event.row, event.pixel_x, event.pixel_y, event.mods, &seq); | ||
| 630 | if (bytes.len == 0) return false; | ||
| 631 | try wire.send(.input, bytes); | ||
| 632 | return true; | ||
| 633 | } | ||
| 634 | fn releaseMouse(self: *Pump, wire: *Wire) !void { | ||
| 635 | var event = self.mouse_active orelse return; | ||
| 636 | self.mouse_active = null; | ||
| 637 | event.kind = .release; | ||
| 638 | _ = try writeMouse(wire, event); | ||
| 639 | } | ||
| 640 | fn reconcileMouse(self: *Pump, wire: *Wire) !void { | ||
| 641 | if (self.mouse_active) |active| { | ||
| 642 | if (!self.mouseFresh(active.token) or self.closing.load(.acquire)) try self.releaseMouse(wire); | ||
| 643 | } | ||
| 644 | } | ||
| 645 | fn routeMouse(self: *Pump, wire: *Wire, event: Mouse) !void { | ||
| 646 | try self.reconcileMouse(wire); | ||
| 647 | if (!self.mouseFresh(event.token) or !event.token.modes.appMouse() or event.button > 3) return; | ||
| 648 | switch (event.kind) { | ||
| 649 | .press => { | ||
| 650 | if (event.button == 3) return; | ||
| 651 | try self.releaseMouse(wire); | ||
| 230 | self.mu.lock(); | 652 | self.mu.lock(); |
| 231 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; | 653 | self.returnLiveLocked(); |
| 654 | self.invalidateSelectionLocked(); | ||
| 655 | self.selection_revision +%= 1; | ||
| 232 | self.mu.unlock(); | 656 | self.mu.unlock(); |
| 233 | if (!pending) continue; | 657 | if (try writeMouse(wire, event)) self.mouse_active = event; |
| 234 | if (!self.admitted) { | 658 | self.wake(); |
| 235 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); | ||
| 236 | continue; | ||
| 237 | } | ||
| 238 | var buf: [proto.end_req_max_len]u8 = undefined; | ||
| 239 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); | ||
| 240 | }, | 659 | }, |
| 241 | .input => |bytes| try wire.send(.input, bytes), | 660 | .motion => { |
| 242 | .resize => |size| { | 661 | const modes = event.token.modes; |
| 243 | self.opts.cols = size.cols; | 662 | if (self.mouse_active) |active| { |
| 244 | self.opts.rows = size.rows; | 663 | if (active.button != event.button or (!modes.mouse_any and !modes.mouse_button)) return; |
| 245 | if (!self.opts.existing_only or self.admitted) { | 664 | const same = if (modes.mouse_sgr_pixels) active.pixel_x == event.pixel_x and active.pixel_y == event.pixel_y else active.col == event.col and active.row == event.row; |
| 246 | const buf = proto.encodeSize(size.cols, size.rows); | 665 | if (same) return; |
| 247 | try wire.send(.resize, &buf); | 666 | if (try writeMouse(wire, event)) self.mouse_active = event; |
| 667 | } else if (event.button == 3 and modes.mouse_any) { | ||
| 668 | _ = try writeMouse(wire, event); | ||
| 248 | } | 669 | } |
| 249 | }, | 670 | }, |
| 250 | .quit, .detach => unreachable, | 671 | .release => { |
| 251 | }; | 672 | const active = self.mouse_active orelse return; |
| 673 | if (event.button != active.button) return; | ||
| 674 | // If a legacy release is outside its encoding range, release | ||
| 675 | // at the last representable point instead of leaving it held. | ||
| 676 | if (!try writeMouse(wire, event)) try self.releaseMouse(wire); | ||
| 677 | self.mouse_active = null; | ||
| 678 | }, | ||
| 679 | } | ||
| 680 | } | ||
| 681 | |||
| 682 | fn routeWheel(self: *Pump, wire: *Wire, wheel: Wheel) !void { | ||
| 683 | self.mu.lock(); | ||
| 684 | if (!self.admitted or self.status.phase != .attached or wheel.notches == 0) { | ||
| 685 | self.mu.unlock(); | ||
| 686 | return; | ||
| 687 | } | ||
| 688 | const modes = self.core.terminal_modes; | ||
| 689 | const arrows = modes.alt_screen and self.scroll_rows == 0; | ||
| 690 | if (!modes.appMouse() and !arrows) { | ||
| 691 | const rows: u32 = @intCast(@min(@as(u64, @abs(wheel.notches)) * 3, std.math.maxInt(u32))); | ||
| 692 | const next = if (wheel.notches > 0) @min(self.scroll_rows +| rows, self.replica.history_rows) else self.scroll_rows -| rows; | ||
| 693 | if (next != self.scroll_rows) { | ||
| 694 | if (next == 0) self.returnLiveLocked() else { | ||
| 695 | self.scroll_rows = next; | ||
| 696 | self.history_revision +%= 1; | ||
| 697 | self.history_dirty = true; | ||
| 698 | } | ||
| 699 | } | ||
| 700 | self.mu.unlock(); | ||
| 701 | self.wake(); | ||
| 702 | return; | ||
| 703 | } | ||
| 704 | self.returnLiveLocked(); | ||
| 705 | self.invalidateSelectionLocked(); | ||
| 706 | self.selection_revision +%= 1; | ||
| 707 | self.mu.unlock(); | ||
| 708 | self.wake(); | ||
| 709 | var seq: [client.keymap.mouse_max_seq_len]u8 = undefined; | ||
| 710 | const bytes = if (modes.appMouse()) client.keymap.encodeWheel( | ||
| 711 | mouseFormat(modes), | ||
| 712 | wheel.notches > 0, | ||
| 713 | wheel.col, | ||
| 714 | wheel.row, | ||
| 715 | wheel.pixel_x, | ||
| 716 | wheel.pixel_y, | ||
| 717 | wheel.mods, | ||
| 718 | &seq, | ||
| 719 | ) else client.keymap.encodeWheelArrow(wheel.notches > 0, modes.cursor_keys, &seq); | ||
| 720 | if (bytes.len == 0) return; | ||
| 721 | // Bound a single mailbox event even if an input adapter supplies an | ||
| 722 | // extreme delta. Ordinary wheels are one or a few notches per event. | ||
| 723 | var left: u32 = @as(u32, @intCast(@min(@abs(wheel.notches), 1024))) * @as(u32, if (modes.appMouse()) 1 else 3); | ||
| 724 | var batch: [1024]u8 = undefined; | ||
| 725 | while (left != 0) { | ||
| 726 | const n = @min(left, batch.len / bytes.len); | ||
| 727 | for (0..n) |i| @memcpy(batch[i * bytes.len ..][0..bytes.len], bytes); | ||
| 728 | try wire.send(.input, batch[0 .. n * bytes.len]); | ||
| 729 | left -= @intCast(n); | ||
| 730 | } | ||
| 731 | } | ||
| 732 | |||
| 733 | /// Drain ready stream bytes before interpreting semantic mouse intent. | ||
| 734 | /// A partial header is progress, not proof that the next mode frame is absent. | ||
| 735 | const ReadState = enum { idle, busy, closed, ended }; | ||
| 736 | fn receiveReady(self: *Pump, wire: *Wire) !ReadState { | ||
| 737 | var changed = false; | ||
| 738 | defer if (changed) self.wake(); | ||
| 739 | var frames: usize = 0; | ||
| 740 | for (0..256) |_| { | ||
| 741 | var fd = [_]std.posix.pollfd{.{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 742 | _ = try std.posix.poll(&fd, 0); | ||
| 743 | if (fd[0].revents == 0 and wire.tr.link != .quic) return .idle; | ||
| 744 | const before = wire.input.items.len; | ||
| 745 | switch (try wire.read()) { | ||
| 746 | .closed => return .closed, | ||
| 747 | .incomplete => if (wire.input.items.len == before) return .idle, | ||
| 748 | .frame => |frame| { | ||
| 749 | defer frame.deinit(self.alloc); | ||
| 750 | const action = try self.onFrame(frame.type, frame.payload); | ||
| 751 | var admitted_now = false; | ||
| 752 | self.mu.lock(); | ||
| 753 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { | ||
| 754 | self.admitted = true; | ||
| 755 | admitted_now = true; | ||
| 756 | } | ||
| 757 | self.mu.unlock(); | ||
| 758 | if (admitted_now and self.opts.existing_only) { | ||
| 759 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | ||
| 760 | try wire.send(.resize, &size); | ||
| 761 | } | ||
| 762 | changed = changed or action != .skip; | ||
| 763 | switch (action) { | ||
| 764 | .resync => try self.attach(wire, true), | ||
| 765 | .end => return .ended, | ||
| 766 | else => {}, | ||
| 767 | } | ||
| 768 | frames += 1; | ||
| 769 | if (frames == 64) return .busy; | ||
| 770 | }, | ||
| 771 | } | ||
| 772 | } | ||
| 773 | return .busy; | ||
| 252 | } | 774 | } |
| 253 | 775 | ||
| 254 | fn connected(self: *Pump, wire: *Wire) !bool { | 776 | fn connected(self: *Pump, wire: *Wire) !bool { |
| 255 | try self.attach(wire, false); | 777 | try self.attach(wire, false); |
| 256 | var eager = wire.tr.link == .quic; | ||
| 257 | while (true) { | 778 | while (true) { |
| 258 | self.expireEnd(); | 779 | self.expireEnd(); |
| 259 | try self.mail(wire); | 780 | self.expireSelection(); |
| 781 | wire.tr.service(); | ||
| 782 | var busy = false; | ||
| 783 | if (!self.closing.load(.acquire)) switch (try self.receiveReady(wire)) { | ||
| 784 | .closed => return false, | ||
| 785 | .ended => return true, | ||
| 786 | .busy => busy = true, | ||
| 787 | .idle => {}, | ||
| 788 | }; | ||
| 789 | try self.reconcileMouse(wire); | ||
| 790 | try self.mail(wire, !busy); | ||
| 791 | try self.requestHistory(wire); | ||
| 260 | if (self.closing.load(.acquire)) { | 792 | if (self.closing.load(.acquire)) { |
| 261 | try wire.send(.detach, ""); | 793 | try wire.send(.detach, ""); |
| 262 | // A responsive peer receives detach; a stalled peer cannot | 794 | // A responsive peer receives detach; a stalled peer cannot |
| @@ -276,41 +808,10 @@ pub const Pump = struct { | |||
| 276 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, | 808 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, |
| 277 | .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, | 809 | .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, |
| 278 | }; | 810 | }; |
| 279 | _ = try std.posix.poll(&fds, self.endWaitMs(wire.tr.timeoutMs(if (eager) 0 else 1000))); | 811 | _ = try std.posix.poll(&fds, self.endWaitMs(wire.tr.timeoutMs(if (busy) 0 else 1000))); |
| 280 | wire.tr.service(); | 812 | wire.tr.service(); |
| 281 | if (fds[2].revents != 0) wire.tr.drainErr(); | 813 | if (fds[2].revents != 0) wire.tr.drainErr(); |
| 282 | if (fds[3].revents != 0) try wire.flush(); | 814 | if (fds[3].revents != 0) try wire.flush(); |
| 283 | eager = false; | ||
| 284 | if (fds[0].revents != 0 or wire.tr.link == .quic) { | ||
| 285 | var changed = false; | ||
| 286 | defer if (changed) self.wake(); | ||
| 287 | const budget: usize = if (wire.tr.link == .quic) 64 else 1; | ||
| 288 | for (0..budget) |i| { | ||
| 289 | const incoming = try wire.read(); | ||
| 290 | switch (incoming) { | ||
| 291 | .closed => return false, | ||
| 292 | .incomplete => break, | ||
| 293 | .frame => |frame| { | ||
| 294 | defer frame.deinit(self.alloc); | ||
| 295 | const action = try self.onFrame(frame.type, frame.payload); | ||
| 296 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { | ||
| 297 | self.admitted = true; | ||
| 298 | if (self.opts.existing_only) { | ||
| 299 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | ||
| 300 | try wire.send(.resize, &size); | ||
| 301 | } | ||
| 302 | } | ||
| 303 | changed = changed or action != .skip; | ||
| 304 | switch (action) { | ||
| 305 | .resync => try self.attach(wire, true), | ||
| 306 | .end => return true, | ||
| 307 | else => {}, | ||
| 308 | } | ||
| 309 | if (i + 1 == budget and wire.tr.link == .quic) eager = true; | ||
| 310 | }, | ||
| 311 | } | ||
| 312 | } | ||
| 313 | } | ||
| 314 | } | 815 | } |
| 315 | } | 816 | } |
| 316 | 817 | ||
| @@ -318,7 +819,9 @@ pub const Pump = struct { | |||
| 318 | fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { | 819 | fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { |
| 319 | self.mu.lock(); | 820 | self.mu.lock(); |
| 320 | defer self.mu.unlock(); | 821 | defer self.mu.unlock(); |
| 822 | if (kind != .selection_reply) self.history_waiting_metadata = false; | ||
| 321 | switch (kind) { | 823 | switch (kind) { |
| 824 | .scrollback_chunk => return self.historyReplyLocked(payload), | ||
| 322 | .end_reply => { | 825 | .end_reply => { |
| 323 | if (self.status.ending.phase != .pending) return .skip; | 826 | if (self.status.ending.phase != .pending) return .skip; |
| 324 | if (proto.parseEndReply(payload)) |reply| { | 827 | if (proto.parseEndReply(payload)) |reply| { |
| @@ -328,11 +831,31 @@ pub const Pump = struct { | |||
| 328 | }, | 831 | }, |
| 329 | .snapshot, .delta => { | 832 | .snapshot, .delta => { |
| 330 | const begin = std.time.nanoTimestamp(); | 833 | const begin = std.time.nanoTimestamp(); |
| 834 | const old_epoch = self.replica.session_epoch; | ||
| 835 | const old_cols = self.grid.cols; | ||
| 836 | const old_rows = self.grid.rows; | ||
| 331 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { | 837 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { |
| 332 | error.BadPayload => return .skip, | 838 | error.BadPayload => return .skip, |
| 333 | else => return err, | 839 | else => return err, |
| 334 | }; | 840 | }; |
| 335 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | 841 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); |
| 842 | // Output and scrollback growth preserve a tracked source. Only | ||
| 843 | // a resync, session epoch, or geometry change invalidates its | ||
| 844 | // coordinates; the follow-state reply remaps ordinary output. | ||
| 845 | if (applied == .resync or self.replica.session_epoch != old_epoch or | ||
| 846 | self.grid.cols != old_cols or self.grid.rows != old_rows) | ||
| 847 | { | ||
| 848 | self.selection_revision +%= 1; | ||
| 849 | self.invalidateSelectionLocked(); | ||
| 850 | } | ||
| 851 | if (self.replica.session_epoch != old_epoch or self.grid.cols != old_cols or self.grid.rows != old_rows) self.mouse_generation +%= 1; | ||
| 852 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); | ||
| 853 | if (self.scroll_rows != 0) { | ||
| 854 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); | ||
| 855 | self.history_revision +%= 1; | ||
| 856 | self.history_dirty = self.scroll_rows != 0; | ||
| 857 | if (self.scroll_rows == 0) self.returnLiveLocked(); | ||
| 858 | } | ||
| 336 | if (applied == .resync) return .resync; | 859 | if (applied == .resync) return .resync; |
| 337 | if (kind == .snapshot) self.snapshot_ready = true; | 860 | if (kind == .snapshot) self.snapshot_ready = true; |
| 338 | self.setState(.attached, 0, ""); | 861 | self.setState(.attached, 0, ""); |
| @@ -347,12 +870,84 @@ pub const Pump = struct { | |||
| 347 | self.setState(.taken, 0, "session taken over"); | 870 | self.setState(.taken, 0, "session taken over"); |
| 348 | return .end; | 871 | return .end; |
| 349 | }, | 872 | }, |
| 873 | .selection_reply => { | ||
| 874 | const reply = proto.decodeSelectionReply(payload) catch return .skip; | ||
| 875 | if (reply.seq == self.replica.last_seq) { | ||
| 876 | self.follow_seq = reply.seq; | ||
| 877 | self.follow_source = reply.source; | ||
| 878 | // The server emits this position metadata immediately after | ||
| 879 | // a history chunk. Bind that chunk to the source that was | ||
| 880 | // actually rendered; never borrow a later live source. | ||
| 881 | if (self.history != null and self.history_waiting_metadata and reply.id == 0) { | ||
| 882 | self.history_version.source = reply.source; | ||
| 883 | self.history_version.seq = reply.seq; | ||
| 884 | self.history_version.history_rows = reply.history_rows; | ||
| 885 | self.history_waiting_metadata = false; | ||
| 886 | } | ||
| 887 | if (reply.gesture != 0) { | ||
| 888 | const matches = self.selection_gesture == reply.gesture; | ||
| 889 | if (matches) self.follow_position = .{ | ||
| 890 | .id = reply.gesture, | ||
| 891 | .seq = reply.seq, | ||
| 892 | .source = reply.source, | ||
| 893 | .history_rows = reply.history_rows, | ||
| 894 | .status = if (reply.status == .too_large) .ok else reply.status, | ||
| 895 | .anchor = reply.anchor, | ||
| 896 | .active = reply.active, | ||
| 897 | }; | ||
| 898 | } else if (self.selection_gesture != 0) { | ||
| 899 | // A zero-gesture state is the daemon's explicit | ||
| 900 | // tracker retirement (metadata before a gesture has | ||
| 901 | // no tracker to retire). | ||
| 902 | self.follow_position = null; | ||
| 903 | } | ||
| 904 | } | ||
| 905 | const pending = self.selection_pending orelse return .changed; | ||
| 906 | const gesture = if (pending.gesture == 0) pending.id else pending.gesture; | ||
| 907 | if (reply.id != pending.id or reply.gesture != gesture) return .changed; | ||
| 908 | const pending_alive = if (self.selection_pending_copy) | ||
| 909 | self.selectionAliveLocked(pending.version) | ||
| 910 | else | ||
| 911 | self.selectionFreshLocked(pending.version); | ||
| 912 | if (!pending_alive or reply.seq != self.replica.last_seq or | ||
| 913 | (!self.selection_pending_copy and pending.version.source != reply.source)) | ||
| 914 | { | ||
| 915 | self.selection_pending = null; | ||
| 916 | return .changed; | ||
| 917 | } | ||
| 918 | const text = try self.alloc.dupe(u8, reply.text); | ||
| 919 | self.selection_pending = null; | ||
| 920 | if (self.selection_result) |old| self.alloc.free(old.text); | ||
| 921 | self.selection_result = .{ .id = reply.id, .gesture = gesture, .status = reply.status, .text = text, .version = pending.version }; | ||
| 922 | return .changed; | ||
| 923 | }, | ||
| 350 | else => switch (self.core.receive(kind, payload)) { | 924 | else => switch (self.core.receive(kind, payload)) { |
| 351 | .effect => |effect| switch (effect) { | 925 | .effect => |effect| switch (effect) { |
| 352 | .bell => self.status.bell = true, | 926 | .bell => self.status.bell = true, |
| 353 | .clipboard_set => return .skip, | 927 | .clipboard_set => |clip| { |
| 928 | const decoded = client.core.decodeClipboard(self.alloc, clip.target, clip.base64) catch |err| switch (err) { | ||
| 929 | error.InvalidClipboard => return .skip, | ||
| 930 | else => return err, | ||
| 931 | } orelse return .skip; | ||
| 932 | if (!self.admitted or self.status.phase != .attached) { | ||
| 933 | self.alloc.free(decoded.text); | ||
| 934 | return .skip; | ||
| 935 | } | ||
| 936 | const index: usize = @intFromBool(decoded.primary); | ||
| 937 | if (self.clipboard[index]) |old| self.alloc.free(old); | ||
| 938 | self.clipboard[index] = decoded.text; | ||
| 939 | }, | ||
| 940 | }, | ||
| 941 | .state => { | ||
| 942 | self.mouse_generation +%= 1; | ||
| 943 | self.invalidateSelectionLocked(); | ||
| 944 | self.selection_revision +%= 1; | ||
| 945 | if (self.scroll_rows != 0) { | ||
| 946 | self.history_revision +%= 1; | ||
| 947 | self.history_dirty = true; | ||
| 948 | } | ||
| 949 | return .changed; | ||
| 354 | }, | 950 | }, |
| 355 | .state => return .changed, | ||
| 356 | else => return .skip, | 951 | else => return .skip, |
| 357 | }, | 952 | }, |
| 358 | } | 953 | } |
| @@ -371,11 +966,27 @@ pub const Pump = struct { | |||
| 371 | self.mu.unlock(); | 966 | self.mu.unlock(); |
| 372 | if (expired) self.wake(); | 967 | if (expired) self.wake(); |
| 373 | } | 968 | } |
| 969 | |||
| 970 | fn expireSelection(self: *Pump) void { | ||
| 971 | self.mu.lock(); | ||
| 972 | const expired = self.selection_pending != null and std.time.milliTimestamp() >= self.selection_until; | ||
| 973 | if (expired) { | ||
| 974 | const pending = self.selection_pending.?; | ||
| 975 | self.invalidateSelectionLocked(); | ||
| 976 | self.selection_result = .{ .id = pending.id, .gesture = if (pending.gesture == 0) pending.id else pending.gesture, .status = .unavailable, .text = &.{}, .version = pending.version }; | ||
| 977 | } | ||
| 978 | self.mu.unlock(); | ||
| 979 | if (expired) self.wake(); | ||
| 980 | } | ||
| 374 | fn endWaitMs(self: *Pump, cap: i32) i32 { | 981 | fn endWaitMs(self: *Pump, cap: i32) i32 { |
| 375 | self.mu.lock(); | 982 | self.mu.lock(); |
| 376 | defer self.mu.unlock(); | 983 | defer self.mu.unlock(); |
| 377 | if (self.status.ending.phase != .pending) return cap; | 984 | const now = std.time.milliTimestamp(); |
| 378 | return @intCast(@min(cap, @max(0, self.end_until - std.time.milliTimestamp()))); | 985 | var wait: i64 = cap; |
| 986 | if (self.status.ending.phase == .pending) wait = @min(wait, @max(0, self.end_until - now)); | ||
| 987 | if (self.selection_pending != null) wait = @min(wait, @max(0, self.selection_until - now)); | ||
| 988 | if (self.history_pending) |req| wait = @min(wait, @max(0, req.until - now)); | ||
| 989 | return @intCast(wait); | ||
| 379 | } | 990 | } |
| 380 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { | 991 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { |
| 381 | self.mu.lock(); | 992 | self.mu.lock(); |
| @@ -988,3 +1599,524 @@ test "reconnecting SSH authentication refusal is terminal after an established p | |||
| 988 | defer a.free(runs); | 1599 | defer a.free(runs); |
| 989 | try std.testing.expectEqual(@as(usize, 2), runs.len); | 1600 | try std.testing.expectEqual(@as(usize, 2), runs.len); |
| 990 | } | 1601 | } |
| 1602 | |||
| 1603 | // Exercise the production mailbox admission and frame decoder synchronously; | ||
| 1604 | // these tests own actual pipes/grid memory but need no transport peer or GUI. | ||
| 1605 | fn selectionTestPump() !*Pump { | ||
| 1606 | const a = std.testing.allocator; | ||
| 1607 | const g = try term.grid.Grid.init(a, 11, 3); | ||
| 1608 | errdefer g.deinit(); | ||
| 1609 | const wp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1610 | errdefer closePipe(wp); | ||
| 1611 | const cp = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1612 | errdefer closePipe(cp); | ||
| 1613 | const p = try a.create(Pump); | ||
| 1614 | p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp }; | ||
| 1615 | _ = try p.onFrame(.snapshot, &testSnapshot()); | ||
| 1616 | // The unified native path requires a nonzero follow source. Tests model | ||
| 1617 | // the metadata frame that the daemon sends after the initial snapshot. | ||
| 1618 | p.follow_seq = p.replica.last_seq; | ||
| 1619 | p.follow_source = 1; | ||
| 1620 | return p; | ||
| 1621 | } | ||
| 1622 | fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest { | ||
| 1623 | p.mu.lock(); | ||
| 1624 | const version = p.selectionVersionLocked(); | ||
| 1625 | p.mu.unlock(); | ||
| 1626 | try p.say(.{ .selection = .{ .id = id, .anchor = .{ .row = 0, .col = 0 }, .active = .{ .row = 1, .col = 3 }, .version = version } }); | ||
| 1627 | return p.mailbox.items[p.mailbox.items.len - 1].selection; | ||
| 1628 | } | ||
| 1629 | fn selectionTestBegin(p: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { | ||
| 1630 | p.mu.lock(); | ||
| 1631 | defer p.mu.unlock(); | ||
| 1632 | return p.beginSelectionLocked(req); | ||
| 1633 | } | ||
| 1634 | fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void { | ||
| 1635 | var payload: std.ArrayList(u8) = .empty; | ||
| 1636 | defer payload.deinit(std.testing.allocator); | ||
| 1637 | p.mu.lock(); | ||
| 1638 | const history_rows = p.replica.history_rows; | ||
| 1639 | const gesture = if (p.selection_pending) |pending| if (pending.gesture == 0) pending.id else pending.gesture else id; | ||
| 1640 | p.mu.unlock(); | ||
| 1641 | try proto.encodeSelectionReply(&payload, std.testing.allocator, .{ .id = id, .gesture = gesture, .seq = p.replica.last_seq, .source = p.follow_source, .history_rows = history_rows, .status = .ok, .text = text }); | ||
| 1642 | _ = try p.onFrame(.selection_reply, payload.items); | ||
| 1643 | @memset(payload.items, 0xaa); // The published result must own its text. | ||
| 1644 | } | ||
| 1645 | fn selectionTestFollowMetadata(p: *Pump, source: u64) !void { | ||
| 1646 | var payload: std.ArrayList(u8) = .empty; | ||
| 1647 | defer payload.deinit(std.testing.allocator); | ||
| 1648 | try proto.encodeSelectionReply(&payload, std.testing.allocator, .{ | ||
| 1649 | .id = 0, | ||
| 1650 | .gesture = 0, | ||
| 1651 | .seq = p.replica.last_seq, | ||
| 1652 | .source = source, | ||
| 1653 | .history_rows = p.replica.history_rows, | ||
| 1654 | .status = .ok, | ||
| 1655 | .text = "", | ||
| 1656 | }); | ||
| 1657 | _ = try p.onFrame(.selection_reply, payload.items); | ||
| 1658 | } | ||
| 1659 | |||
| 1660 | test "follow selection sends a source-bound start then a gesture-bound copy" { | ||
| 1661 | const p = try selectionTestPump(); | ||
| 1662 | defer p.stop(); | ||
| 1663 | p.admitted = true; | ||
| 1664 | p.follow_seq = p.replica.last_seq; | ||
| 1665 | p.follow_source = 91; | ||
| 1666 | |||
| 1667 | const version = p.selectionVersionLocked(); | ||
| 1668 | const first: SelectionRequest = .{ | ||
| 1669 | .id = 7, | ||
| 1670 | .gesture = 42, | ||
| 1671 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1672 | .active = .{ .row = 1, .col = 3 }, | ||
| 1673 | .version = version, | ||
| 1674 | .ticket = p.selection_ticket, | ||
| 1675 | }; | ||
| 1676 | const start = p.beginSelectionLocked(first).?; | ||
| 1677 | const start_req = try proto.decodeSelectionReq(&start); | ||
| 1678 | try std.testing.expectEqual(.start, start_req.action); | ||
| 1679 | try std.testing.expectEqual(@as(u32, 7), start_req.id); | ||
| 1680 | try std.testing.expectEqual(@as(u32, 42), start_req.gesture); | ||
| 1681 | try std.testing.expectEqual(@as(u64, 91), start_req.source); | ||
| 1682 | |||
| 1683 | const second = SelectionRequest{ .id = 8, .gesture = 42, .anchor = first.anchor, .active = first.active, .version = version, .ticket = p.selection_ticket }; | ||
| 1684 | const copy = p.beginSelectionLocked(second).?; | ||
| 1685 | const copy_req = try proto.decodeSelectionReq(©); | ||
| 1686 | try std.testing.expectEqual(.copy, copy_req.action); | ||
| 1687 | try std.testing.expectEqual(@as(u32, 8), copy_req.id); | ||
| 1688 | try std.testing.expectEqual(@as(u32, 42), copy_req.gesture); | ||
| 1689 | } | ||
| 1690 | |||
| 1691 | test "selection cancels queued work but retains live requests through redraw" { | ||
| 1692 | const p = try selectionTestPump(); | ||
| 1693 | defer p.stop(); | ||
| 1694 | const cancelled = try selectionTestQueue(p, 1); | ||
| 1695 | p.cancelSelection(); | ||
| 1696 | try std.testing.expect(selectionTestBegin(p, cancelled) == null); | ||
| 1697 | const before_redraw = try selectionTestQueue(p, 2); | ||
| 1698 | var newer = testSnapshot(); | ||
| 1699 | std.mem.writeInt(u64, newer[0..8], 38, .little); | ||
| 1700 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1701 | p.follow_seq = p.replica.last_seq; | ||
| 1702 | p.follow_source = 1; | ||
| 1703 | try std.testing.expect(selectionTestBegin(p, before_redraw) != null); | ||
| 1704 | const current = try selectionTestQueue(p, 3); | ||
| 1705 | const bytes = selectionTestBegin(p, current).?; | ||
| 1706 | const wire = try proto.decodeSelectionReq(&bytes); | ||
| 1707 | try std.testing.expectEqual(@as(u32, 3), wire.id); | ||
| 1708 | try std.testing.expectEqualDeep(current.active, wire.active); | ||
| 1709 | } | ||
| 1710 | |||
| 1711 | test "selection results own UTF-8, newest request wins, and redraw preserves a decoded result" { | ||
| 1712 | const p = try selectionTestPump(); | ||
| 1713 | defer p.stop(); | ||
| 1714 | _ = selectionTestBegin(p, try selectionTestQueue(p, 1)).?; | ||
| 1715 | _ = selectionTestBegin(p, try selectionTestQueue(p, 2)).?; | ||
| 1716 | try selectionTestReply(p, 1, "old"); | ||
| 1717 | try std.testing.expect(p.takeSelection() == null); | ||
| 1718 | const text = try std.testing.allocator.alloc(u8, 70 * 1024); | ||
| 1719 | defer std.testing.allocator.free(text); | ||
| 1720 | @memset(text, 'x'); // Native copy does not inherit the OSC 52 base64 cap. | ||
| 1721 | try selectionTestReply(p, 2, text); | ||
| 1722 | const copied = p.takeSelection().?; | ||
| 1723 | defer std.testing.allocator.free(copied.text); | ||
| 1724 | try std.testing.expectEqualStrings(text, copied.text); | ||
| 1725 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; | ||
| 1726 | try selectionTestReply(p, 3, "café"); | ||
| 1727 | var redraw = testSnapshot(); | ||
| 1728 | std.mem.writeInt(u64, redraw[0..8], 38, .little); | ||
| 1729 | _ = try p.onFrame(.snapshot, &redraw); | ||
| 1730 | const retained = p.takeSelection().?; | ||
| 1731 | defer std.testing.allocator.free(retained.text); | ||
| 1732 | try std.testing.expectEqualStrings("café", retained.text); | ||
| 1733 | } | ||
| 1734 | |||
| 1735 | test "live selection survives pending redraw; history and geometry remain guarded" { | ||
| 1736 | const p = try selectionTestPump(); | ||
| 1737 | defer p.stop(); | ||
| 1738 | const req = try selectionTestQueue(p, 1); | ||
| 1739 | _ = selectionTestBegin(p, req).?; | ||
| 1740 | var newer = testSnapshot(); | ||
| 1741 | std.mem.writeInt(u64, newer[0..8], 38, .little); | ||
| 1742 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1743 | p.follow_seq = p.replica.last_seq; | ||
| 1744 | p.follow_source = 1; | ||
| 1745 | try std.testing.expect(p.selectionFresh(req.version)); | ||
| 1746 | try selectionTestReply(p, 1, "current text"); | ||
| 1747 | const result = p.takeSelection().?; | ||
| 1748 | defer std.testing.allocator.free(result.text); | ||
| 1749 | try std.testing.expectEqualStrings("current text", result.text); | ||
| 1750 | try std.testing.expectEqualDeep(req.version, result.version); | ||
| 1751 | |||
| 1752 | const before_history = try selectionTestQueue(p, 2); | ||
| 1753 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 39, .history_rows = 1, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1754 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1755 | try std.testing.expect(!p.selectionFresh(before_history.version)); | ||
| 1756 | try std.testing.expect(selectionTestBegin(p, before_history) == null); | ||
| 1757 | const before_resize = try selectionTestQueue(p, 3); | ||
| 1758 | // Empty fixture rows also decode at 12 cols. | ||
| 1759 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 40, .history_rows = 1, .cols = 12, .rows = 3, .epoch = 93 }); | ||
| 1760 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1761 | try std.testing.expect(!p.selectionFresh(before_resize.version)); | ||
| 1762 | try std.testing.expect(selectionTestBegin(p, before_resize) == null); | ||
| 1763 | } | ||
| 1764 | |||
| 1765 | test "selection mode changes, cancellation and timeout discard later replies" { | ||
| 1766 | const p = try selectionTestPump(); | ||
| 1767 | defer p.stop(); | ||
| 1768 | const req = try selectionTestQueue(p, 1); | ||
| 1769 | _ = selectionTestBegin(p, req).?; | ||
| 1770 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true })); | ||
| 1771 | try std.testing.expect(!p.selectionFresh(req.version)); | ||
| 1772 | try selectionTestReply(p, 1, "wrong screen"); | ||
| 1773 | try std.testing.expect(p.takeSelection() == null); | ||
| 1774 | _ = selectionTestBegin(p, try selectionTestQueue(p, 2)).?; | ||
| 1775 | p.cancelSelection(); | ||
| 1776 | try selectionTestReply(p, 2, "cleared"); | ||
| 1777 | try std.testing.expect(p.takeSelection() == null); | ||
| 1778 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; | ||
| 1779 | p.mu.lock(); | ||
| 1780 | p.selection_until = 0; | ||
| 1781 | p.mu.unlock(); | ||
| 1782 | p.expireSelection(); | ||
| 1783 | const expired = p.takeSelection().?; | ||
| 1784 | defer std.testing.allocator.free(expired.text); | ||
| 1785 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, expired.status); | ||
| 1786 | try selectionTestReply(p, 3, "too late"); | ||
| 1787 | try std.testing.expect(p.takeSelection() == null); | ||
| 1788 | try std.testing.expect(p.core.pending_selection_id == null); | ||
| 1789 | } | ||
| 1790 | |||
| 1791 | test "wheel history tombstones, refresh, resize and timeout preserve the live replica" { | ||
| 1792 | const p = try selectionTestPump(); | ||
| 1793 | defer p.stop(); | ||
| 1794 | p.replica.history_rows = 20; | ||
| 1795 | p.admitted = true; | ||
| 1796 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1797 | defer closePipe(incoming); | ||
| 1798 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1799 | defer closePipe(outgoing); | ||
| 1800 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 1801 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 1802 | defer wire.deinit(); | ||
| 1803 | const up: Wheel = .{ .notches = 1, .col = 2, .row = 1, .pixel_x = 22, .pixel_y = 18 }; | ||
| 1804 | const source_version = p.selectionVersionLocked(); | ||
| 1805 | try p.routeWheel(&wire, up); | ||
| 1806 | try std.testing.expect(p.selectionFresh(source_version)); | ||
| 1807 | try p.requestHistory(&wire); | ||
| 1808 | const first = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1809 | defer first.deinit(std.testing.allocator); | ||
| 1810 | try std.testing.expectEqual(proto.MsgType.fetch_scrollback, first.type); | ||
| 1811 | try std.testing.expectEqualDeep(proto.ScrollbackReq{ .start = 17, .count = 3 }, try proto.decodeScrollbackReq(first.payload)); | ||
| 1812 | const revision = p.history_pending.?.revision; | ||
| 1813 | try p.say(.{ .input = "live" }); | ||
| 1814 | try p.mail(&wire, true); | ||
| 1815 | const typed = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1816 | defer typed.deinit(std.testing.allocator); | ||
| 1817 | try std.testing.expectEqualStrings("live", typed.payload); | ||
| 1818 | try std.testing.expect(!p.selectionFresh(source_version)); | ||
| 1819 | try std.testing.expect(p.history_pending != null); // Keep the cancelled on-wire request. | ||
| 1820 | const after_input = p.selectionVersionLocked(); | ||
| 1821 | try p.routeWheel(&wire, up); | ||
| 1822 | try p.requestHistory(&wire); | ||
| 1823 | try std.testing.expectEqual(revision, p.history_pending.?.revision); | ||
| 1824 | const chunk = [_]u8{ 17, 0, 0, 0, 1, 0, 0, 0 }; // one valid blank CellRow | ||
| 1825 | try std.testing.expectEqual(Pump.Action.skip, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1826 | try std.testing.expect(p.history == null); | ||
| 1827 | try p.requestHistory(&wire); | ||
| 1828 | const fresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1829 | defer fresh.deinit(std.testing.allocator); | ||
| 1830 | try std.testing.expect(p.history_pending.?.revision != revision); | ||
| 1831 | try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1832 | try selectionTestFollowMetadata(p, 1); | ||
| 1833 | try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); | ||
| 1834 | try std.testing.expect(p.viewGridLocked() != p.grid); | ||
| 1835 | try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); | ||
| 1836 | try std.testing.expect(p.selectionFresh(after_input)); | ||
| 1837 | const history_version = p.selectionVersionLocked(); | ||
| 1838 | _ = selectionTestBegin(p, try selectionTestQueue(p, 41)).?; | ||
| 1839 | p.mu.lock(); | ||
| 1840 | p.returnLiveLocked(); | ||
| 1841 | p.mu.unlock(); | ||
| 1842 | try selectionTestReply(p, 41, "kept through viewport"); | ||
| 1843 | const kept = p.takeSelection().?; | ||
| 1844 | defer std.testing.allocator.free(kept.text); | ||
| 1845 | try std.testing.expectEqualStrings("kept through viewport", kept.text); | ||
| 1846 | try std.testing.expectEqualDeep(history_version, kept.version); | ||
| 1847 | try p.routeWheel(&wire, up); | ||
| 1848 | try p.requestHistory(&wire); | ||
| 1849 | const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1850 | defer restored.deinit(std.testing.allocator); | ||
| 1851 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1852 | try selectionTestFollowMetadata(p, 1); | ||
| 1853 | try std.testing.expect(p.selectionFresh(history_version)); | ||
| 1854 | const history_request = try selectionTestQueue(p, 42); | ||
| 1855 | try std.testing.expect(history_request.version.history); | ||
| 1856 | _ = selectionTestBegin(p, history_request).?; | ||
| 1857 | p.mu.lock(); | ||
| 1858 | p.returnLiveLocked(); | ||
| 1859 | p.mu.unlock(); | ||
| 1860 | var live_redraw = testSnapshot(); | ||
| 1861 | proto.writeSnapshotPrefix(live_redraw[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1862 | _ = try p.onFrame(.snapshot, &live_redraw); | ||
| 1863 | try std.testing.expect(!p.selectionFresh(history_request.version)); | ||
| 1864 | try selectionTestReply(p, 42, "stale history"); | ||
| 1865 | try std.testing.expect(p.takeSelection() == null); | ||
| 1866 | try p.routeWheel(&wire, up); | ||
| 1867 | try p.requestHistory(&wire); | ||
| 1868 | const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1869 | defer after_live.deinit(std.testing.allocator); | ||
| 1870 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1871 | try selectionTestFollowMetadata(p, 1); | ||
| 1872 | var newer = testSnapshot(); | ||
| 1873 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 39, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1874 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1875 | try std.testing.expect(p.history_dirty); | ||
| 1876 | try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked())); | ||
| 1877 | try p.requestHistory(&wire); | ||
| 1878 | const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1879 | defer refresh.deinit(std.testing.allocator); | ||
| 1880 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1881 | try selectionTestFollowMetadata(p, 1); | ||
| 1882 | try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); | ||
| 1883 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true })); | ||
| 1884 | try std.testing.expect(p.history != null and p.history_dirty); | ||
| 1885 | try p.mail(&wire, true); | ||
| 1886 | const cleared = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1887 | defer cleared.deinit(std.testing.allocator); | ||
| 1888 | try std.testing.expectEqual(proto.MsgType.selection_req, cleared.type); | ||
| 1889 | try p.requestHistory(&wire); | ||
| 1890 | const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1891 | defer before_resize.deinit(std.testing.allocator); | ||
| 1892 | try p.say(.{ .resize = .{ .cols = 12, .rows = 4 } }); | ||
| 1893 | try p.mail(&wire, true); | ||
| 1894 | const resized = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1895 | defer resized.deinit(std.testing.allocator); | ||
| 1896 | try std.testing.expectEqual(proto.MsgType.resize, resized.type); | ||
| 1897 | try std.testing.expect(p.history_pending != null and p.history == null); | ||
| 1898 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1899 | try std.testing.expect(p.history == null); | ||
| 1900 | try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq); | ||
| 1901 | p.scroll_rows = 3; | ||
| 1902 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1903 | try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire)); | ||
| 1904 | try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 })); | ||
| 1905 | try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq); | ||
| 1906 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1907 | try p.attach(&wire, true); | ||
| 1908 | try std.testing.expect(p.history_pending != null); | ||
| 1909 | try std.testing.expectEqual(Pump.Action.skip, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1910 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1911 | try p.attach(&wire, false); | ||
| 1912 | try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0); | ||
| 1913 | } | ||
| 1914 | |||
| 1915 | test "selection tracker survives source movement while history source stays paired" { | ||
| 1916 | const p = try selectionTestPump(); | ||
| 1917 | defer p.stop(); | ||
| 1918 | p.admitted = true; | ||
| 1919 | |||
| 1920 | const start = try selectionTestQueue(p, 7); | ||
| 1921 | try std.testing.expect(p.selectionAlive(7, start.version)); | ||
| 1922 | _ = selectionTestBegin(p, start).?; | ||
| 1923 | |||
| 1924 | var position: std.ArrayList(u8) = .empty; | ||
| 1925 | defer position.deinit(std.testing.allocator); | ||
| 1926 | try proto.encodeSelectionReply(&position, std.testing.allocator, .{ | ||
| 1927 | .id = 0, | ||
| 1928 | .gesture = 7, | ||
| 1929 | .seq = p.replica.last_seq, | ||
| 1930 | .source = 1, | ||
| 1931 | .history_rows = p.replica.history_rows, | ||
| 1932 | .status = .ok, | ||
| 1933 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1934 | .active = .{ .row = 0, .col = 1 }, | ||
| 1935 | .text = "", | ||
| 1936 | }); | ||
| 1937 | _ = try p.onFrame(.selection_reply, position.items); | ||
| 1938 | var redraw = testSnapshot(); | ||
| 1939 | std.mem.writeInt(u64, redraw[0..8], p.replica.last_seq + 1, .little); | ||
| 1940 | _ = try p.onFrame(.snapshot, &redraw); | ||
| 1941 | try std.testing.expect(p.followPositionLocked() == null); | ||
| 1942 | try std.testing.expect(p.selectionAlive(7, start.version)); | ||
| 1943 | position.clearRetainingCapacity(); | ||
| 1944 | try proto.encodeSelectionReply(&position, std.testing.allocator, .{ | ||
| 1945 | .id = 0, | ||
| 1946 | .gesture = 7, | ||
| 1947 | .seq = p.replica.last_seq, | ||
| 1948 | .source = 2, | ||
| 1949 | .history_rows = p.replica.history_rows, | ||
| 1950 | .status = .ok, | ||
| 1951 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1952 | .active = .{ .row = 0, .col = 1 }, | ||
| 1953 | .text = "", | ||
| 1954 | }); | ||
| 1955 | _ = try p.onFrame(.selection_reply, position.items); | ||
| 1956 | try std.testing.expect(p.followPositionLocked() != null); | ||
| 1957 | |||
| 1958 | const copy = SelectionRequest{ .id = 8, .gesture = 7, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket }; | ||
| 1959 | try std.testing.expect(selectionTestBegin(p, copy) != null); | ||
| 1960 | try selectionTestReply(p, 8, "moved source"); | ||
| 1961 | const result = p.takeSelection().?; | ||
| 1962 | defer std.testing.allocator.free(result.text); | ||
| 1963 | try std.testing.expectEqualStrings("moved source", result.text); | ||
| 1964 | |||
| 1965 | const stale_start = SelectionRequest{ .id = 9, .gesture = 9, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket }; | ||
| 1966 | try std.testing.expect(selectionTestBegin(p, stale_start) == null); | ||
| 1967 | |||
| 1968 | const h = try selectionTestPump(); | ||
| 1969 | defer h.stop(); | ||
| 1970 | h.admitted = true; | ||
| 1971 | h.replica.history_rows = 20; | ||
| 1972 | h.scroll_rows = 1; | ||
| 1973 | h.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = h.history_revision, .until = std.math.maxInt(i64) }; | ||
| 1974 | const chunk = [_]u8{ 17, 0, 0, 0, 1, 0, 0, 0 }; | ||
| 1975 | try std.testing.expectEqual(Pump.Action.changed, try h.onFrame(.scrollback_chunk, &chunk)); | ||
| 1976 | try std.testing.expectEqual(@as(u64, 0), h.history_version.source); | ||
| 1977 | try selectionTestFollowMetadata(h, 3); | ||
| 1978 | try std.testing.expectEqual(@as(u64, 3), h.history_version.source); | ||
| 1979 | var history_redraw = testSnapshot(); | ||
| 1980 | std.mem.writeInt(u64, history_redraw[0..8], h.replica.last_seq + 1, .little); | ||
| 1981 | _ = try h.onFrame(.snapshot, &history_redraw); | ||
| 1982 | try selectionTestFollowMetadata(h, 4); | ||
| 1983 | try std.testing.expectEqual(@as(u64, 3), h.history_version.source); | ||
| 1984 | } | ||
| 1985 | |||
| 1986 | test "ready terminal mode frames precede queued wheel input through the actual wire" { | ||
| 1987 | const p = try selectionTestPump(); | ||
| 1988 | defer p.stop(); | ||
| 1989 | p.admitted = true; | ||
| 1990 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1991 | defer closePipe(incoming); | ||
| 1992 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1993 | defer closePipe(outgoing); | ||
| 1994 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 1995 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 1996 | defer wire.deinit(); | ||
| 1997 | const up: Wheel = .{ .notches = 1, .col = 4, .row = 3, .pixel_x = 422, .pixel_y = 318 }; | ||
| 1998 | try p.say(.{ .wheel = up }); | ||
| 1999 | // More complete frames than one drain budget. Callers must defer the | ||
| 2000 | // mailbox until the final mode frame has been admitted in FIFO order. | ||
| 2001 | for (0..150) |_| try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false })); | ||
| 2002 | try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true })); | ||
| 2003 | try std.testing.expectEqual(Pump.ReadState.busy, try p.receiveReady(&wire)); | ||
| 2004 | try p.mail(&wire, false); | ||
| 2005 | try std.testing.expectEqual(@as(usize, 1), p.mailbox.items.len); | ||
| 2006 | try std.testing.expectEqual(Pump.ReadState.busy, try p.receiveReady(&wire)); | ||
| 2007 | try std.testing.expectEqual(Pump.ReadState.idle, try p.receiveReady(&wire)); | ||
| 2008 | try p.mail(&wire, true); | ||
| 2009 | const arrows = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2010 | defer arrows.deinit(std.testing.allocator); | ||
| 2011 | try std.testing.expectEqualStrings("\x1bOA\x1bOA\x1bOA", arrows.payload); | ||
| 2012 | try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_normal = true, .mouse_sgr_pixels = true })); | ||
| 2013 | try std.testing.expectEqual(Pump.ReadState.idle, try p.receiveReady(&wire)); | ||
| 2014 | try p.say(.{ .wheel = up }); | ||
| 2015 | try p.mail(&wire, true); | ||
| 2016 | const pixels = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2017 | defer pixels.deinit(std.testing.allocator); | ||
| 2018 | try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload); | ||
| 2019 | } | ||
| 2020 | |||
| 2021 | test "mouse press motion release uses negotiated SGR coordinates" { | ||
| 2022 | const p = try selectionTestPump(); | ||
| 2023 | defer p.stop(); | ||
| 2024 | p.admitted = true; | ||
| 2025 | p.status.phase = .attached; | ||
| 2026 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_button = true, .mouse_sgr = true })); | ||
| 2027 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 2028 | defer closePipe(incoming); | ||
| 2029 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 2030 | defer closePipe(outgoing); | ||
| 2031 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 2032 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 2033 | defer wire.deinit(); | ||
| 2034 | const token = p.mouseToken().?; | ||
| 2035 | try p.say(.{ .mouse = .{ .token = token, .kind = .press, .button = 0, .col = 2, .row = 3, .pixel_x = 20, .pixel_y = 30 } }); | ||
| 2036 | try p.mail(&wire, true); | ||
| 2037 | var frame = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2038 | defer frame.deinit(std.testing.allocator); | ||
| 2039 | try std.testing.expectEqualStrings("\x1b[<0;3;4M", frame.payload); | ||
| 2040 | try p.say(.{ .mouse = .{ .token = token, .kind = .motion, .button = 0, .col = 4, .row = 5, .pixel_x = 40, .pixel_y = 50 } }); | ||
| 2041 | try p.mail(&wire, true); | ||
| 2042 | var motion = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2043 | defer motion.deinit(std.testing.allocator); | ||
| 2044 | try std.testing.expectEqualStrings("\x1b[<32;5;6M", motion.payload); | ||
| 2045 | try p.say(.{ .mouse = .{ .token = token, .kind = .release, .button = 0, .col = 4, .row = 5, .pixel_x = 40, .pixel_y = 50 } }); | ||
| 2046 | try p.mail(&wire, true); | ||
| 2047 | var release = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2048 | defer release.deinit(std.testing.allocator); | ||
| 2049 | try std.testing.expectEqualStrings("\x1b[<0;5;6m", release.payload); | ||
| 2050 | // Normal tracking ignores held motion, then a format change cancels in | ||
| 2051 | // the format of the transmitted press, exactly once. | ||
| 2052 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_normal = true, .mouse_sgr = true })); | ||
| 2053 | var event: Mouse = .{ .token = p.mouseToken().?, .kind = .press, .col = 2, .row = 3, .pixel_x = 20, .pixel_y = 30 }; | ||
| 2054 | try p.routeMouse(&wire, event); | ||
| 2055 | const normal = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2056 | defer normal.deinit(std.testing.allocator); | ||
| 2057 | event.kind = .motion; | ||
| 2058 | event.col = 5; | ||
| 2059 | try p.routeMouse(&wire, event); | ||
| 2060 | var probe: [1]u8 = undefined; | ||
| 2061 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 2062 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_any = true })); | ||
| 2063 | try p.reconcileMouse(&wire); | ||
| 2064 | const cancelled = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2065 | defer cancelled.deinit(std.testing.allocator); | ||
| 2066 | try std.testing.expectEqualStrings("\x1b[<0;3;4m", cancelled.payload); | ||
| 2067 | event.kind = .release; | ||
| 2068 | try p.routeMouse(&wire, event); | ||
| 2069 | try p.reconcileMouse(&wire); | ||
| 2070 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 2071 | |||
| 2072 | // A cancelled queued press never starts a replacement gesture. | ||
| 2073 | event.token = p.mouseToken().?; | ||
| 2074 | event.kind = .press; | ||
| 2075 | try p.say(.{ .mouse = event }); | ||
| 2076 | p.cancelMouse(event.token); | ||
| 2077 | try p.mail(&wire, true); | ||
| 2078 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 2079 | event.token = p.mouseToken().?; | ||
| 2080 | event.kind = .motion; | ||
| 2081 | event.button = 3; | ||
| 2082 | try p.routeMouse(&wire, event); | ||
| 2083 | const hover = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2084 | defer hover.deinit(std.testing.allocator); | ||
| 2085 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 67, 38, 36 }, hover.payload); | ||
| 2086 | |||
| 2087 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_x10 = true })); | ||
| 2088 | event.token = p.mouseToken().?; | ||
| 2089 | event.kind = .press; | ||
| 2090 | event.button = 0; | ||
| 2091 | try p.routeMouse(&wire, event); | ||
| 2092 | const x10 = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 2093 | defer x10.deinit(std.testing.allocator); | ||
| 2094 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 32, 38, 36 }, x10.payload); | ||
| 2095 | event.kind = .release; | ||
| 2096 | try p.routeMouse(&wire, event); | ||
| 2097 | try std.testing.expectError(error.WouldBlock, std.posix.read(outgoing[0], &probe)); | ||
| 2098 | } | ||
| 2099 | |||
| 2100 | test "clipboard targets are retained separately and cleared on state change" { | ||
| 2101 | const p = try selectionTestPump(); | ||
| 2102 | defer p.stop(); | ||
| 2103 | p.admitted = true; | ||
| 2104 | p.status.phase = .attached; | ||
| 2105 | _ = try p.onFrame(.term_event, "\x00cYQ=="); | ||
| 2106 | _ = try p.onFrame(.term_event, "\x00pYg=="); | ||
| 2107 | _ = try p.onFrame(.term_event, "\x00cYw=="); | ||
| 2108 | _ = try p.onFrame(.term_event, "\x00cAA=="); // NUL must preserve c. | ||
| 2109 | const ctext = p.takeClipboard(false).?; | ||
| 2110 | defer std.testing.allocator.free(ctext); | ||
| 2111 | const ptext = p.takeClipboard(true).?; | ||
| 2112 | defer std.testing.allocator.free(ptext); | ||
| 2113 | try std.testing.expectEqualStrings("c", ctext); | ||
| 2114 | try std.testing.expectEqualStrings("b", ptext); | ||
| 2115 | _ = try p.onFrame(.term_event, "\x00cYQ=="); | ||
| 2116 | _ = try p.onFrame(.term_event, "\x00sYg=="); | ||
| 2117 | p.mu.lock(); | ||
| 2118 | p.setState(.reconnecting, 0, "reconnecting"); | ||
| 2119 | p.mu.unlock(); | ||
| 2120 | try std.testing.expect(p.takeClipboard(false) == null); | ||
| 2121 | try std.testing.expect(p.takeClipboard(true) == null); | ||
| 2122 | } | ||
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -38,10 +38,59 @@ pub const MuxHandler = struct { | |||
| 38 | comptime action: StreamAction.Tag, | 38 | comptime action: StreamAction.Tag, |
| 39 | value: StreamAction.Value(action), | 39 | value: StreamAction.Value(action), |
| 40 | ) void { | 40 | ) void { |
| 41 | const eng = self.engineOf(); | ||
| 42 | if (comptime action == .print_repeat) { | ||
| 43 | // Ghostty implements REP as repeated print calls. Route each | ||
| 44 | // through the same tracking hook, retaining its character state | ||
| 45 | // and width/wrap implementation instead of predicting the moves. | ||
| 46 | if (eng.term.previous_char) |c| for (0..@max(value, 1)) |_| self.vt(.print, .{ .cp = c }); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | const active_before = eng.term.screens.active_key; | ||
| 50 | const print_x = if (comptime action == .print) eng.term.screens.active.cursor.x else 0; | ||
| 51 | const print_y = if (comptime action == .print) eng.term.screens.active.cursor.y else 0; | ||
| 52 | const print_at_scroll_bottom = if (comptime action == .print) print_y == eng.term.scrolling_region.bottom and | ||
| 53 | print_x >= eng.term.scrolling_region.left and print_x <= eng.term.scrolling_region.right else false; | ||
| 54 | // The source token guards a client coordinate pair while it crosses | ||
| 55 | // the wire to become tracked pins. It is deliberately narrower than | ||
| 56 | // "received bytes": a counter repaint elsewhere must not make a | ||
| 57 | // completed drag refuse to register. These are the stream actions | ||
| 58 | // which can remap a coordinate to another cell identity. Test the | ||
| 59 | // terminal state before forwarding because index/reverse-index and a | ||
| 60 | // pending wrap decide whether this invocation actually scrolls. | ||
| 61 | const source_before = self.movesCoordinateIdentity(action); | ||
| 62 | if (source_before) eng.selection_source +%= 1; | ||
| 63 | // A narrow character at the right margin only arms pending wrap, but | ||
| 64 | // a wide character can wrap and discard the top row immediately. Mark | ||
| 65 | // the possible victims before Ghostty remaps their pins, then retire | ||
| 66 | // them only when the cursor proves that this print really wrapped. | ||
| 67 | if (comptime action == .print) eng.markPossiblePrintDiscard(); | ||
| 68 | eng.prepareTrackedMutation(action, value); | ||
| 69 | // Ghostty's full reset frees the alternate Screen. Its allocator may | ||
| 70 | // reuse the exact address on the next alternate entry, so pointer | ||
| 71 | // equality alone cannot make a stale tracked pin safe to deinit. | ||
| 72 | if (comptime action == .full_reset) | ||
| 73 | eng.alternate_generation +%= 1; | ||
| 41 | if (comptime action == .semantic_prompt) self.onSemanticPrompt(value); | 74 | if (comptime action == .semantic_prompt) self.onSemanticPrompt(value); |
| 42 | if (comptime action == .clipboard_contents) self.onClipboard(value); | 75 | if (comptime action == .clipboard_contents) self.onClipboard(value); |
| 43 | if (comptime action == .bell) self.onBell(); | 76 | if (comptime action == .bell) self.onBell(); |
| 44 | self.inner.vt(action, value); | 77 | self.inner.vt(action, value); |
| 78 | if (comptime action == .print) { | ||
| 79 | const cursor = eng.term.screens.active.cursor; | ||
| 80 | // A narrow glyph at the last column merely arms pending wrap; it | ||
| 81 | // does not yet remap a coordinate. Actual wrapping moves left or | ||
| 82 | // to another row, including a wide glyph that cannot fit. | ||
| 83 | const wrapped = cursor.x < print_x or cursor.y != print_y; | ||
| 84 | if (!source_before and wrapped) | ||
| 85 | eng.selection_source +%= 1; | ||
| 86 | if (wrapped and print_at_scroll_bottom) eng.commitTrackedMutation() else eng.clearTrackedMutation(); | ||
| 87 | } else eng.commitTrackedMutation(); | ||
| 88 | if (comptime action == .full_reset) | ||
| 89 | eng.screen_epoch +%= 1; | ||
| 90 | if (eng.term.screens.active_key != active_before) { | ||
| 91 | eng.screen_epoch +%= 1; | ||
| 92 | eng.selection_source +%= 1; | ||
| 93 | } | ||
| 45 | // After the stock handler, so the mode is on when the first report goes | 94 | // After the stock handler, so the mode is on when the first report goes |
| 46 | // out. A bare vt answers DECRQM 2048 as recognised but never speaks the | 95 | // out. A bare vt answers DECRQM 2048 as recognised but never speaks the |
| 47 | // report, and an app that turns it on then ignores SIGWINCH and waits. | 96 | // report, and an app that turns it on then ignores SIGWINCH and waits. |
| @@ -56,6 +105,36 @@ pub const MuxHandler = struct { | |||
| 56 | return @alignCast(@fieldParentPtr("stream", stream_ptr)); | 105 | return @alignCast(@fieldParentPtr("stream", stream_ptr)); |
| 57 | } | 106 | } |
| 58 | 107 | ||
| 108 | fn movesCoordinateIdentity(self: *MuxHandler, comptime action: StreamAction.Tag) bool { | ||
| 109 | const term = &self.engineOf().term; | ||
| 110 | const cursor = term.screens.active.cursor; | ||
| 111 | const in_horizontal_region = cursor.x >= term.scrolling_region.left and | ||
| 112 | cursor.x <= term.scrolling_region.right; | ||
| 113 | return switch (action) { | ||
| 114 | // These either shift cells/lines directly, or can move retained | ||
| 115 | // rows into or out of scrollback. A no-op is conservatively a | ||
| 116 | // new source, which only rejects a racing registration. | ||
| 117 | .insert_lines, | ||
| 118 | .delete_lines, | ||
| 119 | .insert_blanks, | ||
| 120 | .delete_chars, | ||
| 121 | .scroll_up, | ||
| 122 | .scroll_down, | ||
| 123 | .erase_display_complete, | ||
| 124 | .erase_display_scrollback, | ||
| 125 | .erase_display_scroll_complete, | ||
| 126 | .full_reset, | ||
| 127 | => true, | ||
| 128 | |||
| 129 | // A pending wrap necessarily invokes printWrap. For an otherwise | ||
| 130 | // ordinary print, `vt` compares pre/post cursor coordinates. | ||
| 131 | .print => term.modes.get(.insert) or cursor.pending_wrap, | ||
| 132 | .linefeed, .index, .next_line => in_horizontal_region and cursor.y == term.scrolling_region.bottom, | ||
| 133 | .reverse_index => in_horizontal_region and cursor.y == term.scrolling_region.top, | ||
| 134 | else => false, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 59 | fn onSemanticPrompt( | 138 | fn onSemanticPrompt( |
| 60 | self: *MuxHandler, | 139 | self: *MuxHandler, |
| 61 | value: StreamAction.Value(.semantic_prompt), | 140 | value: StreamAction.Value(.semantic_prompt), |
| @@ -137,6 +216,19 @@ pub const Engine = struct { | |||
| 137 | side_events: std.ArrayList(SideEvent), | 216 | side_events: std.ArrayList(SideEvent), |
| 138 | /// Copied from Options: the interception path reads it per event. | 217 | /// Copied from Options: the interception path reads it per event. |
| 139 | clipboard_max: usize, | 218 | clipboard_max: usize, |
| 219 | /// Full primary scrollback preserves pins only while the screen retains | ||
| 220 | /// scrolled-off rows. With no scrollback, that same scroll discards them. | ||
| 221 | scrollback_enabled: bool, | ||
| 222 | /// Monotonic coordinate-identity token. Clients use this to identify the | ||
| 223 | /// exact source state at which a tracked selection was installed. | ||
| 224 | selection_source: u64, | ||
| 225 | /// Changes when screen identity or geometry is reset, switched, or resized. | ||
| 226 | /// Ordinary output leaves this stable so tracked pins can follow it. | ||
| 227 | screen_epoch: u64, | ||
| 228 | /// Lifetime of Ghostty's lazily allocated alternate Screen. Full reset | ||
| 229 | /// destroys it; a new alternate may reuse its address. | ||
| 230 | alternate_generation: u64, | ||
| 231 | tracked_head: ?*TrackedSelection = null, | ||
| 140 | 232 | ||
| 141 | pub const MarkEvent = struct { | 233 | pub const MarkEvent = struct { |
| 142 | pub const Kind = enum(u8) { prompt_start, command_start, command_end }; | 234 | pub const Kind = enum(u8) { prompt_start, command_start, command_end }; |
| @@ -198,6 +290,96 @@ pub const Engine = struct { | |||
| 198 | } | 290 | } |
| 199 | }; | 291 | }; |
| 200 | 292 | ||
| 293 | /// A selection whose endpoints are pinned in one active Ghostty screen. | ||
| 294 | /// The daemon keeps this per client, rather than using Screen.selection, | ||
| 295 | /// so independent clients cannot overwrite one another's selection. | ||
| 296 | const SelectionPoints = struct { anchor: proto.SelectionPoint, active: proto.SelectionPoint }; | ||
| 297 | |||
| 298 | pub const TrackedSelection = struct { | ||
| 299 | engine: *Engine, | ||
| 300 | screen: *vt.Screen, | ||
| 301 | screen_key: vt.ScreenSet.Key, | ||
| 302 | screen_epoch: u64, | ||
| 303 | alternate_generation: u64, | ||
| 304 | cols: u16, | ||
| 305 | rows: u16, | ||
| 306 | selection: vt.Selection, | ||
| 307 | prev: ?*TrackedSelection = null, | ||
| 308 | next: ?*TrackedSelection = null, | ||
| 309 | valid: bool = true, | ||
| 310 | discard_candidate: bool = false, | ||
| 311 | remap: ?SelectionPoints = null, | ||
| 312 | rotate_rows: u16 = 0, | ||
| 313 | |||
| 314 | pub fn deinit(self: *TrackedSelection) void { | ||
| 315 | self.unlink(); | ||
| 316 | // A full reset may destroy the alternate Screen and its pins. | ||
| 317 | // Its allocator can then recreate it at this exact address; the | ||
| 318 | // explicit generation prevents an ABA untrack in that new Screen. | ||
| 319 | if (self.screen_key == .alternate and | ||
| 320 | self.engine.alternate_generation != self.alternate_generation) | ||
| 321 | return self.engine.alloc.destroy(self); | ||
| 322 | const current = self.engine.term.screens.all.get(self.screen_key) orelse { | ||
| 323 | self.engine.alloc.destroy(self); | ||
| 324 | return; | ||
| 325 | }; | ||
| 326 | if (current == self.screen) self.selection.deinit(self.screen); | ||
| 327 | self.engine.alloc.destroy(self); | ||
| 328 | } | ||
| 329 | |||
| 330 | fn unlink(self: *TrackedSelection) void { | ||
| 331 | if (self.prev) |prev| prev.next = self.next else if (self.engine.tracked_head == self) self.engine.tracked_head = self.next; | ||
| 332 | if (self.next) |next| next.prev = self.prev; | ||
| 333 | self.prev = null; | ||
| 334 | self.next = null; | ||
| 335 | } | ||
| 336 | |||
| 337 | /// Current screen-space endpoints. A garbage pin, screen switch, or | ||
| 338 | /// geometry change retires the durable selection. | ||
| 339 | pub fn points(self: *const TrackedSelection) ?SelectionPoints { | ||
| 340 | if (!self.valid or self.engine.term.screens.active_key != self.screen_key or | ||
| 341 | self.engine.screen_epoch != self.screen_epoch or | ||
| 342 | self.engine.term.cols != self.cols or self.engine.term.rows != self.rows) | ||
| 343 | return null; | ||
| 344 | |||
| 345 | const anchor_pin = self.selection.start(); | ||
| 346 | const active_pin = self.selection.end(); | ||
| 347 | if (anchor_pin.garbage or active_pin.garbage) return null; | ||
| 348 | const anchor = self.screen.pages.pointFromPin(.screen, anchor_pin) orelse return null; | ||
| 349 | const active = self.screen.pages.pointFromPin(.screen, active_pin) orelse return null; | ||
| 350 | const anchor_coord = switch (anchor) { | ||
| 351 | .screen => |coord| coord, | ||
| 352 | else => return null, | ||
| 353 | }; | ||
| 354 | const active_coord = switch (active) { | ||
| 355 | .screen => |coord| coord, | ||
| 356 | else => return null, | ||
| 357 | }; | ||
| 358 | return .{ | ||
| 359 | .anchor = .{ .row = anchor_coord.y, .col = @intCast(anchor_coord.x) }, | ||
| 360 | .active = .{ .row = active_coord.y, .col = @intCast(active_coord.x) }, | ||
| 361 | }; | ||
| 362 | } | ||
| 363 | |||
| 364 | /// Extract from the endpoints' current positions. Null means the | ||
| 365 | /// tracked pins no longer name the original active screen geometry. | ||
| 366 | pub fn extract( | ||
| 367 | self: *const TrackedSelection, | ||
| 368 | alloc: std.mem.Allocator, | ||
| 369 | max_bytes: usize, | ||
| 370 | ) !?SelectionExtract { | ||
| 371 | const p = self.points() orelse return null; | ||
| 372 | return try self.engine.extractSelection( | ||
| 373 | alloc, | ||
| 374 | p.anchor.row, | ||
| 375 | p.anchor.col, | ||
| 376 | p.active.row, | ||
| 377 | p.active.col, | ||
| 378 | max_bytes, | ||
| 379 | ); | ||
| 380 | } | ||
| 381 | }; | ||
| 382 | |||
| 201 | /// Heap-allocates: stream.handler holds a pointer to `term`, so an | 383 | /// Heap-allocates: stream.handler holds a pointer to `term`, so an |
| 202 | /// Engine must never move after init. | 384 | /// Engine must never move after init. |
| 203 | pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { | 385 | pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { |
| @@ -216,6 +398,11 @@ pub const Engine = struct { | |||
| 216 | .mark_events = .empty, | 398 | .mark_events = .empty, |
| 217 | .side_events = .empty, | 399 | .side_events = .empty, |
| 218 | .clipboard_max = opts.clipboard_max, | 400 | .clipboard_max = opts.clipboard_max, |
| 401 | .scrollback_enabled = opts.max_scrollback != 0, | ||
| 402 | .selection_source = 1, | ||
| 403 | .screen_epoch = 1, | ||
| 404 | .alternate_generation = 1, | ||
| 405 | .tracked_head = null, | ||
| 219 | }; | 406 | }; |
| 220 | errdefer self.term.deinit(alloc); | 407 | errdefer self.term.deinit(alloc); |
| 221 | 408 | ||
| @@ -230,6 +417,7 @@ pub const Engine = struct { | |||
| 230 | } | 417 | } |
| 231 | 418 | ||
| 232 | pub fn deinit(self: *Engine) void { | 419 | pub fn deinit(self: *Engine) void { |
| 420 | while (self.tracked_head) |tracked| tracked.deinit(); | ||
| 233 | self.pty_out.deinit(self.alloc); | 421 | self.pty_out.deinit(self.alloc); |
| 234 | self.clearSideEvents(); | 422 | self.clearSideEvents(); |
| 235 | self.side_events.deinit(self.alloc); | 423 | self.side_events.deinit(self.alloc); |
| @@ -243,6 +431,141 @@ pub const Engine = struct { | |||
| 243 | self.stream.nextSlice(bytes); | 431 | self.stream.nextSlice(bytes); |
| 244 | } | 432 | } |
| 245 | 433 | ||
| 434 | fn prepareTrackedMutation(self: *Engine, comptime action: StreamAction.Tag, value: StreamAction.Value(action)) void { | ||
| 435 | if (self.tracked_head == null) return; | ||
| 436 | const t = &self.term; | ||
| 437 | const c = t.screens.active.cursor; | ||
| 438 | const in_region = c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right; | ||
| 439 | switch (action) { | ||
| 440 | .print => if (t.modes.get(.insert)) self.invalidateActive(), | ||
| 441 | .scroll_up => self.prepareScrollUp(value), | ||
| 442 | .scroll_down => self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, value, .down), | ||
| 443 | .index, .linefeed, .next_line => if (in_region and c.y == t.scrolling_region.bottom) self.prepareScrollUp(1), | ||
| 444 | .reverse_index => if (in_region and c.y == t.scrolling_region.top) self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, 1, .down), | ||
| 445 | .insert_lines, .delete_lines => if (in_region and c.y >= t.scrolling_region.top and c.y <= t.scrolling_region.bottom) { | ||
| 446 | self.shiftSelectionRows(c.y, t.scrolling_region.bottom, value, if (action == .insert_lines) .down else .up); | ||
| 447 | }, | ||
| 448 | // Horizontal edits need a different range policy from vertical moves. | ||
| 449 | .insert_blanks, .delete_chars => self.invalidateActive(), | ||
| 450 | .erase_display_complete, .erase_display_scrollback, .erase_display_scroll_complete, .full_reset => self.invalidateActive(), | ||
| 451 | else => {}, | ||
| 452 | } | ||
| 453 | } | ||
| 454 | fn prepareScrollUp(self: *Engine, count: usize) void { | ||
| 455 | const t = &self.term; | ||
| 456 | if (count == 0) return; | ||
| 457 | if (t.scrolling_region.top == 0 and t.scrolling_region.left == 0 and t.scrolling_region.right == t.cols - 1) { | ||
| 458 | // Ghostty moves retained history itself. A partial bottom also | ||
| 459 | // rotates the untouched rows below it; those external pins need | ||
| 460 | // the same displacement after Ghostty has grown/pruned pages. | ||
| 461 | const n: u16 = @intCast(@min(count, t.scrolling_region.bottom + 1)); | ||
| 462 | const origin = self.historyRows(); | ||
| 463 | const bottom = origin + t.scrolling_region.bottom; | ||
| 464 | var it = self.tracked_head; | ||
| 465 | while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| { | ||
| 466 | const first = @min(points.anchor.row, points.active.row); | ||
| 467 | const last = @max(points.anchor.row, points.active.row); | ||
| 468 | if (first <= bottom and last > bottom) tracked.discard_candidate = true; | ||
| 469 | if (first > bottom) tracked.rotate_rows = n; | ||
| 470 | if (!self.scrollback_enabled or t.screens.active_key == .alternate) | ||
| 471 | tracked.discard_candidate = tracked.discard_candidate or first < n; | ||
| 472 | }; | ||
| 473 | return; | ||
| 474 | } | ||
| 475 | self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, count, .up); | ||
| 476 | } | ||
| 477 | const ShiftDirection = enum { up, down }; | ||
| 478 | fn shiftSelectionRows(self: *Engine, first: u16, last: u16, count: usize, direction: ShiftDirection) void { | ||
| 479 | if (count == 0 or self.tracked_head == null) return; | ||
| 480 | const t = &self.term; | ||
| 481 | // A rectangular margin can split a linear selection. Retire it until | ||
| 482 | // that separate selection policy is supported. | ||
| 483 | if (t.scrolling_region.left != 0 or t.scrolling_region.right != t.cols - 1) return self.invalidateActive(); | ||
| 484 | const origin = self.historyRows(); | ||
| 485 | const top = origin + first; | ||
| 486 | const bottom = origin + last; | ||
| 487 | const n: u32 = @intCast(@min(count, last - first + 1)); | ||
| 488 | var it = self.tracked_head; | ||
| 489 | while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| { | ||
| 490 | const start = @min(points.anchor.row, points.active.row); | ||
| 491 | const end = @max(points.anchor.row, points.active.row); | ||
| 492 | if (start <= bottom and end >= top and (start < top or end > bottom)) { | ||
| 493 | tracked.discard_candidate = true; | ||
| 494 | continue; | ||
| 495 | } | ||
| 496 | var moved = points; | ||
| 497 | for ([_]*proto.SelectionPoint{ &moved.anchor, &moved.active }) |point| { | ||
| 498 | if (point.row < top or point.row > bottom) continue; | ||
| 499 | switch (direction) { | ||
| 500 | .up => if (point.row < top + n) { | ||
| 501 | tracked.discard_candidate = true; | ||
| 502 | } else { | ||
| 503 | point.row -= n; | ||
| 504 | }, | ||
| 505 | .down => if (point.row + n > bottom) { | ||
| 506 | tracked.discard_candidate = true; | ||
| 507 | } else { | ||
| 508 | point.row += n; | ||
| 509 | }, | ||
| 510 | } | ||
| 511 | } | ||
| 512 | tracked.remap = moved; | ||
| 513 | }; | ||
| 514 | } | ||
| 515 | fn markPossiblePrintDiscard(self: *Engine) void { | ||
| 516 | if (self.tracked_head == null) return; | ||
| 517 | const t = &self.term; | ||
| 518 | const c = t.screens.active.cursor; | ||
| 519 | if (c.y == t.scrolling_region.bottom and c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right and | ||
| 520 | (c.x == t.scrolling_region.right or c.pending_wrap)) self.prepareScrollUp(1); | ||
| 521 | } | ||
| 522 | fn commitTrackedMutation(self: *Engine) void { | ||
| 523 | var it = self.tracked_head; | ||
| 524 | while (it) |tracked| : (it = tracked.next) { | ||
| 525 | if (tracked.discard_candidate) tracked.valid = false; | ||
| 526 | if (tracked.rotate_rows != 0) { | ||
| 527 | if (tracked.points()) |points| { | ||
| 528 | var moved = points; | ||
| 529 | moved.anchor.row += tracked.rotate_rows; | ||
| 530 | moved.active.row += tracked.rotate_rows; | ||
| 531 | tracked.remap = moved; | ||
| 532 | } else tracked.valid = false; | ||
| 533 | } | ||
| 534 | if (tracked.valid) if (tracked.remap) |points| { | ||
| 535 | // IL/DL copy row contents without remapping arbitrary pins. | ||
| 536 | // Rebind after the action, also covering index's fast path | ||
| 537 | // without duplicating Ghostty's choice of row-copy algorithm. | ||
| 538 | const a = tracked.screen.pages.pin(.{ .screen = .{ .x = points.anchor.col, .y = points.anchor.row } }); | ||
| 539 | const b = tracked.screen.pages.pin(.{ .screen = .{ .x = points.active.col, .y = points.active.row } }); | ||
| 540 | if (a != null and b != null) { | ||
| 541 | tracked.selection.startPtr().* = a.?; | ||
| 542 | tracked.selection.endPtr().* = b.?; | ||
| 543 | } else tracked.valid = false; | ||
| 544 | }; | ||
| 545 | tracked.discard_candidate = false; | ||
| 546 | tracked.remap = null; | ||
| 547 | tracked.rotate_rows = 0; | ||
| 548 | } | ||
| 549 | } | ||
| 550 | fn clearTrackedMutation(self: *Engine) void { | ||
| 551 | var it = self.tracked_head; | ||
| 552 | while (it) |tracked| : (it = tracked.next) { | ||
| 553 | tracked.discard_candidate = false; | ||
| 554 | tracked.remap = null; | ||
| 555 | tracked.rotate_rows = 0; | ||
| 556 | } | ||
| 557 | } | ||
| 558 | fn invalidateActive(self: *Engine) void { | ||
| 559 | var it = self.tracked_head; | ||
| 560 | while (it) |tracked| : (it = tracked.next) { | ||
| 561 | if (tracked.screen == self.term.screens.active) tracked.valid = false; | ||
| 562 | } | ||
| 563 | } | ||
| 564 | |||
| 565 | pub fn selectionSource(self: *const Engine) u64 { | ||
| 566 | return self.selection_source; | ||
| 567 | } | ||
| 568 | |||
| 246 | pub fn ptyOutput(self: *const Engine) []const u8 { | 569 | pub fn ptyOutput(self: *const Engine) []const u8 { |
| 247 | return self.pty_out.items; | 570 | return self.pty_out.items; |
| 248 | } | 571 | } |
| @@ -593,6 +916,46 @@ pub const Engine = struct { | |||
| 593 | g.cursor = .{ .x = cur.x, .y = cur.y }; | 916 | g.cursor = .{ .x = cur.x, .y = cur.y }; |
| 594 | } | 917 | } |
| 595 | 918 | ||
| 919 | /// Pin a selection to the currently active screen. The returned owner | ||
| 920 | /// must be deinitialized by the caller, even when the terminal later | ||
| 921 | /// makes either endpoint unavailable. | ||
| 922 | pub fn trackSelection( | ||
| 923 | self: *Engine, | ||
| 924 | anchor: proto.SelectionPoint, | ||
| 925 | active: proto.SelectionPoint, | ||
| 926 | ) !?*TrackedSelection { | ||
| 927 | if (anchor.col >= self.term.cols or active.col >= self.term.cols) | ||
| 928 | return null; | ||
| 929 | |||
| 930 | const screen = self.term.screens.active; | ||
| 931 | const start = screen.pages.pin(.{ .screen = .{ | ||
| 932 | .x = anchor.col, | ||
| 933 | .y = anchor.row, | ||
| 934 | } }) orelse return null; | ||
| 935 | const end = screen.pages.pin(.{ .screen = .{ | ||
| 936 | .x = active.col, | ||
| 937 | .y = active.row, | ||
| 938 | } }) orelse return null; | ||
| 939 | const selection = vt.Selection.init(start, end, false); | ||
| 940 | const pinned = try selection.track(screen); | ||
| 941 | errdefer pinned.deinit(screen); | ||
| 942 | const tracked = try self.alloc.create(TrackedSelection); | ||
| 943 | tracked.* = .{ | ||
| 944 | .engine = self, | ||
| 945 | .screen = screen, | ||
| 946 | .screen_key = self.term.screens.active_key, | ||
| 947 | .screen_epoch = self.screen_epoch, | ||
| 948 | .alternate_generation = self.alternate_generation, | ||
| 949 | .cols = @intCast(self.term.cols), | ||
| 950 | .rows = @intCast(self.term.rows), | ||
| 951 | .selection = pinned, | ||
| 952 | }; | ||
| 953 | tracked.next = self.tracked_head; | ||
| 954 | if (self.tracked_head) |head| head.prev = tracked; | ||
| 955 | self.tracked_head = tracked; | ||
| 956 | return tracked; | ||
| 957 | } | ||
| 958 | |||
| 596 | /// Screen-space rows, zero being the oldest retained. Formatting writes | 959 | /// Screen-space rows, zero being the oldest retained. Formatting writes |
| 597 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. | 960 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. |
| 598 | pub fn extractSelection( | 961 | pub fn extractSelection( |
| @@ -675,11 +1038,16 @@ pub const Engine = struct { | |||
| 675 | 1038 | ||
| 676 | /// Full reset (RIS). Also DISCARDS queued side_events: drain them first. | 1039 | /// Full reset (RIS). Also DISCARDS queued side_events: drain them first. |
| 677 | pub fn reset(self: *Engine) void { | 1040 | pub fn reset(self: *Engine) void { |
| 1041 | self.selection_source +%= 1; | ||
| 1042 | self.screen_epoch +%= 1; | ||
| 1043 | self.alternate_generation +%= 1; | ||
| 678 | self.term.fullReset(); | 1044 | self.term.fullReset(); |
| 679 | self.clearSideEvents(); | 1045 | self.clearSideEvents(); |
| 680 | } | 1046 | } |
| 681 | 1047 | ||
| 682 | pub fn resize(self: *Engine, cols: u16, rows: u16) !void { | 1048 | pub fn resize(self: *Engine, cols: u16, rows: u16) !void { |
| 1049 | self.selection_source +%= 1; | ||
| 1050 | self.screen_epoch +%= 1; | ||
| 683 | try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); | 1051 | try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); |
| 684 | if (self.term.modes.get(.in_band_size_reports)) self.reportSize(); | 1052 | if (self.term.modes.get(.in_band_size_reports)) self.reportSize(); |
| 685 | } | 1053 | } |
| @@ -950,6 +1318,308 @@ test "Engine: selection extraction uses only the active alternate screen" { | |||
| 950 | try std.testing.expectEqual(@as(?[]u8, null), unavailable.text); | 1318 | try std.testing.expectEqual(@as(?[]u8, null), unavailable.text); |
| 951 | } | 1319 | } |
| 952 | 1320 | ||
| 1321 | test "Engine: tracked selection follows output and preserves duplicate row identity" { | ||
| 1322 | const alloc = std.testing.allocator; | ||
| 1323 | var e = try Engine.init(alloc, .{ .cols = 16, .rows = 3, .max_scrollback = 16 }); | ||
| 1324 | defer e.deinit(); | ||
| 1325 | |||
| 1326 | e.feed("DUPLICATE\r\nsame\r\nDUPLICATE\r\nother"); | ||
| 1327 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 })).?; | ||
| 1328 | defer tracked.deinit(); | ||
| 1329 | var other = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 })).?; | ||
| 1330 | defer other.deinit(); | ||
| 1331 | try std.testing.expect(tracked.points().?.anchor.row != other.points().?.anchor.row); | ||
| 1332 | var first = (try tracked.extract(alloc, 64)).?; | ||
| 1333 | defer first.deinit(alloc); | ||
| 1334 | try std.testing.expectEqualStrings("DUPLICATE", first.text.?); | ||
| 1335 | |||
| 1336 | // More output advances the viewport and creates another identical row; | ||
| 1337 | // tracked pins keep naming the first row rather than matching by text. | ||
| 1338 | e.feed("\r\nnew-1\r\nnew-2\r\nnew-3"); | ||
| 1339 | var after_output = (try tracked.extract(alloc, 64)).?; | ||
| 1340 | defer after_output.deinit(alloc); | ||
| 1341 | try std.testing.expectEqualStrings("DUPLICATE", after_output.text.?); | ||
| 1342 | try std.testing.expect(after_output.history_rows > first.history_rows); | ||
| 1343 | var other_after = (try other.extract(alloc, 64)).?; | ||
| 1344 | defer other_after.deinit(alloc); | ||
| 1345 | try std.testing.expectEqualStrings("DUPLICATE", other_after.text.?); | ||
| 1346 | } | ||
| 1347 | |||
| 1348 | test "Engine: selection source advances only for coordinate remapping actions" { | ||
| 1349 | const alloc = std.testing.allocator; | ||
| 1350 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 }); | ||
| 1351 | defer e.deinit(); | ||
| 1352 | |||
| 1353 | const initial = e.selectionSource(); | ||
| 1354 | // Side effects and ordinary redraws leave coordinates meaningful. This is | ||
| 1355 | // the live-counter case: a completed drag elsewhere must still register. | ||
| 1356 | e.feed("\x07x\rY\n"); | ||
| 1357 | try std.testing.expectEqual(initial, e.selectionSource()); | ||
| 1358 | |||
| 1359 | // Insert mode shifts the remainder of the row even away from its edge. | ||
| 1360 | e.feed("\x1b[4hZ\x1b[4l"); | ||
| 1361 | const inserted = e.selectionSource(); | ||
| 1362 | try std.testing.expect(inserted != initial); | ||
| 1363 | |||
| 1364 | // Full-row repainting reaches the last column but only arms pending-wrap; | ||
| 1365 | // it must not reject a drag elsewhere, such as a live counter redraw. | ||
| 1366 | e.feed("\x1b[1;1H1234"); | ||
| 1367 | try std.testing.expectEqual(inserted, e.selectionSource()); | ||
| 1368 | |||
| 1369 | // A wide glyph at the right margin cannot fit, so Ghostty immediately | ||
| 1370 | // wraps and remaps coordinates. The post-print cursor proves that path. | ||
| 1371 | e.feed("\x1b[1;4H漢"); | ||
| 1372 | const wrapped = e.selectionSource(); | ||
| 1373 | try std.testing.expect(wrapped != inserted); | ||
| 1374 | |||
| 1375 | // Index at the bottom scrolls; the same LF away from the bottom above did | ||
| 1376 | // not. ED 3 clears scrollback and changes absolute row identities. | ||
| 1377 | e.feed("\x1b[3;1H\n"); | ||
| 1378 | const scrolled = e.selectionSource(); | ||
| 1379 | try std.testing.expect(scrolled != wrapped); | ||
| 1380 | e.feed("\x1b[3J"); | ||
| 1381 | try std.testing.expect(e.selectionSource() != scrolled); | ||
| 1382 | } | ||
| 1383 | |||
| 1384 | test "Engine: tracked selection rejects active screen and geometry changes" { | ||
| 1385 | const alloc = std.testing.allocator; | ||
| 1386 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 }); | ||
| 1387 | defer e.deinit(); | ||
| 1388 | e.feed("tracked"); | ||
| 1389 | |||
| 1390 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1391 | defer tracked.deinit(); | ||
| 1392 | try std.testing.expect(tracked.points() != null); | ||
| 1393 | |||
| 1394 | // Switching away and back in one feed still retires the source identity; | ||
| 1395 | // comparing only the final active screen key would miss this. | ||
| 1396 | e.feed("\x1b[?1049halt\x1b[?1049l"); | ||
| 1397 | try std.testing.expect(tracked.points() == null); | ||
| 1398 | |||
| 1399 | // Ordinary alternate leave retains its Screen and pins, but switching | ||
| 1400 | // away/re-entering still retires this selection's presentation epoch. | ||
| 1401 | e.feed("\x1b[?1049h"); | ||
| 1402 | var alt_tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 })).?; | ||
| 1403 | defer alt_tracked.deinit(); | ||
| 1404 | e.feed("\x1b[?1049l\x1b[?1049h"); | ||
| 1405 | try std.testing.expect(alt_tracked.points() == null); | ||
| 1406 | e.feed("\x1b[?1049l"); | ||
| 1407 | |||
| 1408 | var resized = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1409 | defer resized.deinit(); | ||
| 1410 | try e.resize(9, 3); | ||
| 1411 | try std.testing.expect(resized.points() == null); | ||
| 1412 | try e.resize(8, 3); | ||
| 1413 | try std.testing.expect(resized.points() == null); | ||
| 1414 | |||
| 1415 | var reset = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1416 | defer reset.deinit(); | ||
| 1417 | e.reset(); | ||
| 1418 | try std.testing.expect(reset.points() == null); | ||
| 1419 | } | ||
| 1420 | |||
| 1421 | test "Engine: alternate tracked pin deinit rejects a reset ABA lifetime" { | ||
| 1422 | const alloc = std.testing.allocator; | ||
| 1423 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 }); | ||
| 1424 | defer e.deinit(); | ||
| 1425 | |||
| 1426 | e.feed("\x1b[?1049halt"); | ||
| 1427 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1428 | const lifetime = tracked.alternate_generation; | ||
| 1429 | e.reset(); | ||
| 1430 | try std.testing.expect(e.alternate_generation != lifetime); | ||
| 1431 | // This must be a no-op even if Ghostty's next alternate allocation takes | ||
| 1432 | // the former address. The generation, not allocator behavior, is proof. | ||
| 1433 | tracked.deinit(); | ||
| 1434 | e.feed("\x1b[?1049hnew-alt"); | ||
| 1435 | } | ||
| 1436 | |||
| 1437 | test "Engine: bounded scroll retires discarded endpoints but keeps shifted rows" { | ||
| 1438 | const alloc = std.testing.allocator; | ||
| 1439 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 }); | ||
| 1440 | defer e.deinit(); | ||
| 1441 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD"); | ||
| 1442 | const discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1443 | defer discarded.deinit(); | ||
| 1444 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1445 | defer kept.deinit(); | ||
| 1446 | // Alt screen never receives scrollback: CSI S discards its top row. | ||
| 1447 | e.feed("\x1b[1S"); | ||
| 1448 | try std.testing.expect(discarded.points() == null); | ||
| 1449 | try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row); | ||
| 1450 | } | ||
| 1451 | |||
| 1452 | test "Engine: print wrapping and REP retire an alternate discarded row" { | ||
| 1453 | const alloc = std.testing.allocator; | ||
| 1454 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 }); | ||
| 1455 | defer e.deinit(); | ||
| 1456 | |||
| 1457 | e.feed("\x1b[?1049hA\r\nB\r\nC"); | ||
| 1458 | const wide_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1459 | defer wide_discarded.deinit(); | ||
| 1460 | // A wide glyph at the final cell wraps immediately; it does not first set | ||
| 1461 | // pending_wrap, so the candidate must be remembered before Ghostty scrolls. | ||
| 1462 | e.feed("\x1b[3;4H漢"); | ||
| 1463 | try std.testing.expect(wide_discarded.points() == null); | ||
| 1464 | |||
| 1465 | e.reset(); | ||
| 1466 | e.feed("\x1b[?1049hA\r\nB\r\nC"); | ||
| 1467 | const repeated_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1468 | defer repeated_discarded.deinit(); | ||
| 1469 | // REP at the margin prints twice: the second character consumes pending | ||
| 1470 | // wrap and scrolls the alternate screen. | ||
| 1471 | e.feed("\x1b[3;4H\x1b[2b"); | ||
| 1472 | try std.testing.expect(repeated_discarded.points() == null); | ||
| 1473 | } | ||
| 1474 | |||
| 1475 | test "Engine: a non-bottom wrap retains primary history selection" { | ||
| 1476 | const alloc = std.testing.allocator; | ||
| 1477 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 4, .max_scrollback = 16 }); | ||
| 1478 | defer e.deinit(); | ||
| 1479 | e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive"); | ||
| 1480 | const history = e.historyRows(); | ||
| 1481 | try std.testing.expect(history > 0); | ||
| 1482 | const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1483 | defer tracked.deinit(); | ||
| 1484 | // This is an actual wide-character wrap, but it lands on the next row | ||
| 1485 | // above the bottom of the full scrolling region and cannot discard text. | ||
| 1486 | e.feed("\x1b[2;4H漢"); | ||
| 1487 | try std.testing.expect(tracked.points() != null); | ||
| 1488 | } | ||
| 1489 | |||
| 1490 | test "Engine: full primary scroll without scrollback retires its top row" { | ||
| 1491 | const alloc = std.testing.allocator; | ||
| 1492 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3, .max_scrollback = 0 }); | ||
| 1493 | defer e.deinit(); | ||
| 1494 | e.feed("top\r\nmid\r\nbottom"); | ||
| 1495 | const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1496 | defer tracked.deinit(); | ||
| 1497 | e.feed("\x1b[3;1H\n"); | ||
| 1498 | try std.testing.expect(tracked.points() == null); | ||
| 1499 | } | ||
| 1500 | |||
| 1501 | test "Engine: partial primary discard compares absolute rows with history" { | ||
| 1502 | const alloc = std.testing.allocator; | ||
| 1503 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4, .max_scrollback = 16 }); | ||
| 1504 | defer e.deinit(); | ||
| 1505 | |||
| 1506 | e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive"); | ||
| 1507 | const history = e.historyRows(); | ||
| 1508 | try std.testing.expect(history > 0); | ||
| 1509 | const discarded = (try e.trackSelection(.{ .row = history + 1, .col = 0 }, .{ .row = history + 1, .col = 0 })).?; | ||
| 1510 | defer discarded.deinit(); | ||
| 1511 | const kept = (try e.trackSelection(.{ .row = history + 2, .col = 0 }, .{ .row = history + 2, .col = 0 })).?; | ||
| 1512 | defer kept.deinit(); | ||
| 1513 | e.feed("\x1b[2;4r\x1b[1S"); | ||
| 1514 | try std.testing.expect(discarded.points() == null); | ||
| 1515 | try std.testing.expectEqual(history + 1, kept.points().?.anchor.row); | ||
| 1516 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1517 | defer text.deinit(alloc); | ||
| 1518 | try std.testing.expectEqualStrings("f", text.text.?); | ||
| 1519 | } | ||
| 1520 | |||
| 1521 | test "Engine: retained region selection follows IL DL index and reverse scrolling" { | ||
| 1522 | const alloc = std.testing.allocator; | ||
| 1523 | const cases = .{ | ||
| 1524 | .{ "\x1b[2S", @as(u32, 1) }, | ||
| 1525 | .{ "\x1b[1T", @as(u32, 4) }, | ||
| 1526 | .{ "\x1b[3;1H\x1b[1L", @as(u32, 4) }, | ||
| 1527 | .{ "\x1b[2;1H\x1b[1M", @as(u32, 2) }, | ||
| 1528 | .{ "\x1b[6;1H\n", @as(u32, 2) }, | ||
| 1529 | .{ "\x1b[44m\x1b[6;1H\n", @as(u32, 2) }, | ||
| 1530 | .{ "\x1b[2;1H\x1bM", @as(u32, 4) }, | ||
| 1531 | .{ "\x1b[6;8H漢", @as(u32, 2) }, | ||
| 1532 | }; | ||
| 1533 | inline for (cases) |case| { | ||
| 1534 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1535 | defer e.deinit(); | ||
| 1536 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[2;6r"); | ||
| 1537 | const kept = (try e.trackSelection(.{ .row = 3, .col = 0 }, .{ .row = 3, .col = 0 })).?; | ||
| 1538 | defer kept.deinit(); | ||
| 1539 | e.feed(case[0]); | ||
| 1540 | try std.testing.expectEqual(case[1], kept.points().?.anchor.row); | ||
| 1541 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1542 | defer text.deinit(alloc); | ||
| 1543 | try std.testing.expectEqualStrings("D", text.text.?); | ||
| 1544 | } | ||
| 1545 | } | ||
| 1546 | |||
| 1547 | test "Engine: REP uses the normal print tracking path" { | ||
| 1548 | const alloc = std.testing.allocator; | ||
| 1549 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 }); | ||
| 1550 | defer e.deinit(); | ||
| 1551 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD"); | ||
| 1552 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1553 | defer kept.deinit(); | ||
| 1554 | const source = e.selectionSource(); | ||
| 1555 | e.feed("\x1b[1;2Hx\x1b[3b"); | ||
| 1556 | try std.testing.expectEqual(source, e.selectionSource()); | ||
| 1557 | try std.testing.expectEqual(@as(u32, 2), kept.points().?.anchor.row); | ||
| 1558 | e.feed("\x1b[4;8H\x1b[2b"); | ||
| 1559 | try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row); | ||
| 1560 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1561 | defer text.deinit(alloc); | ||
| 1562 | try std.testing.expectEqualStrings("C", text.text.?); | ||
| 1563 | } | ||
| 1564 | |||
| 1565 | test "Engine: split region ranges and insert-mode prints retire selection" { | ||
| 1566 | const alloc = std.testing.allocator; | ||
| 1567 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1568 | defer e.deinit(); | ||
| 1569 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF"); | ||
| 1570 | const crossing = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 4, .col = 0 })).?; | ||
| 1571 | defer crossing.deinit(); | ||
| 1572 | e.feed("\x1b[2;4r\x1b[1S"); | ||
| 1573 | try std.testing.expect(crossing.points() == null); | ||
| 1574 | e.feed("\x1b[r\x1b[1;1Hhello"); | ||
| 1575 | const inserted = (try e.trackSelection(.{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 })).?; | ||
| 1576 | defer inserted.deinit(); | ||
| 1577 | e.feed("\x1b[1;1H\x1b[4hZ"); | ||
| 1578 | try std.testing.expect(inserted.points() == null); | ||
| 1579 | } | ||
| 1580 | |||
| 1581 | test "Engine: top-zero partial-bottom scrolling preserves selected occurrence" { | ||
| 1582 | const alloc = std.testing.allocator; | ||
| 1583 | inline for (.{ "\x1b[1S", "\x1b[4;1H\n" }) |action| { | ||
| 1584 | inline for (.{ true, false }) |alternate| { | ||
| 1585 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1586 | defer e.deinit(); | ||
| 1587 | if (alternate) e.feed("\x1b[?1049h"); | ||
| 1588 | e.feed("A\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[1;4r"); | ||
| 1589 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1590 | defer kept.deinit(); | ||
| 1591 | const below = (try e.trackSelection(.{ .row = 4, .col = 0 }, .{ .row = 5, .col = 0 })).?; | ||
| 1592 | defer below.deinit(); | ||
| 1593 | const crossing = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 4, .col = 0 })).?; | ||
| 1594 | defer crossing.deinit(); | ||
| 1595 | e.feed(action); | ||
| 1596 | try std.testing.expect(crossing.points() == null); | ||
| 1597 | try std.testing.expectEqual(e.historyRows() + 4, below.points().?.anchor.row); | ||
| 1598 | const outside_text = (try below.extract(alloc, 100)).?; | ||
| 1599 | defer outside_text.deinit(alloc); | ||
| 1600 | try std.testing.expectEqualStrings("E\nF", outside_text.text.?); | ||
| 1601 | try std.testing.expectEqual(e.historyRows() + 1, kept.points().?.anchor.row); | ||
| 1602 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1603 | defer text.deinit(alloc); | ||
| 1604 | try std.testing.expectEqualStrings("C", text.text.?); | ||
| 1605 | } | ||
| 1606 | } | ||
| 1607 | } | ||
| 1608 | |||
| 1609 | test "Engine: history eviction makes tracked pins unavailable" { | ||
| 1610 | const alloc = std.testing.allocator; | ||
| 1611 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 3, .max_scrollback = 3 }); | ||
| 1612 | defer e.deinit(); | ||
| 1613 | |||
| 1614 | e.feed("old\r\none\r\ntwo\r\nthree\r\nfour"); | ||
| 1615 | const oldest = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1616 | defer oldest.deinit(); | ||
| 1617 | // Scrollback is page-backed. Drive past several pages rather than | ||
| 1618 | // assuming max_scrollback is a line count. | ||
| 1619 | for (0..1600) |_| e.feed("discard\r\n"); | ||
| 1620 | try std.testing.expect(oldest.points() == null); | ||
| 1621 | } | ||
| 1622 | |||
| 953 | test "ghostty-vt boots headless and text lands in the grid" { | 1623 | test "ghostty-vt boots headless and text lands in the grid" { |
| 954 | const alloc = std.testing.allocator; | 1624 | const alloc = std.testing.allocator; |
| 955 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 1625 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -377,111 +377,106 @@ pub const SelectionPoint = struct { | |||
| 377 | col: u16, | 377 | col: u16, |
| 378 | }; | 378 | }; |
| 379 | 379 | ||
| 380 | /// One selection contract for direct extraction and per-client tracking. | ||
| 381 | /// Start guards the coordinate source; copy and clear name an owned gesture. | ||
| 380 | pub const SelectionReq = struct { | 382 | pub const SelectionReq = struct { |
| 383 | action: enum(u8) { extract, start, copy, clear } = .extract, | ||
| 381 | id: u32, | 384 | id: u32, |
| 385 | gesture: u32 = 0, | ||
| 386 | epoch: u64 = 0, | ||
| 387 | source: u64 = 0, | ||
| 382 | anchor: SelectionPoint, | 388 | anchor: SelectionPoint, |
| 383 | active: SelectionPoint, | 389 | active: SelectionPoint, |
| 384 | }; | 390 | }; |
| 385 | |||
| 386 | pub const selection_text_max: usize = 1024 * 1024; | 391 | pub const selection_text_max: usize = 1024 * 1024; |
| 387 | pub const selection_req_len: usize = 16; | 392 | pub const selection_req_len: usize = 37; |
| 388 | pub const selection_reply_prefix_len: usize = 9; | 393 | pub const selection_reply_prefix_len: usize = 41; |
| 389 | 394 | ||
| 390 | pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { | 395 | fn writeSelectionPoint(out: *[6]u8, point: SelectionPoint) void { |
| 391 | var buf: [selection_req_len]u8 = undefined; | 396 | std.mem.writeInt(u32, out[0..4], point.row, .little); |
| 392 | std.mem.writeInt(u32, buf[0..4], req.id, .little); | 397 | std.mem.writeInt(u16, out[4..6], point.col, .little); |
| 393 | std.mem.writeInt(u32, buf[4..8], req.anchor.row, .little); | ||
| 394 | std.mem.writeInt(u16, buf[8..10], req.anchor.col, .little); | ||
| 395 | std.mem.writeInt(u32, buf[10..14], req.active.row, .little); | ||
| 396 | std.mem.writeInt(u16, buf[14..16], req.active.col, .little); | ||
| 397 | return buf; | ||
| 398 | } | 398 | } |
| 399 | 399 | fn readSelectionPoint(bytes: *const [6]u8) SelectionPoint { | |
| 400 | pub fn decodeSelectionReq(payload: []const u8) !SelectionReq { | 400 | return .{ .row = std.mem.readInt(u32, bytes[0..4], .little), .col = std.mem.readInt(u16, bytes[4..6], .little) }; |
| 401 | if (payload.len != selection_req_len) return error.BadPayload; | 401 | } |
| 402 | return .{ | 402 | pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { |
| 403 | .id = std.mem.readInt(u32, payload[0..4], .little), | 403 | var out: [selection_req_len]u8 = undefined; |
| 404 | .anchor = .{ | 404 | std.mem.writeInt(u32, out[0..4], req.id, .little); |
| 405 | .row = std.mem.readInt(u32, payload[4..8], .little), | 405 | writeSelectionPoint(out[4..10], req.anchor); |
| 406 | .col = std.mem.readInt(u16, payload[8..10], .little), | 406 | writeSelectionPoint(out[10..16], req.active); |
| 407 | }, | 407 | out[16] = @intFromEnum(req.action); |
| 408 | .active = .{ | 408 | std.mem.writeInt(u32, out[17..21], req.gesture, .little); |
| 409 | .row = std.mem.readInt(u32, payload[10..14], .little), | 409 | std.mem.writeInt(u64, out[21..29], req.epoch, .little); |
| 410 | .col = std.mem.readInt(u16, payload[14..16], .little), | 410 | std.mem.writeInt(u64, out[29..37], req.source, .little); |
| 411 | }, | 411 | return out; |
| 412 | } | ||
| 413 | pub fn decodeSelectionReq(bytes: []const u8) !SelectionReq { | ||
| 414 | if (bytes.len != selection_req_len) return error.BadPayload; | ||
| 415 | const req: SelectionReq = .{ | ||
| 416 | .id = std.mem.readInt(u32, bytes[0..4], .little), | ||
| 417 | .anchor = readSelectionPoint(bytes[4..10]), | ||
| 418 | .active = readSelectionPoint(bytes[10..16]), | ||
| 419 | .action = try enumFromByte(@FieldType(SelectionReq, "action"), bytes[16]), | ||
| 420 | .gesture = std.mem.readInt(u32, bytes[17..21], .little), | ||
| 421 | .epoch = std.mem.readInt(u64, bytes[21..29], .little), | ||
| 422 | .source = std.mem.readInt(u64, bytes[29..37], .little), | ||
| 412 | }; | 423 | }; |
| 424 | const valid = switch (req.action) { | ||
| 425 | .extract => req.id != 0 and req.gesture == 0, | ||
| 426 | .start, .copy => req.id != 0 and req.gesture != 0, | ||
| 427 | .clear => req.id == 0 and req.gesture != 0, | ||
| 428 | }; | ||
| 429 | if (!valid) return error.BadPayload; | ||
| 430 | return req; | ||
| 413 | } | 431 | } |
| 414 | 432 | pub const SelectionStatus = enum(u8) { ok = 0, invalid = 1, too_large = 2, unavailable = 3 }; | |
| 415 | pub const SelectionStatus = enum(u8) { | ||
| 416 | ok = 0, | ||
| 417 | invalid = 1, | ||
| 418 | too_large = 2, | ||
| 419 | unavailable = 3, | ||
| 420 | }; | ||
| 421 | |||
| 422 | pub const SelectionReply = struct { | 433 | pub const SelectionReply = struct { |
| 423 | id: u32, | 434 | id: u32 = 0, // zero is a position update, never a clipboard write |
| 424 | status: SelectionStatus, | 435 | gesture: u32 = 0, |
| 425 | /// Retained history rows on the screen the text was extracted from. Absolute | 436 | seq: u64 = 0, |
| 426 | /// rows count from the OLDEST RETAINED row, so an eviction shifts every one | 437 | source: u64 = 0, |
| 427 | /// of them under a request in flight and the reply is `.ok`, valid UTF-8, | ||
| 428 | /// and the wrong text. | ||
| 429 | /// | ||
| 430 | /// A watermark rather than a lease on purpose: the daemon holds no | ||
| 431 | /// per-client selection state, so the requester compares and decides. | ||
| 432 | /// Ordinary output RAISES it without moving row zero; only eviction lowers it. | ||
| 433 | history_rows: u32, | 438 | history_rows: u32, |
| 434 | /// Borrowed from the frame payload and valid only as long as that | 439 | status: SelectionStatus = .unavailable, |
| 435 | /// payload remains alive and unchanged. | 440 | anchor: SelectionPoint = .{ .row = 0, .col = 0 }, |
| 436 | text: []const u8, | 441 | active: SelectionPoint = .{ .row = 0, .col = 0 }, |
| 442 | /// Borrows the frame payload. Only successful copy/extract replies carry text. | ||
| 443 | text: []const u8 = &.{}, | ||
| 437 | }; | 444 | }; |
| 438 | 445 | fn checkSelectionText(reply: SelectionReply) !void { | |
| 439 | /// One rule for both ends of a selection_reply: `.ok` carries valid UTF-8 | 446 | if (reply.status != .ok or reply.id == 0) { |
| 440 | /// within the cap, every refusal carries nothing at all. A writer that | 447 | if (reply.text.len != 0) return error.BadPayload; |
| 441 | /// checked less than the reader would mint a frame it could not read back. | 448 | } else if (reply.text.len > selection_text_max or !std.unicode.utf8ValidateSlice(reply.text)) return error.BadPayload; |
| 442 | fn checkSelectionText(status: SelectionStatus, text_value: []const u8) !void { | 449 | } |
| 443 | switch (status) { | 450 | /// Validate before appending so malformed replies leave the caller's buffer intact. |
| 444 | .ok => { | 451 | pub fn encodeSelectionReply(out: *std.ArrayList(u8), alloc: std.mem.Allocator, reply: SelectionReply) !void { |
| 445 | if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value)) | 452 | try checkSelectionText(reply); |
| 446 | return error.BadPayload; | ||
| 447 | }, | ||
| 448 | .invalid, .too_large, .unavailable => { | ||
| 449 | if (text_value.len != 0) return error.BadPayload; | ||
| 450 | }, | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 454 | /// Validation completes before the first append, so `error.BadPayload` | ||
| 455 | /// leaves a reused `out` unchanged. | ||
| 456 | pub fn encodeSelectionReply( | ||
| 457 | out: *std.ArrayList(u8), | ||
| 458 | alloc: std.mem.Allocator, | ||
| 459 | id: u32, | ||
| 460 | status: SelectionStatus, | ||
| 461 | history_rows: u32, | ||
| 462 | text_value: []const u8, | ||
| 463 | ) !void { | ||
| 464 | try checkSelectionText(status, text_value); | ||
| 465 | |||
| 466 | var prefix: [selection_reply_prefix_len]u8 = undefined; | 453 | var prefix: [selection_reply_prefix_len]u8 = undefined; |
| 467 | std.mem.writeInt(u32, prefix[0..4], id, .little); | 454 | std.mem.writeInt(u32, prefix[0..4], reply.id, .little); |
| 468 | prefix[4] = @intFromEnum(status); | 455 | prefix[4] = @intFromEnum(reply.status); |
| 469 | std.mem.writeInt(u32, prefix[5..9], history_rows, .little); | 456 | std.mem.writeInt(u32, prefix[5..9], reply.history_rows, .little); |
| 457 | std.mem.writeInt(u32, prefix[9..13], reply.gesture, .little); | ||
| 458 | std.mem.writeInt(u64, prefix[13..21], reply.seq, .little); | ||
| 459 | std.mem.writeInt(u64, prefix[21..29], reply.source, .little); | ||
| 460 | writeSelectionPoint(prefix[29..35], reply.anchor); | ||
| 461 | writeSelectionPoint(prefix[35..41], reply.active); | ||
| 470 | try out.appendSlice(alloc, &prefix); | 462 | try out.appendSlice(alloc, &prefix); |
| 471 | try out.appendSlice(alloc, text_value); | 463 | try out.appendSlice(alloc, reply.text); |
| 472 | } | 464 | } |
| 473 | 465 | pub fn decodeSelectionReply(bytes: []const u8) !SelectionReply { | |
| 474 | pub fn decodeSelectionReply(payload: []const u8) !SelectionReply { | 466 | if (bytes.len < selection_reply_prefix_len) return error.BadPayload; |
| 475 | if (payload.len < selection_reply_prefix_len) return error.BadPayload; | 467 | const reply: SelectionReply = .{ |
| 476 | const status = try enumFromByte(SelectionStatus, payload[4]); | 468 | .id = std.mem.readInt(u32, bytes[0..4], .little), |
| 477 | const text_value = payload[selection_reply_prefix_len..]; | 469 | .status = try enumFromByte(SelectionStatus, bytes[4]), |
| 478 | try checkSelectionText(status, text_value); | 470 | .history_rows = std.mem.readInt(u32, bytes[5..9], .little), |
| 479 | return .{ | 471 | .gesture = std.mem.readInt(u32, bytes[9..13], .little), |
| 480 | .id = std.mem.readInt(u32, payload[0..4], .little), | 472 | .seq = std.mem.readInt(u64, bytes[13..21], .little), |
| 481 | .status = status, | 473 | .source = std.mem.readInt(u64, bytes[21..29], .little), |
| 482 | .history_rows = std.mem.readInt(u32, payload[5..9], .little), | 474 | .anchor = readSelectionPoint(bytes[29..35]), |
| 483 | .text = text_value, | 475 | .active = readSelectionPoint(bytes[35..41]), |
| 476 | .text = bytes[selection_reply_prefix_len..], | ||
| 484 | }; | 477 | }; |
| 478 | try checkSelectionText(reply); | ||
| 479 | return reply; | ||
| 485 | } | 480 | } |
| 486 | 481 | ||
| 487 | /// Where the command-boundary signal came from, weakest-last. `marks` is the | 482 | /// Where the command-boundary signal came from, weakest-last. `marks` is the |
| @@ -2672,7 +2667,8 @@ test "selection request has a fixed little-endian wire layout" { | |||
| 2672 | 0x14, 0x13, 0x12, 0x11, | 2667 | 0x14, 0x13, 0x12, 0x11, |
| 2673 | 0x22, 0x21, 0x34, 0x33, | 2668 | 0x22, 0x21, 0x34, 0x33, |
| 2674 | 0x32, 0x31, 0x42, 0x41, | 2669 | 0x32, 0x31, 0x42, 0x41, |
| 2675 | }, &bytes); | 2670 | }, bytes[0..16]); |
| 2671 | try std.testing.expectEqualSlices(u8, &(@as([21]u8, @splat(0))), bytes[16..]); | ||
| 2676 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); | 2672 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); |
| 2677 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15])); | 2673 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15])); |
| 2678 | var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1); | 2674 | var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1); |
| @@ -2685,11 +2681,11 @@ test "selection reply validates status and text shape" { | |||
| 2685 | var payload: std.ArrayList(u8) = .empty; | 2681 | var payload: std.ArrayList(u8) = .empty; |
| 2686 | defer payload.deinit(alloc); | 2682 | defer payload.deinit(alloc); |
| 2687 | 2683 | ||
| 2688 | try encodeSelectionReply(&payload, alloc, 7, .ok, 0x11223344, "hello"); | 2684 | try encodeSelectionReply(&payload, alloc, .{ .id = 7, .status = .ok, .history_rows = 0x11223344, .text = "hello" }); |
| 2689 | try std.testing.expectEqualSlices( | 2685 | try std.testing.expectEqualSlices( |
| 2690 | u8, | 2686 | u8, |
| 2691 | &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11, 'h', 'e', 'l', 'l', 'o' }, | 2687 | &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11 }, |
| 2692 | payload.items, | 2688 | payload.items[0..9], |
| 2693 | ); | 2689 | ); |
| 2694 | const ok = try decodeSelectionReply(payload.items); | 2690 | const ok = try decodeSelectionReply(payload.items); |
| 2695 | try std.testing.expectEqual(@as(u32, 7), ok.id); | 2691 | try std.testing.expectEqual(@as(u32, 7), ok.id); |
| @@ -2698,27 +2694,60 @@ test "selection reply validates status and text shape" { | |||
| 2698 | try std.testing.expectEqualStrings("hello", ok.text); | 2694 | try std.testing.expectEqualStrings("hello", ok.text); |
| 2699 | 2695 | ||
| 2700 | payload.clearRetainingCapacity(); | 2696 | payload.clearRetainingCapacity(); |
| 2701 | try encodeSelectionReply(&payload, alloc, 9, .invalid, 5, ""); | 2697 | try encodeSelectionReply(&payload, alloc, .{ .id = 9, .status = .invalid, .history_rows = 5, .text = "" }); |
| 2702 | try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items); | 2698 | try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items[0..9]); |
| 2703 | const invalid = try decodeSelectionReply(payload.items); | 2699 | const invalid = try decodeSelectionReply(payload.items); |
| 2704 | try std.testing.expectEqual(@as(u32, 9), invalid.id); | 2700 | try std.testing.expectEqual(@as(u32, 9), invalid.id); |
| 2705 | try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); | 2701 | try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); |
| 2706 | try std.testing.expectEqual(@as(u32, 5), invalid.history_rows); | 2702 | try std.testing.expectEqual(@as(u32, 5), invalid.history_rows); |
| 2707 | try std.testing.expectEqual(@as(usize, 0), invalid.text.len); | 2703 | try std.testing.expectEqual(@as(usize, 0), invalid.text.len); |
| 2708 | 2704 | ||
| 2705 | payload.items[4] = 0xff; | ||
| 2706 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2707 | payload.items[4] = @intFromEnum(SelectionStatus.invalid); | ||
| 2708 | try payload.append(alloc, 'x'); | ||
| 2709 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2710 | payload.items[4] = @intFromEnum(SelectionStatus.ok); | ||
| 2711 | payload.items[selection_reply_prefix_len] = 0xff; | ||
| 2712 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2713 | |||
| 2709 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 })); | 2714 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 })); |
| 2710 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); | 2715 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); |
| 2711 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff })); | 2716 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff })); |
| 2712 | 2717 | ||
| 2713 | // The watermark is part of the prefix, so a reply carrying only the | 2718 | // The watermark is part of the prefix, so a reply carrying only the |
| 2714 | // pre-watermark five bytes is short, not a legacy reply to interpret. | 2719 | // pre-watermark five bytes is short, not a legacy reply to interpret. |
| 2715 | const prefix = [_]u8{ 1, 0, 0, 0, 0, 0, 0, 0, 0 }; | 2720 | const prefix: [selection_reply_prefix_len]u8 = @splat(0); |
| 2716 | for (0..selection_reply_prefix_len) |len| { | 2721 | for (0..selection_reply_prefix_len) |len| { |
| 2717 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); | 2722 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); |
| 2718 | } | 2723 | } |
| 2719 | } | 2724 | } |
| 2720 | 2725 | ||
| 2721 | test "selection status discriminants have exact nine-byte golden replies" { | 2726 | test "selection tracking has one request and reply codec with guarded action shapes" { |
| 2727 | var req: SelectionReq = .{ .action = .start, .id = 7, .gesture = 9, .epoch = 11, .source = 13, .anchor = .{ .row = 70000, .col = 3 }, .active = .{ .row = 70001, .col = 4 } }; | ||
| 2728 | const bytes = encodeSelectionReq(req); | ||
| 2729 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); | ||
| 2730 | try std.testing.expectEqualSlices(u8, &.{ 1, 9, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 0, 0, 0, 0 }, bytes[16..]); | ||
| 2731 | req.id = 0; | ||
| 2732 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2733 | req.action = .clear; | ||
| 2734 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2735 | req.gesture = 0; | ||
| 2736 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2737 | var bad = bytes; | ||
| 2738 | bad[16] = 0xff; | ||
| 2739 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&bad)); | ||
| 2740 | |||
| 2741 | const reply: SelectionReply = .{ .gesture = 9, .seq = 15, .source = 13, .history_rows = 69999, .status = .ok, .anchor = req.anchor, .active = req.active }; | ||
| 2742 | var out: std.ArrayList(u8) = .empty; | ||
| 2743 | defer out.deinit(std.testing.allocator); | ||
| 2744 | try encodeSelectionReply(&out, std.testing.allocator, reply); | ||
| 2745 | try std.testing.expectEqualDeep(reply, try decodeSelectionReply(out.items)); | ||
| 2746 | try out.append(std.testing.allocator, 'x'); | ||
| 2747 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(out.items)); | ||
| 2748 | } | ||
| 2749 | |||
| 2750 | test "selection status discriminants retain exact scalar prefixes" { | ||
| 2722 | const alloc = std.testing.allocator; | 2751 | const alloc = std.testing.allocator; |
| 2723 | const cases = [_]struct { status: SelectionStatus, wire: u8 }{ | 2752 | const cases = [_]struct { status: SelectionStatus, wire: u8 }{ |
| 2724 | .{ .status = .ok, .wire = 0 }, | 2753 | .{ .status = .ok, .wire = 0 }, |
| @@ -2729,11 +2758,11 @@ test "selection status discriminants have exact nine-byte golden replies" { | |||
| 2729 | for (cases) |case| { | 2758 | for (cases) |case| { |
| 2730 | var payload: std.ArrayList(u8) = .empty; | 2759 | var payload: std.ArrayList(u8) = .empty; |
| 2731 | defer payload.deinit(alloc); | 2760 | defer payload.deinit(alloc); |
| 2732 | try encodeSelectionReply(&payload, alloc, 0x01020304, case.status, 0x0a0b0c0d, ""); | 2761 | try encodeSelectionReply(&payload, alloc, .{ .id = 0x01020304, .status = case.status, .history_rows = 0x0a0b0c0d, .text = "" }); |
| 2733 | try std.testing.expectEqualSlices( | 2762 | try std.testing.expectEqualSlices( |
| 2734 | u8, | 2763 | u8, |
| 2735 | &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a }, | 2764 | &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a }, |
| 2736 | payload.items, | 2765 | payload.items[0..9], |
| 2737 | ); | 2766 | ); |
| 2738 | const reply = try decodeSelectionReply(payload.items); | 2767 | const reply = try decodeSelectionReply(payload.items); |
| 2739 | try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); | 2768 | try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); |
| @@ -2751,7 +2780,7 @@ test "selection text accepts the exact cap and rejects one byte more" { | |||
| 2751 | 2780 | ||
| 2752 | var payload: std.ArrayList(u8) = .empty; | 2781 | var payload: std.ArrayList(u8) = .empty; |
| 2753 | defer payload.deinit(alloc); | 2782 | defer payload.deinit(alloc); |
| 2754 | try encodeSelectionReply(&payload, alloc, 1, .ok, 0, at_cap); | 2783 | try encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = at_cap }); |
| 2755 | try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); | 2784 | try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); |
| 2756 | const decoded = try decodeSelectionReply(payload.items); | 2785 | const decoded = try decodeSelectionReply(payload.items); |
| 2757 | try std.testing.expectEqual(selection_text_max, decoded.text.len); | 2786 | try std.testing.expectEqual(selection_text_max, decoded.text.len); |
| @@ -2759,7 +2788,7 @@ test "selection text accepts the exact cap and rejects one byte more" { | |||
| 2759 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); | 2788 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); |
| 2760 | defer alloc.free(over_cap); | 2789 | defer alloc.free(over_cap); |
| 2761 | @memset(over_cap, 'x'); | 2790 | @memset(over_cap, 'x'); |
| 2762 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); | 2791 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap })); |
| 2763 | 2792 | ||
| 2764 | var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); | 2793 | var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); |
| 2765 | defer alloc.free(oversized_payload); | 2794 | defer alloc.free(oversized_payload); |
| @@ -2776,20 +2805,20 @@ test "selection reply validation errors do not modify a reused output buffer" { | |||
| 2776 | defer payload.deinit(alloc); | 2805 | defer payload.deinit(alloc); |
| 2777 | try payload.appendSlice(alloc, "sentinel"); | 2806 | try payload.appendSlice(alloc, "sentinel"); |
| 2778 | 2807 | ||
| 2779 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .invalid, 0, "x")); | 2808 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .invalid, .history_rows = 0, .text = "x" })); |
| 2780 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2809 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2781 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, &.{0xff})); | 2810 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = &.{0xff} })); |
| 2782 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2811 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2783 | 2812 | ||
| 2784 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); | 2813 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); |
| 2785 | defer alloc.free(over_cap); | 2814 | defer alloc.free(over_cap); |
| 2786 | @memset(over_cap, 'x'); | 2815 | @memset(over_cap, 'x'); |
| 2787 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); | 2816 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap })); |
| 2788 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2817 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2789 | } | 2818 | } |
| 2790 | 2819 | ||
| 2791 | test "decoded selection text borrows the payload" { | 2820 | test "decoded selection text borrows the payload" { |
| 2792 | var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0, 'o', 'n', 'e' }; | 2821 | var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0 } ++ [_]u8{0} ** 32 ++ [_]u8{ 'o', 'n', 'e' }; |
| 2793 | const reply = try decodeSelectionReply(&payload); | 2822 | const reply = try decodeSelectionReply(&payload); |
| 2794 | try std.testing.expectEqualStrings("one", reply.text); | 2823 | try std.testing.expectEqualStrings("one", reply.text); |
| 2795 | payload[selection_reply_prefix_len] = 'O'; | 2824 | payload[selection_reply_prefix_len] = 'O'; |
| @@ -2799,8 +2828,8 @@ test "decoded selection text borrows the payload" { | |||
| 2799 | test "selection message values and fixed lengths are pinned" { | 2828 | test "selection message values and fixed lengths are pinned" { |
| 2800 | try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); | 2829 | try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); |
| 2801 | try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); | 2830 | try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); |
| 2802 | try std.testing.expectEqual(@as(usize, 16), selection_req_len); | 2831 | try std.testing.expectEqual(@as(usize, 37), selection_req_len); |
| 2803 | try std.testing.expectEqual(@as(usize, 9), selection_reply_prefix_len); | 2832 | try std.testing.expectEqual(@as(usize, 41), selection_reply_prefix_len); |
| 2804 | try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); | 2833 | try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); |
| 2805 | } | 2834 | } |
| 2806 | 2835 | ||
src/gui/config.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | //! Small, explicit native appearance and font configuration parser. | 1 | //! Small, explicit native appearance and font configuration parser. |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const font_options = @import("font_options.zig"); | ||
| 3 | 4 | ||
| 4 | pub const Settings = struct { | 5 | pub const Settings = struct { |
| 5 | family: ?[:0]const u8 = null, | 6 | family: ?[:0]const u8 = null, |
| @@ -32,8 +33,6 @@ pub fn parsePalette(text: []const u8) Error!PalettePair { | |||
| 32 | return .{ .index = index, .color = parseColor(std.mem.trim(u8, text[eq + 1 ..], " \t")) catch return error.InvalidPalette }; | 33 | return .{ .index = index, .color = parseColor(std.mem.trim(u8, text[eq + 1 ..], " \t")) catch return error.InvalidPalette }; |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 35 | // 192pt is the largest accepted size: at Linux's 96 DPI it is 256px at 100%. | ||
| 36 | |||
| 37 | fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 { | 36 | fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 { |
| 38 | var value = raw; | 37 | var value = raw; |
| 39 | if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1]; | 38 | if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1]; |
| @@ -70,20 +69,11 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Err | |||
| 70 | return error.InvalidSyntax; | 69 | return error.InvalidSyntax; |
| 71 | } | 70 | } |
| 72 | out.family = try valueText(value, alloc); | 71 | out.family = try valueText(value, alloc); |
| 73 | if (out.family.?.len == 0) { | ||
| 74 | return error.MissingFamily; | ||
| 75 | } | ||
| 76 | } else if (std.mem.eql(u8, key, "font-size")) { | 72 | } else if (std.mem.eql(u8, key, "font-size")) { |
| 77 | if (out.size_points != null) { | 73 | if (out.size_points != null) { |
| 78 | return error.InvalidSyntax; | 74 | return error.InvalidSyntax; |
| 79 | } | 75 | } |
| 80 | const points = std.fmt.parseFloat(f64, value) catch { | 76 | out.size_points = font_options.parsePointSize(value) catch return error.InvalidValue; |
| 81 | return error.InvalidValue; | ||
| 82 | }; | ||
| 83 | if (!std.math.isFinite(points) or points < 1 or points > 192) { | ||
| 84 | return error.InvalidValue; | ||
| 85 | } | ||
| 86 | out.size_points = points; | ||
| 87 | } else if (std.mem.eql(u8, key, "theme")) { | 77 | } else if (std.mem.eql(u8, key, "theme")) { |
| 88 | if (out.theme != null) return error.InvalidSyntax; | 78 | if (out.theme != null) return error.InvalidSyntax; |
| 89 | out.theme = valueText(value, alloc) catch |err| return if (err == error.MissingFamily) error.InvalidThemeName else err; | 79 | out.theme = valueText(value, alloc) catch |err| return if (err == error.MissingFamily) error.InvalidThemeName else err; |
src/gui/font_options.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,19 @@ | |||
| 1 | //! Shared font option validation for config files and native CLI flags. | ||
| 2 | const std = @import("std"); | ||
| 3 | |||
| 4 | pub const Error = error{InvalidValue}; | ||
| 5 | |||
| 6 | /// Ghostty-style point size: finite, inclusive range 1–192 points. | ||
| 7 | /// The upper limit is 256 pixels at Linux's 96 DPI and 100% display scale. | ||
| 8 | pub fn parsePointSize(text: []const u8) Error!f64 { | ||
| 9 | const value = std.fmt.parseFloat(f64, text) catch return error.InvalidValue; | ||
| 10 | if (!std.math.isFinite(value) or value < 1 or value > 192) return error.InvalidValue; | ||
| 11 | return value; | ||
| 12 | } | ||
| 13 | |||
| 14 | test "point size validation is shared and bounded" { | ||
| 15 | try std.testing.expectEqual(@as(f64, 12.5), try parsePointSize("12.5")); | ||
| 16 | try std.testing.expectError(error.InvalidValue, parsePointSize("nan")); | ||
| 17 | try std.testing.expectError(error.InvalidValue, parsePointSize("0")); | ||
| 18 | try std.testing.expectError(error.InvalidValue, parsePointSize("193")); | ||
| 19 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,7 @@ const interaction = native_core.interaction; | |||
| 12 | const font = @import("font.zig"); | 12 | const font = @import("font.zig"); |
| 13 | const atlas = @import("atlas.zig"); | 13 | const atlas = @import("atlas.zig"); |
| 14 | const quads = @import("quads.zig"); | 14 | const quads = @import("quads.zig"); |
| 15 | const theme_mod = @import("theme.zig"); | 15 | const theme_mod = native_core.theme; |
| 16 | const gl = @import("gl.zig"); | 16 | const gl = @import("gl.zig"); |
| 17 | const bench = @import("bench.zig"); | 17 | const bench = @import("bench.zig"); |
| 18 | 18 | ||
| @@ -87,17 +87,31 @@ pub const Hook = union(enum) { | |||
| 87 | text: []const u8, | 87 | text: []const u8, |
| 88 | key: struct { code: u32, mods: u16 = 0 }, | 88 | key: struct { code: u32, mods: u16 = 0 }, |
| 89 | click: struct { x: f32, y: f32 }, | 89 | click: struct { x: f32, y: f32 }, |
| 90 | pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32 }, | 90 | pointer: struct { kind: enum { down, motion, up }, x: f32, y: f32, button: u8 = 0, mods: ?u8 = null }, |
| 91 | wheel: struct { x: f32, y: f32, delta: f32, flipped: bool = false }, | ||
| 91 | state: []const u8, | 92 | state: []const u8, |
| 92 | resize: struct { w: u32, h: u32 }, | 93 | resize: struct { w: u32, h: u32 }, |
| 93 | capture: []const u8, | 94 | capture: []const u8, |
| 94 | capture_last: []const u8, | 95 | capture_last: []const u8, |
| 96 | clipboard: []const u8, | ||
| 97 | primary: []const u8, | ||
| 95 | quit, | 98 | quit, |
| 96 | }; | 99 | }; |
| 97 | 100 | ||
| 98 | pub fn parseHook(line: []const u8) ?Hook { | 101 | pub fn parseHook(line: []const u8) ?Hook { |
| 99 | if (std.mem.eql(u8, line, "quit")) return .quit; | 102 | if (std.mem.eql(u8, line, "quit")) return .quit; |
| 100 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; | 103 | if (std.mem.startsWith(u8, line, "state:")) return .{ .state = line[6..] }; |
| 104 | if (std.mem.startsWith(u8, line, "mouse:")) { | ||
| 105 | var it = std.mem.splitScalar(u8, line[6..], ','); | ||
| 106 | const name = it.next() orelse return null; | ||
| 107 | const kind: @FieldType(@FieldType(Hook, "pointer"), "kind") = if (std.mem.eql(u8, name, "down")) .down else if (std.mem.eql(u8, name, "move")) .motion else if (std.mem.eql(u8, name, "up")) .up else return null; | ||
| 108 | const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 109 | const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 110 | const button = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null; | ||
| 111 | const mods = std.fmt.parseInt(u8, it.next() orelse return null, 10) catch return null; | ||
| 112 | if (button > 2 or mods > 7 or it.next() != null) return null; | ||
| 113 | return .{ .pointer = .{ .kind = kind, .x = x, .y = y, .button = button, .mods = mods } }; | ||
| 114 | } | ||
| 101 | inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| { | 115 | inline for (.{ .{ "mousedown:", .down }, .{ "mousemove:", .motion }, .{ "mouseup:", .up } }) |entry| { |
| 102 | if (std.mem.startsWith(u8, line, entry[0])) { | 116 | if (std.mem.startsWith(u8, line, entry[0])) { |
| 103 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 117 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| @@ -105,6 +119,16 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 105 | } | 119 | } |
| 106 | } | 120 | } |
| 107 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; | 121 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; |
| 122 | if (std.mem.startsWith(u8, line, "primary:")) return .{ .primary = line[8..] }; | ||
| 123 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; | ||
| 124 | if (std.mem.startsWith(u8, line, "wheel:")) { | ||
| 125 | var it = std.mem.splitScalar(u8, line[6..], ','); | ||
| 126 | const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 127 | const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 128 | const delta = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 129 | const flipped = if (it.next()) |v| std.mem.eql(u8, v, "flipped") else false; | ||
| 130 | return .{ .wheel = .{ .x = x, .y = y, .delta = delta, .flipped = flipped } }; | ||
| 131 | } | ||
| 108 | if (std.mem.startsWith(u8, line, "click:")) { | 132 | if (std.mem.startsWith(u8, line, "click:")) { |
| 109 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 133 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| 110 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; | 134 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; |
| @@ -114,6 +138,8 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 114 | if (std.mem.startsWith(u8, line, "key:")) { | 138 | if (std.mem.startsWith(u8, line, "key:")) { |
| 115 | const name = line["key:".len..]; | 139 | const name = line["key:".len..]; |
| 116 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; | 140 | if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; |
| 141 | if (std.mem.eql(u8, name, "copy")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT } }; | ||
| 142 | if (std.mem.eql(u8, name, "interrupt")) return .{ .key = .{ .code = c.SDLK_C, .mods = c.SDL_KMOD_CTRL } }; | ||
| 117 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } }; | 143 | if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } }; |
| 118 | inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { | 144 | inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { |
| 119 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; | 145 | if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; |
| @@ -249,6 +275,15 @@ const HookReader = struct { | |||
| 249 | ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP; | 275 | ev.button.type = c.SDL_EVENT_MOUSE_BUTTON_UP; |
| 250 | }, | 276 | }, |
| 251 | .pointer => |at| { | 277 | .pointer => |at| { |
| 278 | if (at.mods) |mods| { | ||
| 279 | // Queue modifier state with the ordinary SDL event, so | ||
| 280 | // multiple FIFO gestures retain their own event ordering. | ||
| 281 | ev.user.type = c.SDL_EVENT_USER; | ||
| 282 | ev.user.code = 0x4d4f4453; | ||
| 283 | ev.user.data1 = @ptrFromInt(@as(usize, mods)); | ||
| 284 | if (!c.SDL_PushEvent(&ev)) return error.EventInjectionFailed; | ||
| 285 | ev = std.mem.zeroes(c.SDL_Event); | ||
| 286 | } | ||
| 252 | if (at.kind == .motion) { | 287 | if (at.kind == .motion) { |
| 253 | ev.motion.type = c.SDL_EVENT_MOUSE_MOTION; | 288 | ev.motion.type = c.SDL_EVENT_MOUSE_MOTION; |
| 254 | ev.motion.state = c.SDL_BUTTON_LMASK; | 289 | ev.motion.state = c.SDL_BUTTON_LMASK; |
| @@ -256,11 +291,25 @@ const HookReader = struct { | |||
| 256 | ev.motion.y = at.y; | 291 | ev.motion.y = at.y; |
| 257 | } else { | 292 | } else { |
| 258 | ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; | 293 | ev.button.type = if (at.kind == .down) c.SDL_EVENT_MOUSE_BUTTON_DOWN else c.SDL_EVENT_MOUSE_BUTTON_UP; |
| 259 | ev.button.button = c.SDL_BUTTON_LEFT; | 294 | ev.button.button = switch (at.button) { |
| 295 | 1 => c.SDL_BUTTON_MIDDLE, | ||
| 296 | 2 => c.SDL_BUTTON_RIGHT, | ||
| 297 | else => c.SDL_BUTTON_LEFT, | ||
| 298 | }; | ||
| 260 | ev.button.x = at.x; | 299 | ev.button.x = at.x; |
| 261 | ev.button.y = at.y; | 300 | ev.button.y = at.y; |
| 262 | } | 301 | } |
| 263 | }, | 302 | }, |
| 303 | .wheel => |at| { | ||
| 304 | ev.wheel.type = c.SDL_EVENT_MOUSE_WHEEL; | ||
| 305 | ev.wheel.mouse_x = at.x; | ||
| 306 | ev.wheel.mouse_y = at.y; | ||
| 307 | ev.wheel.x = 0; | ||
| 308 | ev.wheel.y = at.delta; | ||
| 309 | ev.wheel.integer_x = 0; | ||
| 310 | ev.wheel.integer_y = 0; | ||
| 311 | ev.wheel.direction = if (at.flipped) c.SDL_MOUSEWHEEL_FLIPPED else c.SDL_MOUSEWHEEL_NORMAL; | ||
| 312 | }, | ||
| 264 | .state => |path| { | 313 | .state => |path| { |
| 265 | const copy = try self.alloc.dupe(u8, path); | 314 | const copy = try self.alloc.dupe(u8, path); |
| 266 | if (self.state) |old| self.alloc.free(old); | 315 | if (self.state) |old| self.alloc.free(old); |
| @@ -284,6 +333,14 @@ const HookReader = struct { | |||
| 284 | self.capture_last = copy; | 333 | self.capture_last = copy; |
| 285 | return; | 334 | return; |
| 286 | }, | 335 | }, |
| 336 | .clipboard, .primary => |path| { | ||
| 337 | const text = (if (hook == .primary) c.SDL_GetPrimarySelectionText() else c.SDL_GetClipboardText()) orelse return error.ClipboardReadFailed; | ||
| 338 | defer c.SDL_free(@ptrCast(text)); | ||
| 339 | const file = try std.fs.cwd().createFile(path, .{ .truncate = true }); | ||
| 340 | defer file.close(); | ||
| 341 | try file.writeAll(std.mem.span(text)); | ||
| 342 | return; | ||
| 343 | }, | ||
| 287 | .quit => ev.type = c.SDL_EVENT_QUIT, | 344 | .quit => ev.type = c.SDL_EVENT_QUIT, |
| 288 | } | 345 | } |
| 289 | if (!c.SDL_PushEvent(&ev)) { | 346 | if (!c.SDL_PushEvent(&ev)) { |
| @@ -319,7 +376,7 @@ const Events = struct { | |||
| 319 | self.syncCapture(); | 376 | self.syncCapture(); |
| 320 | } | 377 | } |
| 321 | fn syncCapture(self: *Events) void { | 378 | fn syncCapture(self: *Events) void { |
| 322 | const capturing = self.ui.drag != null; | 379 | const capturing = self.ui.hasPointerCapture(); |
| 323 | if (self.captured != capturing) { | 380 | if (self.captured != capturing) { |
| 324 | _ = c.SDL_CaptureMouse(capturing); | 381 | _ = c.SDL_CaptureMouse(capturing); |
| 325 | self.captured = capturing; | 382 | self.captured = capturing; |
| @@ -328,7 +385,7 @@ const Events = struct { | |||
| 328 | /// Commit events sample the actual drawable even if its resize notice | 385 | /// Commit events sample the actual drawable even if its resize notice |
| 329 | /// is still behind this event in SDL's bounded queue. | 386 | /// is still behind this event in SDL's bounded queue. |
| 330 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 387 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 331 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or (self.ui.drag != null and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { | 388 | if ((ev.type == c.SDL_EVENT_KEY_DOWN and (self.ui.resize_mode or ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN or ev.type == c.SDL_EVENT_MOUSE_WHEEL or ((self.ui.hasPointerCapture()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 332 | self.geometry_dirty = true; | 389 | self.geometry_dirty = true; |
| 333 | try self.refreshGeometry(); | 390 | try self.refreshGeometry(); |
| 334 | } | 391 | } |
| @@ -346,27 +403,48 @@ const Events = struct { | |||
| 346 | c.SDL_EVENT_KEY_DOWN => try self.ui.keyDown(interactionKey(ev.key)), | 403 | c.SDL_EVENT_KEY_DOWN => try self.ui.keyDown(interactionKey(ev.key)), |
| 347 | c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key), | 404 | c.SDL_EVENT_KEY_UP => self.ui.keyUp(ev.key.key), |
| 348 | c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(), | 405 | c.SDL_EVENT_WINDOW_FOCUS_LOST => self.ui.focusLost(), |
| 349 | c.SDL_EVENT_MOUSE_BUTTON_DOWN => if (ev.button.button == c.SDL_BUTTON_LEFT) { | 406 | c.SDL_EVENT_USER => if (ev.user.code == 0x4d4f4453) { |
| 407 | const mods = @intFromPtr(ev.user.data1); | ||
| 408 | c.SDL_SetModState(@as(c.SDL_Keymod, if (mods & 1 != 0) c.SDL_KMOD_SHIFT else 0) | @as(c.SDL_Keymod, if (mods & 2 != 0) c.SDL_KMOD_ALT else 0) | @as(c.SDL_Keymod, if (mods & 4 != 0) c.SDL_KMOD_CTRL else 0)); | ||
| 409 | }, | ||
| 410 | c.SDL_EVENT_MOUSE_BUTTON_DOWN => { | ||
| 411 | const button = mouseButton(ev.button.button) orelse return true; | ||
| 350 | var w: c_int = 0; | 412 | var w: c_int = 0; |
| 351 | var h: c_int = 0; | 413 | var h: c_int = 0; |
| 352 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 414 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 353 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h); | 415 | const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.ui.fb_w, self.ui.fb_h); |
| 354 | try self.ui.pointerDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h)); | 416 | try self.ui.mouseDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h), button, mouseMods()); |
| 417 | } | ||
| 418 | }, | ||
| 419 | c.SDL_EVENT_MOUSE_WHEEL => { | ||
| 420 | var w: c_int = 0; | ||
| 421 | var h: c_int = 0; | ||
| 422 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | ||
| 423 | const at = physicalPoint(ev.wheel.mouse_x, ev.wheel.mouse_y, w, h, self.ui.fb_w, self.ui.fb_h); | ||
| 424 | try self.ui.wheel(at.x, at.y, ev.wheel.y, ev.wheel.direction == c.SDL_MOUSEWHEEL_FLIPPED, mouseMods()); | ||
| 355 | } | 425 | } |
| 356 | }, | 426 | }, |
| 357 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { | 427 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { |
| 358 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; | 428 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; |
| 359 | if (self.ui.drag != null) { | 429 | const button = if (motion) 0 else mouseButton(ev.button.button) orelse return true; |
| 360 | var w: c_int = 0; | 430 | var w: c_int = 0; |
| 361 | var h: c_int = 0; | 431 | var h: c_int = 0; |
| 362 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | 432 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { |
| 363 | const motion = ev.type == c.SDL_EVENT_MOUSE_MOTION; | 433 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); |
| 364 | const x = physicalSignedAxis(if (motion) ev.motion.x else ev.button.x, w, self.ui.fb_w); | 434 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); |
| 365 | const y = physicalSignedAxis(if (motion) ev.motion.y else ev.button.y, h, self.ui.fb_h); | 435 | if (x != null and y != null) { |
| 366 | if (x != null and y != null) try self.ui.pointerMove(x.?, y.?); | 436 | if (motion) try self.ui.mouseMove(x.?, y.?, mouseMods()) else { |
| 437 | if (button == 0 and self.ui.drag != null) try self.ui.pointerMove(x.?, y.?); | ||
| 438 | try self.ui.mouseUp(@intCast(@max(x.?, 0)), @intCast(@max(y.?, 0)), button, mouseMods()); | ||
| 439 | } | ||
| 440 | } else if (self.ui.app_drag != null) { | ||
| 441 | self.ui.cancelMouse(); | ||
| 442 | } else if (motion) { | ||
| 443 | try self.ui.pointerMove(-1, -1); | ||
| 444 | } else if (button == 0) { | ||
| 445 | try self.ui.mouseUp(0, 0, button, mouseMods()); | ||
| 367 | } | 446 | } |
| 368 | } | 447 | } |
| 369 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP) self.ui.cancelDrag(); | ||
| 370 | }, | 448 | }, |
| 371 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, | 449 | c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, |
| 372 | c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true, | 450 | c.SDL_EVENT_WINDOW_EXPOSED => self.ui.dirty = true, |
| @@ -384,7 +462,10 @@ const Events = struct { | |||
| 384 | var logical_w: c_int = 0; | 462 | var logical_w: c_int = 0; |
| 385 | var logical_h: c_int = 0; | 463 | var logical_h: c_int = 0; |
| 386 | if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; | 464 | if (!c.SDL_GetWindowSize(self.win, &logical_w, &logical_h)) return error.WindowSizeFailed; |
| 387 | if (logical_w != self.logical_w or logical_h != self.logical_h) self.ui.cancelDrag(); | 465 | if (logical_w != self.logical_w or logical_h != self.logical_h) { |
| 466 | self.ui.cancelDrag(); | ||
| 467 | self.ui.clearSelection(); | ||
| 468 | } | ||
| 388 | self.logical_w = logical_w; | 469 | self.logical_w = logical_w; |
| 389 | self.logical_h = logical_h; | 470 | self.logical_h = logical_h; |
| 390 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); | 471 | try self.updateGeometry(w, h, c.SDL_GetWindowDisplayScale(self.win)); |
| @@ -418,6 +499,7 @@ fn grabPixels(logical: c_int, pixels: c_int) u32 { | |||
| 418 | fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { | 499 | fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { |
| 419 | const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key; | 500 | const translated = if (ev.scancode != c.SDL_SCANCODE_UNKNOWN) c.SDL_GetKeyFromScancode(ev.scancode, ev.mod, false) else ev.key; |
| 420 | const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; | 501 | const prefix = ev.key == c.SDLK_BACKSLASH and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; |
| 502 | const copy = ev.key == c.SDLK_C and ev.mod & c.SDL_KMOD_CTRL != 0 and ev.mod & c.SDL_KMOD_SHIFT != 0 and ev.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; | ||
| 421 | return .{ | 503 | return .{ |
| 422 | .code = ev.key, | 504 | .code = ev.key, |
| 423 | .kind = switch (ev.key) { | 505 | .kind = switch (ev.key) { |
| @@ -444,6 +526,7 @@ fn interactionKey(ev: c.SDL_KeyboardEvent) interaction.KeyDown { | |||
| 444 | .prefix = prefix, | 526 | .prefix = prefix, |
| 445 | .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0, | 527 | .modified = ev.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0, |
| 446 | .repeat = ev.repeat, | 528 | .repeat = ev.repeat, |
| 529 | .copy = copy, | ||
| 447 | .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod), | 530 | .terminal = keyEvent(if (prefix) ev.key else translated, ev.mod), |
| 448 | }; | 531 | }; |
| 449 | } | 532 | } |
| @@ -555,7 +638,19 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 555 | try events.refreshGeometry(); | 638 | try events.refreshGeometry(); |
| 556 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); | 639 | if (usr1_seen.swap(false, .acq_rel)) report(&ring); |
| 557 | const now = std.time.milliTimestamp(); | 640 | const now = std.time.milliTimestamp(); |
| 558 | events.ui.dirty = rt.poll(now) or events.ui.dirty; | 641 | events.ui.dirty = events.ui.poll(now) or events.ui.dirty; |
| 642 | // Every effect is drained from its source attachment; focus does not | ||
| 643 | // redirect another pane's write into a selection request. | ||
| 644 | for (events.ui.rt.lives) |slot| if (slot) |live| { | ||
| 645 | inline for (.{ false, true }) |primary| if (live.pump.takeClipboard(primary)) |text| { | ||
| 646 | defer alloc.free(text); | ||
| 647 | try setClipboard(&events.ui, text, primary); | ||
| 648 | }; | ||
| 649 | }; | ||
| 650 | if (events.ui.takeSelectionText()) |text| { | ||
| 651 | defer alloc.free(text); | ||
| 652 | try setClipboard(&events.ui, text, false); | ||
| 653 | } | ||
| 559 | try events.ui.pollEnd(); | 654 | try events.ui.pollEnd(); |
| 560 | events.syncCapture(); | 655 | events.syncCapture(); |
| 561 | if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { | 656 | if (events.ui.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { |
| @@ -627,7 +722,10 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 627 | ctx.y0 = @floatFromInt(p.content.y); | 722 | ctx.y0 = @floatFromInt(p.content.y); |
| 628 | const bg_start = lists.backgrounds.items.len; | 723 | const bg_start = lists.backgrounds.items.len; |
| 629 | const fg_start = lists.foregrounds.items.len; | 724 | const fg_start = lists.foregrounds.items.len; |
| 630 | for (0..grid.rows) |y| visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; | 725 | for (0..grid.rows) |y| { |
| 726 | ctx.selection = if (events.ui.selectedSpan(p.id, live.view_origin + @as(u32, @intCast(y)), grid.cols)) |s| .{ .from = s.from, .to = s.to } else null; | ||
| 727 | visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; | ||
| 728 | } | ||
| 631 | if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx)); | 729 | if (rt.workspace.tab().focus == p.id and grid.cursor.x < grid.cols and grid.cursor.y < grid.rows) try lists.foregrounds.append(alloc, quads.cursorInstance(grid.cursor.x, grid.cursor.y, ctx)); |
| 632 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); | 730 | clipPane(&lists, bg_start, fg_start, model.Rect.intersect(p.content, p.visible)); |
| 633 | ctx.x0 = @floatFromInt(p.header.x); | 731 | ctx.x0 = @floatFromInt(p.header.x); |
| @@ -860,6 +958,26 @@ test "pending End header stays with its origin and rejects stale generations" { | |||
| 860 | events.ui.pending_end = null; | 958 | events.ui.pending_end = null; |
| 861 | } | 959 | } |
| 862 | 960 | ||
| 961 | fn mouseButton(button: u8) ?u8 { | ||
| 962 | return switch (button) { | ||
| 963 | c.SDL_BUTTON_LEFT => 0, | ||
| 964 | c.SDL_BUTTON_MIDDLE => 1, | ||
| 965 | c.SDL_BUTTON_RIGHT => 2, | ||
| 966 | else => null, | ||
| 967 | }; | ||
| 968 | } | ||
| 969 | fn mouseMods() keymap.Mods { | ||
| 970 | const mods = c.SDL_GetModState(); | ||
| 971 | return .{ .shift = mods & c.SDL_KMOD_SHIFT != 0, .ctrl = mods & c.SDL_KMOD_CTRL != 0, .alt = mods & c.SDL_KMOD_ALT != 0 }; | ||
| 972 | } | ||
| 973 | fn setClipboard(ui: *interaction.Controller, text: []const u8, primary: bool) !void { | ||
| 974 | if (!client.core.validClipboardText(text)) return; | ||
| 975 | const z = try ui.rt.alloc.dupeZ(u8, text); | ||
| 976 | defer ui.rt.alloc.free(z); | ||
| 977 | const ok = if (primary) c.SDL_SetPrimarySelectionText(z.ptr) else c.SDL_SetClipboardText(z.ptr); | ||
| 978 | if (!ok) ui.setNotice("Clipboard update failed"); | ||
| 979 | } | ||
| 980 | |||
| 863 | const Header = struct { | 981 | const Header = struct { |
| 864 | bytes: [512]u8 = undefined, | 982 | bytes: [512]u8 = undefined, |
| 865 | cells: [512]term.grid.Cell = undefined, | 983 | cells: [512]term.grid.Cell = undefined, |
| @@ -1224,6 +1342,28 @@ test "command key text is consumed and subsequent ordinary text is not suppresse | |||
| 1224 | try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); | 1342 | try std.testing.expectEqualSlices(u8, &.{0x1c}, keymap.encode(keyEvent(c.SDLK_BACKSLASH, event.key.mod).?, &bytes)); |
| 1225 | } | 1343 | } |
| 1226 | 1344 | ||
| 1345 | test "copy chord is exact while plain interrupt remains terminal input" { | ||
| 1346 | var copy_event = std.mem.zeroes(c.SDL_KeyboardEvent); | ||
| 1347 | copy_event.key = c.SDLK_C; | ||
| 1348 | copy_event.mod = c.SDL_KMOD_CTRL | c.SDL_KMOD_SHIFT; | ||
| 1349 | const copy = interactionKey(copy_event); | ||
| 1350 | try std.testing.expect(copy.copy); | ||
| 1351 | var interrupt_event = copy_event; | ||
| 1352 | interrupt_event.mod = c.SDL_KMOD_CTRL; | ||
| 1353 | const interrupt = interactionKey(interrupt_event); | ||
| 1354 | try std.testing.expect(!interrupt.copy); | ||
| 1355 | try std.testing.expect(interrupt.terminal != null); | ||
| 1356 | for ([_]u16{ c.SDL_KMOD_LCTRL | c.SDL_KMOD_LSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_LCTRL | c.SDL_KMOD_RSHIFT, c.SDL_KMOD_RCTRL | c.SDL_KMOD_LSHIFT }) |mods| { | ||
| 1357 | copy_event.mod = mods; | ||
| 1358 | try std.testing.expect(interactionKey(copy_event).copy); | ||
| 1359 | } | ||
| 1360 | for ([_]u16{ c.SDL_KMOD_ALT, c.SDL_KMOD_GUI, c.SDL_KMOD_MODE, c.SDL_KMOD_RALT }) |extra| { | ||
| 1361 | var excluded = copy_event; | ||
| 1362 | excluded.mod |= extra; | ||
| 1363 | try std.testing.expect(!interactionKey(excluded).copy); | ||
| 1364 | } | ||
| 1365 | } | ||
| 1366 | |||
| 1227 | test "later pane atlas growth precedes earlier pane UV generation" { | 1367 | test "later pane atlas growth precedes earlier pane UV generation" { |
| 1228 | const a = std.testing.allocator; | 1368 | const a = std.testing.allocator; |
| 1229 | var face = try font.Face.open(16); | 1369 | var face = try font.Face.open(16); |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -19,9 +19,21 @@ pub const KeyDown = struct { | |||
| 19 | prefix: bool = false, | 19 | prefix: bool = false, |
| 20 | modified: bool = false, | 20 | modified: bool = false, |
| 21 | repeat: bool = false, | 21 | repeat: bool = false, |
| 22 | copy: bool = false, | ||
| 22 | terminal: ?keymap.Event = null, | 23 | terminal: ?keymap.Event = null, |
| 23 | }; | 24 | }; |
| 24 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; | 25 | pub const PendingEnd = struct { key: model.Attachment, request: u64 }; |
| 26 | pub const Wheel = struct { | ||
| 27 | fraction: f32 = 0, | ||
| 28 | /// Convert native wheel lines into whole terminal notches, retaining input. | ||
| 29 | pub fn notches(self: *Wheel, delta: f32, flipped: bool) i32 { | ||
| 30 | if (!std.math.isFinite(delta)) return 0; | ||
| 31 | self.fraction += std.math.clamp(if (flipped) -delta else delta, -1024, 1024); | ||
| 32 | const whole = @trunc(self.fraction); | ||
| 33 | self.fraction -= whole; | ||
| 34 | return @intFromFloat(whole); | ||
| 35 | } | ||
| 36 | }; | ||
| 25 | 37 | ||
| 26 | pub const Recovery = struct { | 38 | pub const Recovery = struct { |
| 27 | kind: enum { recovery, force_end }, | 39 | kind: enum { recovery, force_end }, |
| @@ -80,6 +92,14 @@ pub const Controller = struct { | |||
| 80 | resize_mode: bool = false, | 92 | resize_mode: bool = false, |
| 81 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, | 93 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, |
| 82 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, | 94 | drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null, |
| 95 | selection_drag: client.selection.Drag = .{}, | ||
| 96 | app_drag: ?struct { key: model.Attachment, token: client.session_pump.MouseToken, button: u8 } = null, | ||
| 97 | local_held: bool = false, | ||
| 98 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), | ||
| 99 | selection_key: ?model.Attachment = null, | ||
| 100 | selection_request: u32 = 0, | ||
| 101 | selection_gesture: u32 = 0, | ||
| 102 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 83 | consumed_key: ?u32 = null, | 103 | consumed_key: ?u32 = null, |
| 84 | notice: []const u8 = "", | 104 | notice: []const u8 = "", |
| 85 | 105 | ||
| @@ -88,11 +108,97 @@ pub const Controller = struct { | |||
| 88 | pub fn deinit(self: *Controller) void { | 108 | pub fn deinit(self: *Controller) void { |
| 89 | if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{}); | 109 | if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{}); |
| 90 | self.cancelDrag(); | 110 | self.cancelDrag(); |
| 111 | self.clearSelection(); | ||
| 91 | self.modal_held.deinit(self.rt.alloc); | 112 | self.modal_held.deinit(self.rt.alloc); |
| 92 | if (self.picker) |picker| picker.deinit(); | 113 | if (self.picker) |picker| picker.deinit(); |
| 93 | } | 114 | } |
| 115 | pub fn hasPointerCapture(self: *const Controller) bool { | ||
| 116 | return self.drag != null or self.selection_drag.buttonHeld() or self.app_drag != null or self.local_held; | ||
| 117 | } | ||
| 118 | pub fn cancelMouse(self: *Controller) void { | ||
| 119 | if (self.app_drag) |app| { | ||
| 120 | if (self.rt.accepts(app.key)) self.rt.get(app.key.pane).?.pump.cancelMouse(app.token); | ||
| 121 | self.app_drag = null; | ||
| 122 | } | ||
| 123 | self.local_held = false; | ||
| 124 | } | ||
| 125 | pub fn clearSelection(self: *Controller) void { | ||
| 126 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); | ||
| 127 | self.selection_drag.clear(); | ||
| 128 | self.selection_key = null; | ||
| 129 | self.selection_gesture = 0; | ||
| 130 | self.dirty = true; | ||
| 131 | } | ||
| 132 | fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit { | ||
| 133 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return null; | ||
| 134 | const id = self.layout.hit(x, y) orelse return null; | ||
| 135 | const p = self.layout.get(id) orelse return null; | ||
| 136 | if (x < p.content.x or y < p.content.y or x >= p.content.x + p.content.w or y >= p.content.y + p.content.h) return null; | ||
| 137 | const live = self.rt.get(id) orelse return null; | ||
| 138 | const local_row: u32 = (y - p.content.y) / self.metrics.cell_h; | ||
| 139 | const col: u16 = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x - p.content.x) / self.metrics.cell_w)); | ||
| 140 | if (local_row >= live.snapshot.rows or col >= live.snapshot.cols) return null; | ||
| 141 | const actual_col = if (live.snapshot.row(@intCast(local_row)).cells[col].wide == .spacer_tail and col > 0) col - 1 else col; | ||
| 142 | return .{ .tile = @intCast(id), .row = live.view_origin + local_row, .col = actual_col }; | ||
| 143 | } | ||
| 144 | fn cellFor(self: *Controller, x: u32, y: u32) client.selection.Cell { | ||
| 145 | const h = self.hit(x, y) orelse return self.selection_drag.at; | ||
| 146 | const p = self.layout.get(@intCast(h.tile)) orelse return self.selection_drag.at; | ||
| 147 | return .{ .row = @intCast((y -| p.content.y) / self.metrics.cell_h), .col = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x -| p.content.x) / self.metrics.cell_w)) }; | ||
| 148 | } | ||
| 149 | pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span { | ||
| 150 | if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) { | ||
| 151 | if (self.selection_gesture != 0 and !self.selection_drag.buttonHeld()) { | ||
| 152 | const position = self.rt.get(key.pane).?.snapshot_follow orelse return null; | ||
| 153 | if (position.id != self.selection_gesture or position.status != .ok) return null; | ||
| 154 | const range: client.selection.Range = .{ | ||
| 155 | .from = .{ .tile = tile, .row = position.anchor.row, .col = position.anchor.col }, | ||
| 156 | .to = .{ .tile = tile, .row = position.active.row, .col = position.active.col }, | ||
| 157 | }; | ||
| 158 | return range.span(tile, row, cols); | ||
| 159 | } | ||
| 160 | return (self.selection_drag.range() orelse return null).span(tile, row, cols); | ||
| 161 | }; | ||
| 162 | return null; | ||
| 163 | } | ||
| 164 | pub fn poll(self: *Controller, now: i64) bool { | ||
| 165 | const changed = self.rt.poll(now); | ||
| 166 | if (self.app_drag) |app| { | ||
| 167 | if (!self.rt.accepts(app.key) or !self.rt.get(app.key.pane).?.pump.mouseFresh(app.token)) self.cancelMouse(); | ||
| 168 | } | ||
| 169 | if (self.selection_key) |key| { | ||
| 170 | const live = self.rt.get(key.pane) orelse { | ||
| 171 | self.clearSelection(); | ||
| 172 | return true; | ||
| 173 | }; | ||
| 174 | const fresh = if (self.selection_drag.buttonHeld()) | ||
| 175 | live.pump.selectionFresh(self.selection_version) | ||
| 176 | else if (self.selection_gesture != 0) | ||
| 177 | live.pump.selectionAlive(self.selection_gesture, self.selection_version) | ||
| 178 | else | ||
| 179 | live.pump.selectionFresh(self.selection_version); | ||
| 180 | if (!self.rt.accepts(key) or !fresh) { | ||
| 181 | self.clearSelection(); | ||
| 182 | return true; | ||
| 183 | } | ||
| 184 | } | ||
| 185 | return changed; | ||
| 186 | } | ||
| 187 | pub fn takeSelectionText(self: *Controller) ?[]u8 { | ||
| 188 | const key = self.selection_key orelse return null; | ||
| 189 | const live = self.rt.get(key.pane) orelse return null; | ||
| 190 | if (!self.rt.accepts(key)) return null; | ||
| 191 | const result = live.pump.takeSelection() orelse return null; | ||
| 192 | if (result.id != self.selection_request or result.gesture != self.selection_gesture or result.status != .ok) { | ||
| 193 | self.rt.alloc.free(result.text); | ||
| 194 | if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable"); | ||
| 195 | return null; | ||
| 196 | } | ||
| 197 | return result.text; | ||
| 198 | } | ||
| 94 | pub fn command(self: *Controller, key: Key) !void { | 199 | pub fn command(self: *Controller, key: Key) !void { |
| 95 | self.cancelDrag(); | 200 | self.cancelDrag(); |
| 201 | self.clearSelection(); | ||
| 96 | self.command_mode = false; | 202 | self.command_mode = false; |
| 97 | self.notice = ""; | 203 | self.notice = ""; |
| 98 | const ws = &self.rt.workspace; | 204 | const ws = &self.rt.workspace; |
| @@ -136,6 +242,7 @@ pub const Controller = struct { | |||
| 136 | } | 242 | } |
| 137 | pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { | 243 | pub fn openPicker(self: *Controller, mode: picker_mod.Picker.Mode) !void { |
| 138 | self.cancelDrag(); | 244 | self.cancelDrag(); |
| 245 | self.clearSelection(); | ||
| 139 | self.recovery = null; | 246 | self.recovery = null; |
| 140 | self.notice = ""; | 247 | self.notice = ""; |
| 141 | const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); | 248 | const picker = try picker_mod.Picker.initMode(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake_ctx, self.wake, mode); |
| @@ -248,6 +355,7 @@ pub const Controller = struct { | |||
| 248 | } | 355 | } |
| 249 | pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { | 356 | pub fn pointerDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { |
| 250 | self.cancelDrag(); | 357 | self.cancelDrag(); |
| 358 | self.clearSelection(); | ||
| 251 | if (self.recovery) |menu| { | 359 | if (self.recovery) |menu| { |
| 252 | const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics); | 360 | const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics); |
| 253 | for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) { | 361 | for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) { |
| @@ -266,11 +374,147 @@ pub const Controller = struct { | |||
| 266 | if (self.layout.hit(x, y)) |id| { | 374 | if (self.layout.hit(x, y)) |id| { |
| 267 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; | 375 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; |
| 268 | _ = self.rt.workspace.focus(id); | 376 | _ = self.rt.workspace.focus(id); |
| 377 | const point = self.hit(x, y); | ||
| 378 | self.selection_drag.press(.{ .row = if (point) |h| @intCast(h.row - self.rt.get(id).?.view_origin) else 0, .col = if (point) |h| h.col else 0 }, point); | ||
| 379 | if (point) |h| { | ||
| 380 | const live = self.rt.get(h.tile).?; | ||
| 381 | self.selection_key = live.key; | ||
| 382 | self.selection_version = live.snapshot_version; | ||
| 383 | } | ||
| 269 | } else if (self.layout.len == 0) try self.openPicker(.insert); | 384 | } else if (self.layout.len == 0) try self.openPicker(.insert); |
| 270 | } | 385 | } |
| 271 | self.dirty = true; | 386 | self.dirty = true; |
| 272 | } | 387 | } |
| 388 | |||
| 389 | pub fn mouseDown(self: *Controller, x: u32, y: u32, grab_x: u32, grab_y: u32, button: u8, mods: keymap.Mods) !void { | ||
| 390 | if (self.hasPointerCapture() or button > 2 or self.command_mode) return; | ||
| 391 | if (self.picker != null or self.recovery != null or self.resize_mode or self.layout.hitDivider(x, y, grab_x, grab_y) != null) { | ||
| 392 | if (button == 0) try self.pointerDown(x, y, grab_x, grab_y); | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | if (self.layout.hit(x, y)) |id| { | ||
| 396 | const p = self.layout.get(id).?; | ||
| 397 | if (p.content.contains(x, y)) if (self.rt.get(id)) |live| { | ||
| 398 | if (!mods.shift) if (live.pump.mouseToken()) |token| if (token.modes.appMouse()) { | ||
| 399 | const event = self.mouseAt(live.key, token, .press, button, x, y, mods) orelse return; | ||
| 400 | try live.pump.say(.{ .mouse = event }); | ||
| 401 | self.clearSelection(); | ||
| 402 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; | ||
| 403 | _ = self.rt.workspace.focus(id); | ||
| 404 | self.app_drag = .{ .key = live.key, .token = token, .button = button }; | ||
| 405 | self.dirty = true; | ||
| 406 | return; | ||
| 407 | }; | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | if (button != 0) return; | ||
| 411 | try self.pointerDown(x, y, grab_x, grab_y); | ||
| 412 | self.local_held = self.selection_drag.buttonHeld(); | ||
| 413 | } | ||
| 414 | |||
| 415 | fn mouseAt(self: *Controller, key: model.Attachment, token: client.session_pump.MouseToken, kind: anytype, button: u8, x: u32, y: u32, mods: keymap.Mods) ?client.session_pump.Mouse { | ||
| 416 | const p = self.layout.get(key.pane) orelse return null; | ||
| 417 | if (self.metrics.cell_w == 0 or self.metrics.cell_h == 0 or p.cols == 0 or p.rows == 0 or p.content.w == 0 or p.content.h == 0) return null; | ||
| 418 | const px = std.math.clamp(x, p.content.x, p.content.x +| p.content.w -| 1); | ||
| 419 | const py = std.math.clamp(y, p.content.y, p.content.y +| p.content.h -| 1); | ||
| 420 | return .{ .token = token, .kind = kind, .button = button, .col = @intCast(@min(@as(u32, p.cols - 1), (px - p.content.x) / self.metrics.cell_w)), .row = @intCast(@min(@as(u32, p.rows - 1), (py - p.content.y) / self.metrics.cell_h)), .pixel_x = px - p.content.x, .pixel_y = py - p.content.y, .mods = mods }; | ||
| 421 | } | ||
| 422 | |||
| 423 | pub fn mouseMove(self: *Controller, x: i64, y: i64, mods: keymap.Mods) !void { | ||
| 424 | if (self.app_drag) |app| { | ||
| 425 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | ||
| 426 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); | ||
| 427 | if (!live.pump.mouseFresh(app.token)) return self.cancelMouse(); | ||
| 428 | const event = self.mouseAt(app.key, app.token, .motion, app.button, @intCast(@max(x, 0)), @intCast(@max(y, 0)), mods) orelse return; | ||
| 429 | try live.pump.say(.{ .mouse = event }); | ||
| 430 | return; | ||
| 431 | } | ||
| 432 | if (self.local_held or self.drag != null or self.selection_drag.buttonHeld()) return self.pointerMove(x, y); | ||
| 433 | if (x < 0 or y < 0 or mods.shift or self.picker != null or self.recovery != null or self.resize_mode or self.command_mode) return; | ||
| 434 | const id = self.layout.hit(@intCast(x), @intCast(y)) orelse return; | ||
| 435 | if (self.layout.hitDivider(@intCast(x), @intCast(y), 0, 0) != null) return; | ||
| 436 | const p = self.layout.get(id) orelse return; | ||
| 437 | if (!p.content.contains(@intCast(x), @intCast(y))) return; | ||
| 438 | const live = self.rt.get(id) orelse return; | ||
| 439 | const token = live.pump.mouseToken() orelse return; | ||
| 440 | if (!token.modes.mouse_any) return; | ||
| 441 | if (self.mouseAt(live.key, token, .motion, 3, @intCast(x), @intCast(y), mods)) |event| try live.pump.say(.{ .mouse = event }); | ||
| 442 | } | ||
| 443 | |||
| 444 | pub fn mouseUp(self: *Controller, x: u32, y: u32, button: u8, mods: keymap.Mods) !void { | ||
| 445 | if (self.app_drag) |app| { | ||
| 446 | if (button != app.button) return; | ||
| 447 | if (!self.rt.accepts(app.key)) return self.cancelMouse(); | ||
| 448 | const live = self.rt.get(app.key.pane) orelse return self.cancelMouse(); | ||
| 449 | if (live.pump.mouseFresh(app.token)) { | ||
| 450 | if (self.mouseAt(app.key, app.token, .release, app.button, x, y, mods)) |event| try live.pump.say(.{ .mouse = event }); | ||
| 451 | } | ||
| 452 | self.app_drag = null; | ||
| 453 | return; | ||
| 454 | } | ||
| 455 | if ((self.local_held or self.drag != null or self.selection_drag.buttonHeld()) and button == 0) { | ||
| 456 | self.local_held = false; | ||
| 457 | return self.pointerUp(x, y); | ||
| 458 | } | ||
| 459 | } | ||
| 460 | |||
| 461 | pub fn pointerUp(self: *Controller, x: u32, y: u32) !void { | ||
| 462 | if (self.drag != null) { | ||
| 463 | self.cancelDrag(); | ||
| 464 | return; | ||
| 465 | } | ||
| 466 | const id = self.selection_key orelse return; | ||
| 467 | const live = self.rt.get(id.pane) orelse return self.clearSelection(); | ||
| 468 | if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection(); | ||
| 469 | const point = self.hit(x, y); | ||
| 470 | const cell = self.cellFor(x, y); | ||
| 471 | self.selection_drag.motion(cell, point); | ||
| 472 | switch (self.selection_drag.release()) { | ||
| 473 | .click => {}, | ||
| 474 | .selection => |range| { | ||
| 475 | // A release always starts a new daemon tracker, even when a | ||
| 476 | // copy was requested while the button was held. | ||
| 477 | self.selection_gesture = 0; | ||
| 478 | try self.queueSelection(live, range); | ||
| 479 | }, | ||
| 480 | .nothing => self.clearSelection(), | ||
| 481 | } | ||
| 482 | self.dirty = true; | ||
| 483 | } | ||
| 484 | fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void { | ||
| 485 | self.selection_request +%= 1; | ||
| 486 | if (self.selection_request == 0) self.selection_request = 1; | ||
| 487 | if (self.selection_gesture == 0) self.selection_gesture = self.selection_request; | ||
| 488 | try self.rt.requestSelection(live.key, self.selection_request, self.selection_gesture, range, self.selection_version); | ||
| 489 | } | ||
| 490 | pub fn copySelection(self: *Controller) !void { | ||
| 491 | const key = self.selection_key orelse return; | ||
| 492 | if (!self.rt.accepts(key)) return self.clearSelection(); | ||
| 493 | const live = self.rt.get(key.pane) orelse return; | ||
| 494 | const range = self.selection_drag.range() orelse return; | ||
| 495 | const held = self.selection_drag.buttonHeld(); | ||
| 496 | const valid = if (held) | ||
| 497 | live.pump.selectionFresh(self.selection_version) | ||
| 498 | else | ||
| 499 | live.pump.selectionAlive(self.selection_gesture, self.selection_version); | ||
| 500 | if (!valid) return self.clearSelection(); | ||
| 501 | if (held) self.selection_gesture = 0; | ||
| 502 | try self.queueSelection(live, range); | ||
| 503 | } | ||
| 273 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { | 504 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { |
| 505 | if (self.drag == null and self.selection_drag.on() != null) { | ||
| 506 | if (x < 0 or y < 0) { | ||
| 507 | self.selection_drag.motion(self.selection_drag.at, null); | ||
| 508 | self.dirty = true; | ||
| 509 | return; | ||
| 510 | } | ||
| 511 | const px: u32 = @intCast(@max(x, 0)); | ||
| 512 | const py: u32 = @intCast(@max(y, 0)); | ||
| 513 | const point = self.hit(px, py); | ||
| 514 | self.selection_drag.motion(self.cellFor(px, py), point); | ||
| 515 | self.dirty = true; | ||
| 516 | return; | ||
| 517 | } | ||
| 274 | const drag = self.drag orelse return; | 518 | const drag = self.drag orelse return; |
| 275 | if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); | 519 | if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); |
| 276 | const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); | 520 | const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); |
| @@ -280,12 +524,45 @@ pub const Controller = struct { | |||
| 280 | try self.relayout(); | 524 | try self.relayout(); |
| 281 | } | 525 | } |
| 282 | } | 526 | } |
| 527 | /// Semantic wheel entry point. Transport routing is deliberately deferred | ||
| 528 | /// until the pump has sampled the pane's current terminal modes. | ||
| 529 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: keymap.Mods) !void { | ||
| 530 | if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; | ||
| 531 | const id = self.layout.hit(x, y) orelse return; | ||
| 532 | const placement = self.layout.get(id) orelse return; | ||
| 533 | if (!placement.content.contains(x, y) or placement.cols == 0 or placement.rows == 0 or self.metrics.cell_w == 0 or self.metrics.cell_h == 0) return; | ||
| 534 | const live = self.rt.get(id) orelse return; | ||
| 535 | const slot = found: { | ||
| 536 | for (&self.wheel_remainder) |*candidate| { | ||
| 537 | if (candidate.key != null and candidate.key.?.pane == live.key.pane) break :found candidate; | ||
| 538 | } | ||
| 539 | for (&self.wheel_remainder) |*candidate| { | ||
| 540 | if (candidate.key == null or self.rt.get(candidate.key.?.pane) == null) break :found candidate; | ||
| 541 | } | ||
| 542 | return; | ||
| 543 | }; | ||
| 544 | if (slot.key == null or !std.meta.eql(slot.key.?, live.key)) { | ||
| 545 | slot.* = .{ .key = live.key, .wheel = .{} }; | ||
| 546 | } | ||
| 547 | const notches = slot.wheel.notches(delta, flipped); | ||
| 548 | if (notches == 0) return; | ||
| 549 | try self.rt.wheel(live.key, .{ | ||
| 550 | .notches = notches, | ||
| 551 | .col = @intCast(@min((x - placement.content.x) / self.metrics.cell_w, placement.cols - 1)), | ||
| 552 | .row = @intCast(@min((y - placement.content.y) / self.metrics.cell_h, placement.rows - 1)), | ||
| 553 | .pixel_x = x - placement.content.x, | ||
| 554 | .pixel_y = y - placement.content.y, | ||
| 555 | .mods = mods, | ||
| 556 | }); | ||
| 557 | } | ||
| 283 | pub fn cancelDrag(self: *Controller) void { | 558 | pub fn cancelDrag(self: *Controller) void { |
| 559 | self.cancelMouse(); | ||
| 284 | if (self.drag == null) return; | 560 | if (self.drag == null) return; |
| 285 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; | 561 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; |
| 286 | self.drag = null; | 562 | self.drag = null; |
| 287 | } | 563 | } |
| 288 | pub fn sendKey(self: *Controller, key: keymap.Event) !void { | 564 | pub fn sendKey(self: *Controller, key: keymap.Event) !void { |
| 565 | self.clearSelection(); | ||
| 289 | var buf: [keymap.max_seq_len]u8 = undefined; | 566 | var buf: [keymap.max_seq_len]u8 = undefined; |
| 290 | const bytes = keymap.encode(key, &buf); | 567 | const bytes = keymap.encode(key, &buf); |
| 291 | if (bytes.len != 0) try self.rt.input(bytes); | 568 | if (bytes.len != 0) try self.rt.input(bytes); |
| @@ -300,7 +577,10 @@ pub const Controller = struct { | |||
| 300 | if (self.picker) |picker| { | 577 | if (self.picker) |picker| { |
| 301 | try picker.text(text); | 578 | try picker.text(text); |
| 302 | self.dirty = true; | 579 | self.dirty = true; |
| 303 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) try self.rt.input(text); | 580 | } else if (!self.command_mode and !self.resize_mode and self.recovery == null) { |
| 581 | self.clearSelection(); | ||
| 582 | try self.rt.input(text); | ||
| 583 | } | ||
| 304 | } | 584 | } |
| 305 | self.suppress_text = false; | 585 | self.suppress_text = false; |
| 306 | } | 586 | } |
| @@ -315,11 +595,15 @@ pub const Controller = struct { | |||
| 315 | self.resize_mode = false; | 595 | self.resize_mode = false; |
| 316 | self.modal_held.clearRetainingCapacity(); | 596 | self.modal_held.clearRetainingCapacity(); |
| 317 | self.cancelDrag(); | 597 | self.cancelDrag(); |
| 598 | self.clearSelection(); | ||
| 318 | self.consumed_key = null; | 599 | self.consumed_key = null; |
| 319 | self.dirty = true; | 600 | self.dirty = true; |
| 320 | } | 601 | } |
| 321 | pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void { | 602 | pub fn updateGeometry(self: *Controller, w: i32, h: i32, metrics: model.Metrics) !void { |
| 322 | if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) self.cancelDrag(); | 603 | if (w != self.fb_w or h != self.fb_h or !std.meta.eql(metrics, self.metrics)) { |
| 604 | self.cancelDrag(); | ||
| 605 | self.clearSelection(); | ||
| 606 | } | ||
| 323 | self.fb_w = w; | 607 | self.fb_w = w; |
| 324 | self.fb_h = h; | 608 | self.fb_h = h; |
| 325 | self.metrics = metrics; | 609 | self.metrics = metrics; |
| @@ -343,6 +627,12 @@ pub const Controller = struct { | |||
| 343 | self.suppress_text = true; | 627 | self.suppress_text = true; |
| 344 | return; | 628 | return; |
| 345 | } | 629 | } |
| 630 | if (input.copy and !input.prefix and self.picker == null and self.recovery == null and !self.resize_mode and !self.command_mode) { | ||
| 631 | self.consumed_key = key; | ||
| 632 | self.suppress_text = true; | ||
| 633 | try self.copySelection(); | ||
| 634 | return; | ||
| 635 | } | ||
| 346 | if (self.recovery) |*menu| { | 636 | if (self.recovery) |*menu| { |
| 347 | try self.modal_held.put(self.rt.alloc, key, {}); | 637 | try self.modal_held.put(self.rt.alloc, key, {}); |
| 348 | self.suppress_text = true; | 638 | self.suppress_text = true; |
| @@ -531,3 +821,102 @@ test "beginEnd records pending request without opening an ending modal" { | |||
| 531 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); | 821 | try std.testing.expectEqual(@as(u64, 1), ui.pending_end.?.request); |
| 532 | ui.pending_end = null; | 822 | ui.pending_end = null; |
| 533 | } | 823 | } |
| 824 | |||
| 825 | test "wheel retains fractional deltas and emits whole notches" { | ||
| 826 | var wheel: Wheel = .{}; | ||
| 827 | try std.testing.expectEqual(@as(i32, 0), wheel.notches(0.4, false)); | ||
| 828 | try std.testing.expectEqual(@as(i32, 1), wheel.notches(0.6, false)); | ||
| 829 | try std.testing.expectEqual(@as(i32, -1), wheel.notches(1, true)); | ||
| 830 | } | ||
| 831 | |||
| 832 | test "selection hit keeps absolute history and pane-local pointer cells" { | ||
| 833 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 834 | defer rt.deinit(); | ||
| 835 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 836 | const first = try rt.add(.{ .via = "cat" }, "history-a", 800, 600, metrics); | ||
| 837 | const second = try rt.add(.{ .via = "cat" }, "history-b", 800, 600, metrics); | ||
| 838 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 839 | defer ui.deinit(); | ||
| 840 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 841 | for (ui.layout.items()) |placement| { | ||
| 842 | const live = rt.get(placement.id).?; | ||
| 843 | try live.snapshot.resize(22, 9); | ||
| 844 | live.view_origin = if (placement.id == first) 70_000 else 3; | ||
| 845 | } | ||
| 846 | const a = ui.layout.get(first).?; | ||
| 847 | const b = ui.layout.get(second).?; | ||
| 848 | const ha = ui.hit(a.content.x + 4, a.content.y + 16).?; | ||
| 849 | const hb = ui.hit(b.content.x + 4, b.content.y + 16).?; | ||
| 850 | try std.testing.expectEqual(@as(u32, 70_001), ha.row); | ||
| 851 | try std.testing.expectEqual(@as(u32, 4), hb.row); | ||
| 852 | try std.testing.expectEqual(@as(u16, 1), ui.cellFor(a.content.x + 4, a.content.y + 16).row); | ||
| 853 | try std.testing.expectEqual(@as(u16, 1), ui.cellFor(b.content.x + 4, b.content.y + 16).row); | ||
| 854 | try ui.pointerDown(a.content.x + 4, a.content.y + 16, 0, 0); | ||
| 855 | try ui.pointerMove(b.content.x + 4, b.content.y + 32); | ||
| 856 | try std.testing.expect(ui.selection_drag.buttonHeld()); | ||
| 857 | try std.testing.expectEqual(@as(u32, 70_001), ui.selection_drag.range().?.to.row); | ||
| 858 | try ui.pointerMove(a.content.x + 12, a.content.y + 32); | ||
| 859 | try std.testing.expectEqual(@as(u32, 70_002), ui.selection_drag.range().?.to.row); | ||
| 860 | ui.focusLost(); | ||
| 861 | try std.testing.expect(!ui.selection_drag.buttonHeld()); | ||
| 862 | try std.testing.expect(ui.selection_drag.range() == null); | ||
| 863 | } | ||
| 864 | |||
| 865 | test "held selection copies restart the gesture and release starts another" { | ||
| 866 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 867 | defer rt.deinit(); | ||
| 868 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 869 | const id = try rt.add(.{ .via = "cat" }, "copy-gesture", 800, 600, metrics); | ||
| 870 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 871 | defer ui.deinit(); | ||
| 872 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 873 | const live = rt.get(id).?; | ||
| 874 | live.pump.mu.lock(); | ||
| 875 | try live.snapshot.resize(22, 9); | ||
| 876 | live.pump.status.phase = .attached; | ||
| 877 | live.pump.admitted = true; | ||
| 878 | live.pump.replica.state_since_attach = true; | ||
| 879 | live.pump.follow_seq = live.pump.replica.last_seq; | ||
| 880 | live.pump.follow_source = 1; | ||
| 881 | const source_version = live.pump.selectionVersionLocked(); | ||
| 882 | live.pump.mu.unlock(); | ||
| 883 | const p = ui.layout.get(id).?.content; | ||
| 884 | try ui.pointerDown(p.x + 4, p.y + 8, 0, 0); | ||
| 885 | ui.selection_version = source_version; | ||
| 886 | try ui.pointerMove(p.x + 20, p.y + 8); | ||
| 887 | const first_range = ui.selection_drag.range().?; | ||
| 888 | try std.testing.expectEqual(@as(u16, 2), first_range.to.col); | ||
| 889 | try ui.copySelection(); | ||
| 890 | const first = ui.selection_gesture; | ||
| 891 | try std.testing.expect(first != 0); | ||
| 892 | try ui.pointerMove(p.x + 28, p.y + 8); | ||
| 893 | const extended_range = ui.selection_drag.range().?; | ||
| 894 | try std.testing.expectEqual(@as(u16, 3), extended_range.to.col); | ||
| 895 | try std.testing.expect(extended_range.to.col != first_range.to.col); | ||
| 896 | try ui.copySelection(); | ||
| 897 | try std.testing.expect(ui.selection_gesture != first); | ||
| 898 | const second = ui.selection_gesture; | ||
| 899 | try ui.pointerUp(p.x + 36, p.y + 8); | ||
| 900 | try std.testing.expect(ui.selection_gesture != second); | ||
| 901 | try std.testing.expectEqual(@as(u16, 4), ui.selection_drag.range().?.to.col); | ||
| 902 | } | ||
| 903 | |||
| 904 | test "wheel reuses an existing pane remainder before a vacant earlier slot" { | ||
| 905 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 906 | defer rt.deinit(); | ||
| 907 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 908 | const id = try rt.add(.{ .via = "cat" }, "fraction", 800, 600, metrics); | ||
| 909 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 910 | defer ui.deinit(); | ||
| 911 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 912 | const live = rt.get(id).?; | ||
| 913 | ui.wheel_remainder[0] = .{ .key = .{ .pane = id + 1, .generation = 1 }, .wheel = .{ .fraction = 0.75 } }; | ||
| 914 | ui.wheel_remainder[1] = .{ .key = live.key, .wheel = .{ .fraction = 0.5 } }; | ||
| 915 | const rect = ui.layout.get(id).?.content; | ||
| 916 | try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{}); | ||
| 917 | try std.testing.expectEqual(@as(f32, 0), ui.wheel_remainder[1].wheel.fraction); | ||
| 918 | try std.testing.expectEqual(@as(f32, 0.75), ui.wheel_remainder[0].wheel.fraction); | ||
| 919 | ui.wheel_remainder[1].key.?.generation +%= 1; | ||
| 920 | try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{}); | ||
| 921 | try std.testing.expectEqual(@as(f32, 0.5), ui.wheel_remainder[1].wheel.fraction); | ||
| 922 | } | ||
src/gui/native.zig
| Old | New | ||
|---|---|---|---|
| @@ -16,9 +16,10 @@ pub const frame = @import("frame.zig"); | |||
| 16 | pub const bench = @import("bench.zig"); | 16 | pub const bench = @import("bench.zig"); |
| 17 | pub const atlas = @import("atlas.zig"); | 17 | pub const atlas = @import("atlas.zig"); |
| 18 | pub const font = @import("font.zig"); | 18 | pub const font = @import("font.zig"); |
| 19 | pub const config = @import("config.zig"); | 19 | pub const config = core.config; |
| 20 | pub const quads = @import("quads.zig"); | 20 | pub const quads = @import("quads.zig"); |
| 21 | pub const theme = @import("theme.zig"); | 21 | pub const theme = core.theme; |
| 22 | pub const font_options = core.font_options; | ||
| 22 | pub const gl = @import("gl.zig"); | 23 | pub const gl = @import("gl.zig"); |
| 23 | 24 | ||
| 24 | // Compatibility aliases for callers that previously reached these through | 25 | // Compatibility aliases for callers that previously reached these through |
| @@ -41,8 +42,6 @@ test { | |||
| 41 | _ = bench; | 42 | _ = bench; |
| 42 | _ = atlas; | 43 | _ = atlas; |
| 43 | _ = font; | 44 | _ = font; |
| 44 | _ = config; | ||
| 45 | _ = quads; | 45 | _ = quads; |
| 46 | _ = theme; | ||
| 47 | _ = gl; | 46 | _ = gl; |
| 48 | } | 47 | } |
src/gui/native_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,6 +1,7 @@ | |||
| 1 | //! Window-free native GUI model, transport runtime, picker, persistence, and | 1 | //! Window-free native GUI model, transport runtime, appearance/font policy, |
| 2 | //! interaction policy. The native window and painter consume this component; | 2 | //! picker, persistence, and interaction policy. The native window and painter |
| 3 | //! keeping it as its own module lets its tests run without window libraries. | 3 | //! consume this component; keeping it as its own module lets its tests run |
| 4 | //! without window libraries. | ||
| 4 | const client = @import("client"); | 5 | const client = @import("client"); |
| 5 | const term = @import("term"); | 6 | const term = @import("term"); |
| 6 | 7 | ||
| @@ -9,6 +10,9 @@ pub const runtime = @import("runtime.zig"); | |||
| 9 | pub const picker = @import("picker.zig"); | 10 | pub const picker = @import("picker.zig"); |
| 10 | pub const persistence = @import("persistence.zig"); | 11 | pub const persistence = @import("persistence.zig"); |
| 11 | pub const interaction = @import("interaction.zig"); | 12 | pub const interaction = @import("interaction.zig"); |
| 13 | pub const config = @import("config.zig"); | ||
| 14 | pub const theme = @import("theme.zig"); | ||
| 15 | pub const font_options = @import("font_options.zig"); | ||
| 12 | 16 | ||
| 13 | test { | 17 | test { |
| 14 | _ = client; | 18 | _ = client; |
| @@ -18,4 +22,7 @@ test { | |||
| 18 | _ = picker; | 22 | _ = picker; |
| 19 | _ = persistence; | 23 | _ = persistence; |
| 20 | _ = interaction; | 24 | _ = interaction; |
| 25 | _ = config; | ||
| 26 | _ = theme; | ||
| 27 | _ = font_options; | ||
| 21 | } | 28 | } |
src/gui/quads.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,10 +1,11 @@ | |||
| 1 | //! Grid cells to ordered background and foreground instance streams. | 1 | //! Grid cells to ordered background and foreground instance streams. |
| 2 | const std = @import("std"); | 2 | const std = @import("std"); |
| 3 | const client = @import("client"); | ||
| 3 | const term = @import("term"); | 4 | const term = @import("term"); |
| 4 | const grid = term.grid; | 5 | const grid = term.grid; |
| 5 | const proto = term.protocol; | 6 | const proto = term.protocol; |
| 6 | const atlas = @import("atlas.zig"); | 7 | const atlas = @import("atlas.zig"); |
| 7 | const theme_mod = @import("theme.zig"); | 8 | const theme_mod = @import("native_core").theme; |
| 8 | pub const Instance = extern struct { | 9 | pub const Instance = extern struct { |
| 9 | x: f32, | 10 | x: f32, |
| 10 | y: f32, | 11 | y: f32, |
| @@ -33,7 +34,7 @@ pub const Lists = struct { | |||
| 33 | try out.appendSlice(a, s.foregrounds.items); | 34 | try out.appendSlice(a, s.foregrounds.items); |
| 34 | } | 35 | } |
| 35 | }; | 36 | }; |
| 36 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, theme: *const theme_mod.Theme = &theme_mod.legacy, fg: ?u32 = null, bg: ?u32 = null }; | 37 | pub const Ctx = struct { cell_w: u16, cell_h: u16, ascent: u16, x0: f32 = 0, y0: f32 = 0, atlas_w: f32, atlas_h: f32, glyphs: Glyphs, blink_visible: bool = true, theme: *const theme_mod.Theme = &theme_mod.legacy, fg: ?u32 = null, bg: ?u32 = null, selection: ?client.selection.Span = null }; |
| 37 | pub fn rgbaOf(c: u32, d: u32, appearance: *const theme_mod.Theme) u32 { | 38 | pub fn rgbaOf(c: u32, d: u32, appearance: *const theme_mod.Theme) u32 { |
| 38 | return switch (c >> 24) { | 39 | return switch (c >> 24) { |
| 39 | 1 => appearance.palette[@intCast(c & 0xff)], | 40 | 1 => appearance.palette[@intCast(c & 0xff)], |
| @@ -84,9 +85,10 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 84 | for (row.cells[0..n], 0..) |cell, x| { | 85 | for (row.cells[0..n], 0..) |cell, x| { |
| 85 | if (cell.wide == .spacer_tail) continue; | 86 | if (cell.wide == .spacer_tail) continue; |
| 86 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 87 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| 88 | const selected = if (ctx.selection) |s| x <= s.to and x + span_cols - 1 >= s.from else false; | ||
| 87 | const inv = cell.style.flags & (1 << 4) != 0; | 89 | const inv = cell.style.flags & (1 << 4) != 0; |
| 88 | const bg = if (inv) rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme) else rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme); | 90 | const bg = if (selected) ctx.theme.chrome_focus_bg else if (inv) rgbaOf(cell.style.fg, ctx.fg orelse ctx.theme.terminal_fg, ctx.theme) else rgbaOf(cell.style.bg, ctx.bg orelse ctx.theme.terminal_bg, ctx.theme); |
| 89 | if (inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); | 91 | if (selected or inv or cell.style.bg != proto.color_none) try out.backgrounds.append(a, solid(ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw, top, cw * @as(f32, @floatFromInt(span_cols)), ch, bg)); |
| 90 | } | 92 | } |
| 91 | for (row.cells[0..n], 0..) |cell, x| { | 93 | for (row.cells[0..n], 0..) |cell, x| { |
| 92 | if (cell.wide == .spacer_tail) continue; | 94 | if (cell.wide == .spacer_tail) continue; |
| @@ -100,6 +102,8 @@ pub fn rowInstances(out: *Lists, a: std.mem.Allocator, row: *const grid.Row, col | |||
| 100 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; | 102 | const left = ctx.x0 + @as(f32, @floatFromInt(col_off + x)) * cw; |
| 101 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); | 103 | const span_cols: usize = @min(if (cell.wide == .wide) @as(usize, 2) else 1, n - x); |
| 102 | const span = cw * @as(f32, @floatFromInt(span_cols)); | 104 | const span = cw * @as(f32, @floatFromInt(span_cols)); |
| 105 | const selected = if (ctx.selection) |s| x <= s.to and x + span_cols - 1 >= s.from else false; | ||
| 106 | if (selected) fg = ctx.theme.chrome_focus_fg; | ||
| 103 | if (cell.text_len > 0) { | 107 | if (cell.text_len > 0) { |
| 104 | const run = try ctx.glyphs.resolve(ctx.glyphs.ctx, row.textOf(cell), variantOf(flags)); | 108 | const run = try ctx.glyphs.resolve(ctx.glyphs.ctx, row.textOf(cell), variantOf(flags)); |
| 105 | var pen_x: i32 = 0; | 109 | var pen_x: i32 = 0; |
| @@ -251,6 +255,24 @@ test "glyphs and a clipped wide edge stay inside the authoritative span" { | |||
| 251 | try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); | 255 | try std.testing.expect(glyph.u1 < @as(f32, 22) / ctx.atlas_w); |
| 252 | } | 256 | } |
| 253 | 257 | ||
| 258 | test "selection highlight covers wide cells without changing grid styles" { | ||
| 259 | var cells = [_]grid.Cell{ | ||
| 260 | .{ .wide = .wide, .text_len = 1, .style = .{ .fg = proto.colorRgb(1, 2, 3), .bg = proto.colorRgb(4, 5, 6) } }, | ||
| 261 | .{ .wide = .spacer_tail }, | ||
| 262 | }; | ||
| 263 | var row: grid.Row = .{ .cells = &cells, .text = .{ .items = @constCast("界"), .capacity = 3 } }; | ||
| 264 | var lists: Lists = .{}; | ||
| 265 | defer lists.deinit(std.testing.allocator); | ||
| 266 | var ctx = testCtx(true); | ||
| 267 | ctx.selection = .{ .from = 1, .to = 1 }; | ||
| 268 | _ = try rowInstances(&lists, std.testing.allocator, &row, 2, 0, 0, ctx); | ||
| 269 | // Selecting the continuation still paints the owning wide cell as one | ||
| 270 | // complete two-column rectangle, while the source style remains intact. | ||
| 271 | try std.testing.expectEqual(@as(f32, 16), lists.backgrounds.items[0].w); | ||
| 272 | try std.testing.expectEqual(theme_mod.legacy.chrome_focus_bg, lists.backgrounds.items[0].rgba); | ||
| 273 | try std.testing.expectEqual(proto.colorRgb(1, 2, 3), cells[0].style.fg); | ||
| 274 | } | ||
| 275 | |||
| 254 | /// Clip only the newly emitted range, adjusting glyph UVs proportionally. | 276 | /// Clip only the newly emitted range, adjusting glyph UVs proportionally. |
| 255 | /// Removed instances are compacted without changing background/foreground order. | 277 | /// Removed instances are compacted without changing background/foreground order. |
| 256 | pub fn clip(list: *std.ArrayListUnmanaged(Instance), start: usize, x: f32, y: f32, w: f32, h: f32) void { | 278 | pub fn clip(list: *std.ArrayListUnmanaged(Instance), start: usize, x: f32, y: f32, w: f32, h: f32) void { |
src/gui/runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -11,7 +11,10 @@ pub const Live = struct { | |||
| 11 | pump: *Pump, | 11 | pump: *Pump, |
| 12 | snapshot: *term.grid.Grid, | 12 | snapshot: *term.grid.Grid, |
| 13 | snapshot_seq: u64 = 0, | 13 | snapshot_seq: u64 = 0, |
| 14 | snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 15 | snapshot_follow: ?client.session_pump.FollowPosition = null, | ||
| 14 | painted_seq: u64 = 0, | 16 | painted_seq: u64 = 0, |
| 17 | view_origin: u32 = 0, | ||
| 15 | status: client.session_pump.State = .{}, | 18 | status: client.session_pump.State = .{}, |
| 16 | size: term.protocol.Size, | 19 | size: term.protocol.Size, |
| 17 | notify: Notify, | 20 | notify: Notify, |
| @@ -45,9 +48,12 @@ pub const Live = struct { | |||
| 45 | defer self.pump.mu.unlock(); | 48 | defer self.pump.mu.unlock(); |
| 46 | if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0; | 49 | if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0; |
| 47 | self.preserve_snapshot = false; | 50 | self.preserve_snapshot = false; |
| 48 | const src = self.pump.grid; | 51 | const src = self.pump.viewGridLocked(); |
| 49 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); | 52 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); |
| 50 | self.snapshot_seq = self.pump.replica.last_seq; | 53 | self.snapshot_seq = self.pump.replica.last_seq; |
| 54 | self.snapshot_version = self.pump.selectionVersionLocked(); | ||
| 55 | self.snapshot_follow = self.pump.followPositionLocked(); | ||
| 56 | self.view_origin = self.pump.viewOriginLocked(); | ||
| 51 | return self.pump.last_apply_us; | 57 | return self.pump.last_apply_us; |
| 52 | } | 58 | } |
| 53 | }; | 59 | }; |
| @@ -129,6 +135,8 @@ pub const Runtime = struct { | |||
| 129 | if (keep_snapshot) { | 135 | if (keep_snapshot) { |
| 130 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); | 136 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); |
| 131 | live.snapshot_seq = old.snapshot_seq; | 137 | live.snapshot_seq = old.snapshot_seq; |
| 138 | live.snapshot_version = old.snapshot_version; | ||
| 139 | live.view_origin = old.view_origin; | ||
| 132 | live.preserve_snapshot = true; | 140 | live.preserve_snapshot = true; |
| 133 | } | 141 | } |
| 134 | for (&self.lives) |*slot| if (slot.* == old) { | 142 | for (&self.lives) |*slot| if (slot.* == old) { |
| @@ -170,6 +178,18 @@ pub const Runtime = struct { | |||
| 170 | else => {}, | 178 | else => {}, |
| 171 | } | 179 | } |
| 172 | } | 180 | } |
| 181 | |||
| 182 | pub fn wheel(self: *Runtime, key: model.Attachment, event: client.session_pump.Wheel) !void { | ||
| 183 | const live = self.get(key.pane) orelse return; | ||
| 184 | if (!self.accepts(key) or live.status.phase != .attached) return; | ||
| 185 | try live.pump.say(.{ .wheel = event }); | ||
| 186 | } | ||
| 187 | |||
| 188 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, gesture: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { | ||
| 189 | const live = self.get(key.pane) orelse return error.MissingPane; | ||
| 190 | if (!self.accepts(key)) return error.StaleAttachment; | ||
| 191 | try live.pump.say(.{ .selection = .{ .id = id, .gesture = gesture, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } }); | ||
| 192 | } | ||
| 173 | pub fn poll(self: *Runtime, now: i64) bool { | 193 | pub fn poll(self: *Runtime, now: i64) bool { |
| 174 | var changed = false; | 194 | var changed = false; |
| 175 | for (self.lives) |p| if (p) |live| { | 195 | for (self.lives) |p| if (p) |live| { |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -266,6 +266,12 @@ const ClientSlot = struct { | |||
| 266 | /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and | 266 | /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and |
| 267 | /// per-connection: a redial is a new slot and must offer again. | 267 | /// per-connection: a redial is a new slot and must offer again. |
| 268 | agent_offer: bool = false, | 268 | agent_offer: bool = false, |
| 269 | selection: ?struct { id: u32, tracked: *Engine.TrackedSelection } = null, | ||
| 270 | |||
| 271 | fn clearSelection(self: *ClientSlot) void { | ||
| 272 | if (self.selection) |*selection| selection.tracked.deinit(); | ||
| 273 | self.selection = null; | ||
| 274 | } | ||
| 269 | }; | 275 | }; |
| 270 | 276 | ||
| 271 | /// One client's outstanding await: the request as asked, plus the two pieces | 277 | /// One client's outstanding await: the request as asked, plus the two pieces |
| @@ -1083,6 +1089,7 @@ pub const Server = struct { | |||
| 1083 | fn teardownClient(self: *Server, i: usize, close_sink: bool) void { | 1089 | fn teardownClient(self: *Server, i: usize, close_sink: bool) void { |
| 1084 | self.agents.closeOfClient(self, i); | 1090 | self.agents.closeOfClient(self, i); |
| 1085 | if (self.clients[i]) |*slot| { | 1091 | if (self.clients[i]) |*slot| { |
| 1092 | slot.clearSelection(); | ||
| 1086 | slot.pending.deinit(self.alloc); | 1093 | slot.pending.deinit(self.alloc); |
| 1087 | slot.inbound.deinit(self.alloc); | 1094 | slot.inbound.deinit(self.alloc); |
| 1088 | if (close_sink) slot.sink.close(); | 1095 | if (close_sink) slot.sink.close(); |
| @@ -1118,26 +1125,22 @@ pub const Server = struct { | |||
| 1118 | return self.clients[i] != null; | 1125 | return self.clients[i] != null; |
| 1119 | } | 1126 | } |
| 1120 | 1127 | ||
| 1121 | /// The lane is TOTAL: a decodable request leaves with exactly one answer, | 1128 | pub fn queueSelectionReply(self: *Server, i: usize, reply: proto.SelectionReply) void { |
| 1122 | /// so the fallback reserves capacity first. | 1129 | var bytes: std.ArrayList(u8) = .empty; |
| 1123 | pub fn queueSelectionReply( | 1130 | defer bytes.deinit(self.alloc); |
| 1124 | self: *Server, | 1131 | // Reserve the refusal before formatting a potentially large reply. |
| 1125 | i: usize, | 1132 | bytes.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch { |
| 1126 | id: u32, | 1133 | self.dropClient(i); |
| 1127 | status: proto.SelectionStatus, | ||
| 1128 | history_rows: u32, | ||
| 1129 | value: []const u8, | ||
| 1130 | ) void { | ||
| 1131 | var payload: std.ArrayList(u8) = .empty; | ||
| 1132 | defer payload.deinit(self.alloc); | ||
| 1133 | payload.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch return; | ||
| 1134 | proto.encodeSelectionReply(&payload, self.alloc, id, status, history_rows, value) catch { | ||
| 1135 | payload.clearRetainingCapacity(); | ||
| 1136 | proto.encodeSelectionReply(&payload, self.alloc, id, .unavailable, 0, "") catch return; | ||
| 1137 | _ = self.queueFrame(i, .selection_reply, payload.items); | ||
| 1138 | return; | 1134 | return; |
| 1139 | }; | 1135 | }; |
| 1140 | _ = self.queueFrame(i, .selection_reply, payload.items); | 1136 | proto.encodeSelectionReply(&bytes, self.alloc, reply) catch { |
| 1137 | bytes.clearRetainingCapacity(); | ||
| 1138 | var refusal = reply; | ||
| 1139 | refusal.status = .unavailable; | ||
| 1140 | refusal.text = &.{}; | ||
| 1141 | proto.encodeSelectionReply(&bytes, self.alloc, refusal) catch unreachable; | ||
| 1142 | }; | ||
| 1143 | _ = self.queueFrame(i, .selection_reply, bytes.items); | ||
| 1141 | } | 1144 | } |
| 1142 | 1145 | ||
| 1143 | /// Wait up to `budget_ms` for clients to accept what they are owed. Only | 1146 | /// Wait up to `budget_ms` for clients to accept what they are owed. Only |
| @@ -1651,7 +1654,7 @@ pub const Server = struct { | |||
| 1651 | .resize => self.onResize(i, frame), | 1654 | .resize => self.onResize(i, frame), |
| 1652 | .input => self.onInput(i, frame), | 1655 | .input => self.onInput(i, frame), |
| 1653 | .fetch_scrollback => self.onFetchScrollback(i, frame), | 1656 | .fetch_scrollback => self.onFetchScrollback(i, frame), |
| 1654 | .selection_req => self.onSelectionReq(i, frame), | 1657 | .selection_req => self.onSelectionReq(i, frame.payload), |
| 1655 | .detach => self.dropClient(i), | 1658 | .detach => self.dropClient(i), |
| 1656 | .status_req => self.onStatusReq(i, frame), | 1659 | .status_req => self.onStatusReq(i, frame), |
| 1657 | .await_req => self.onAwaitReq(i, frame), | 1660 | .await_req => self.onAwaitReq(i, frame), |
| @@ -1814,6 +1817,7 @@ pub const Server = struct { | |||
| 1814 | return; | 1817 | return; |
| 1815 | }; | 1818 | }; |
| 1816 | if (self.clients[i]) |*c| { | 1819 | if (self.clients[i]) |*c| { |
| 1820 | c.clearSelection(); | ||
| 1817 | // Seq series are per-session, so an await's `since_seq` from the | 1821 | // Seq series are per-session, so an await's `since_seq` from the |
| 1818 | // old one would answer instantly or never, arbitrarily. Only on a | 1822 | // old one would answer instantly or never, arbitrarily. Only on a |
| 1819 | // real change: a reconnect re-resolving the same name keeps its await. | 1823 | // real change: a reconnect re-resolving the same name keeps its await. |
| @@ -1876,47 +1880,79 @@ pub const Server = struct { | |||
| 1876 | @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count)); | 1880 | @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count)); |
| 1877 | @memcpy(payload[6..], got.bytes); | 1881 | @memcpy(payload[6..], got.bytes); |
| 1878 | _ = self.queueFrame(i, .scrollback_chunk, payload); | 1882 | _ = self.queueFrame(i, .scrollback_chunk, payload); |
| 1883 | self.sendSelectionState(si, i); | ||
| 1879 | } | 1884 | } |
| 1880 | 1885 | ||
| 1881 | fn onSelectionReq(self: *Server, i: usize, frame: proto.Frame) void { | 1886 | fn onSelectionReq(self: *Server, i: usize, payload: []const u8) void { |
| 1882 | // Selection is a client-local observation, not input: it claims no | 1887 | const req = proto.decodeSelectionReq(payload) catch return; |
| 1883 | // size and repaints nobody. Decoded BEFORE the session lookup, because | ||
| 1884 | // a malformed payload carries no id to correlate and only silence is | ||
| 1885 | // honest — while a well-formed one from a session-less slot deserves | ||
| 1886 | // its answer rather than the client's whole timeout. | ||
| 1887 | const req = proto.decodeSelectionReq(frame.payload) catch return; | ||
| 1888 | const si = self.clients[i].?.session orelse { | 1888 | const si = self.clients[i].?.session orelse { |
| 1889 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, ""); | 1889 | self.queueSelectionReply(i, .{ .id = req.id, .gesture = req.gesture, .history_rows = 0 }); |
| 1890 | return; | 1890 | return; |
| 1891 | }; | 1891 | }; |
| 1892 | var result = self.ses(si).eng.extractSelection( | 1892 | const s = self.ses(si); |
| 1893 | self.alloc, | 1893 | if (req.action == .clear) { |
| 1894 | req.anchor.row, | 1894 | if (self.clients[i].?.selection) |selection| { |
| 1895 | req.anchor.col, | 1895 | if (selection.id == req.gesture) self.clients[i].?.clearSelection(); |
| 1896 | req.active.row, | 1896 | } |
| 1897 | req.active.col, | ||
| 1898 | proto.selection_text_max, | ||
| 1899 | ) catch { | ||
| 1900 | // No extraction happened, so there is no screen whose | ||
| 1901 | // history this could honestly quote. Zero is the | ||
| 1902 | // watermark that claims nothing, and a non-ok status is | ||
| 1903 | // never compared against one anyway. | ||
| 1904 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, ""); | ||
| 1905 | return; | 1897 | return; |
| 1906 | }; | 1898 | } |
| 1907 | defer result.deinit(self.alloc); | 1899 | var state = self.selectionState(si, i); |
| 1908 | // Status and text are one invariant, not two independent | 1900 | state.id = req.id; |
| 1909 | // fields. In particular, an impossible `.ok` without the | 1901 | state.gesture = req.gesture; |
| 1910 | // owned text must not become a successful empty selection. | 1902 | if (req.action == .start) { |
| 1911 | const base = result.history_rows; | 1903 | const tracked = if (req.gesture != 0 and req.epoch == s.epoch and req.source == s.eng.selectionSource()) |
| 1912 | switch (result.status) { | 1904 | s.eng.trackSelection(req.anchor, req.active) catch null |
| 1913 | .ok => if (result.text) |value| | ||
| 1914 | self.queueSelectionReply(i, req.id, selectionReplyStatus(.ok), base, value) | ||
| 1915 | else | 1905 | else |
| 1916 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), base, ""), | 1906 | null; |
| 1917 | .invalid => self.queueSelectionReply(i, req.id, selectionReplyStatus(.invalid), base, ""), | 1907 | if (tracked) |selection| { |
| 1918 | .too_large => self.queueSelectionReply(i, req.id, selectionReplyStatus(.too_large), base, ""), | 1908 | self.clients[i].?.clearSelection(); |
| 1909 | self.clients[i].?.selection = .{ .id = req.gesture, .tracked = selection }; | ||
| 1910 | state = self.selectionState(si, i); | ||
| 1911 | state.id = req.id; | ||
| 1912 | } else { | ||
| 1913 | state.status = .unavailable; | ||
| 1914 | self.queueSelectionReply(i, state); | ||
| 1915 | return; | ||
| 1916 | } | ||
| 1917 | } | ||
| 1918 | const result: ?Engine.SelectionExtract = if (req.action == .extract) | ||
| 1919 | s.eng.extractSelection(self.alloc, req.anchor.row, req.anchor.col, req.active.row, req.active.col, proto.selection_text_max) catch null | ||
| 1920 | else if (self.clients[i].?.selection) |*selection| | ||
| 1921 | if (selection.id == req.gesture and req.epoch == s.epoch) | ||
| 1922 | selection.tracked.extract(self.alloc, proto.selection_text_max) catch null | ||
| 1923 | else | ||
| 1924 | null | ||
| 1925 | else | ||
| 1926 | null; | ||
| 1927 | defer if (result) |value| value.deinit(self.alloc); | ||
| 1928 | if (result) |value| { | ||
| 1929 | state.status = selectionReplyStatus(value.status); | ||
| 1930 | state.text = value.text orelse &.{}; | ||
| 1931 | } else state.status = .unavailable; | ||
| 1932 | self.queueSelectionReply(i, state); | ||
| 1933 | } | ||
| 1934 | |||
| 1935 | fn selectionState(self: *Server, si: usize, i: usize) proto.SelectionReply { | ||
| 1936 | const s = self.ses(si); | ||
| 1937 | var state: proto.SelectionReply = .{ | ||
| 1938 | .seq = s.tracker.seq, | ||
| 1939 | .source = s.eng.selectionSource(), | ||
| 1940 | .history_rows = s.eng.historyRows(), | ||
| 1941 | }; | ||
| 1942 | if (self.clients[i].?.selection) |*selection| { | ||
| 1943 | state.gesture = selection.id; | ||
| 1944 | if (selection.tracked.points()) |points| { | ||
| 1945 | state.status = .ok; | ||
| 1946 | state.anchor = points.anchor; | ||
| 1947 | state.active = points.active; | ||
| 1948 | } else self.clients[i].?.clearSelection(); | ||
| 1919 | } | 1949 | } |
| 1950 | return state; | ||
| 1951 | } | ||
| 1952 | |||
| 1953 | fn sendSelectionState(self: *Server, si: usize, i: usize) void { | ||
| 1954 | if (!self.inSession(i, si)) return; | ||
| 1955 | self.queueSelectionReply(i, self.selectionState(si, i)); | ||
| 1920 | } | 1956 | } |
| 1921 | 1957 | ||
| 1922 | fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void { | 1958 | fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void { |
| @@ -2369,6 +2405,7 @@ pub const Server = struct { | |||
| 2369 | for (0..max_clients) |i| { | 2405 | for (0..max_clients) |i| { |
| 2370 | if (if (to) |only| i != only else !self.inSession(i, si)) continue; | 2406 | if (if (to) |only| i != only else !self.inSession(i, si)) continue; |
| 2371 | if (!self.queueFrame(i, t, payload)) continue; | 2407 | if (!self.queueFrame(i, t, payload)) continue; |
| 2408 | self.sendSelectionState(si, i); | ||
| 2372 | sent = true; | 2409 | sent = true; |
| 2373 | if (t == .delta) { | 2410 | if (t == .delta) { |
| 2374 | self.stats.deltas += 1; | 2411 | self.stats.deltas += 1; |
| @@ -2408,7 +2445,7 @@ pub const Server = struct { | |||
| 2408 | } | 2445 | } |
| 2409 | const upd = s.tracker.update(self.alloc, s.eng) catch return; | 2446 | const upd = s.tracker.update(self.alloc, s.eng) catch return; |
| 2410 | switch (upd) { | 2447 | switch (upd) { |
| 2411 | .none => {}, | 2448 | .none => for (0..max_clients) |i| self.sendSelectionState(si, i), |
| 2412 | .discontinuity => self.resyncSnapshot(si), | 2449 | .discontinuity => self.resyncSnapshot(si), |
| 2413 | .advanced => { | 2450 | .advanced => { |
| 2414 | // Losing the payload would strand the clients a seq behind | 2451 | // Losing the payload would strand the clients a seq behind |
| @@ -2662,6 +2699,9 @@ pub const Server = struct { | |||
| 2662 | /// unlike sendResync: every attached client was told the mode on attach | 2699 | /// unlike sendResync: every attached client was told the mode on attach |
| 2663 | /// and at each change; a resync that skips it breaks that, silently. | 2700 | /// and at each change; a resync that skips it breaks that, silently. |
| 2664 | pub fn resyncSnapshot(self: *Server, si: usize) void { | 2701 | pub fn resyncSnapshot(self: *Server, si: usize) void { |
| 2702 | for (0..max_clients) |i| { | ||
| 2703 | if (self.inSession(i, si)) self.clients[i].?.clearSelection(); | ||
| 2704 | } | ||
| 2665 | if (!self.rebuildTracker(si)) return; | 2705 | if (!self.rebuildTracker(si)) return; |
| 2666 | if (!self.hasClientsIn(si)) return; | 2706 | if (!self.hasClientsIn(si)) return; |
| 2667 | const payload = self.buildSnapshotPayload(si) catch return; | 2707 | const payload = self.buildSnapshotPayload(si) catch return; |
src/server/server_test_attach.zig
| Old | New | ||
|---|---|---|---|
| @@ -610,6 +610,7 @@ test "Server: scrollback fetch is per-client and independent" { | |||
| 610 | if (frame.type != .selection_reply) return; | 610 | if (frame.type != .selection_reply) return; |
| 611 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | 611 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); |
| 612 | const reply = try proto.decodeSelectionReply(frame.payload); | 612 | const reply = try proto.decodeSelectionReply(frame.payload); |
| 613 | if (reply.id == 0) return; | ||
| 613 | switch (self.n) { | 614 | switch (self.n) { |
| 614 | 0 => { | 615 | 0 => { |
| 615 | try std.testing.expectEqual(@as(u32, 77), reply.id); | 616 | try std.testing.expectEqual(@as(u32, 77), reply.id); |
| @@ -656,7 +657,7 @@ test "Server: scrollback fetch is per-client and independent" { | |||
| 656 | fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void { | 657 | fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void { |
| 657 | // A fifth reply to four requests would mean the malformed frame | 658 | // A fifth reply to four requests would mean the malformed frame |
| 658 | // generated one of its own. | 659 | // generated one of its own. |
| 659 | if (frame.type == .selection_reply) return error.LateSelectionReply; | 660 | if (frame.type == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id != 0) return error.LateSelectionReply; |
| 660 | } | 661 | } |
| 661 | }; | 662 | }; |
| 662 | var status_reply: ?proto.StatusReply = null; | 663 | var status_reply: ?proto.StatusReply = null; |
src/server/server_test_clipboard.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const awaitFrame = h.awaitFrame; | |||
| 12 | const awaitGridText = h.awaitGridText; | 12 | const awaitGridText = h.awaitGridText; |
| 13 | const connectedPair = h.connectedPair; | 13 | const connectedPair = h.connectedPair; |
| 14 | const writeDyingGapShell = h.writeDyingGapShell; | 14 | const writeDyingGapShell = h.writeDyingGapShell; |
| 15 | const SelectionAction = @FieldType(proto.SelectionReq, "action"); | ||
| 15 | 16 | ||
| 16 | /// Returns the parts of the reply that outlive the frame's payload — which | 17 | /// Returns the parts of the reply that outlive the frame's payload — which |
| 17 | /// is the whole reason this is not `awaitFrame` spelled at each call site: | 18 | /// is the whole reason this is not `awaitFrame` spelled at each call site: |
| @@ -21,10 +22,223 @@ fn awaitSelectionReply( | |||
| 21 | srv: *Server, | 22 | srv: *Server, |
| 22 | peer: std.posix.fd_t, | 23 | peer: std.posix.fd_t, |
| 23 | ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { | 24 | ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { |
| 24 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; | 25 | // Every grid update also carries an id-zero source/position state. A |
| 25 | defer frame.deinit(alloc); | 26 | // direct extraction is correlated by its nonzero id, as ClientCore is. |
| 26 | const reply = try proto.decodeSelectionReply(frame.payload); | 27 | for (0..16) |_| { |
| 27 | return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len }; | 28 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; |
| 29 | defer frame.deinit(alloc); | ||
| 30 | const reply = try proto.decodeSelectionReply(frame.payload); | ||
| 31 | if (reply.id == 0) continue; | ||
| 32 | return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len }; | ||
| 33 | } | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | |||
| 37 | const TrackedReply = struct { | ||
| 38 | id: u32, | ||
| 39 | gesture: u32, | ||
| 40 | seq: u64, | ||
| 41 | source: u64, | ||
| 42 | status: proto.SelectionStatus, | ||
| 43 | anchor: proto.SelectionPoint, | ||
| 44 | active: proto.SelectionPoint, | ||
| 45 | text: [128]u8 = undefined, | ||
| 46 | text_len: usize = 0, | ||
| 47 | |||
| 48 | fn textValue(self: *const TrackedReply) []const u8 { | ||
| 49 | return self.text[0..self.text_len]; | ||
| 50 | } | ||
| 51 | }; | ||
| 52 | |||
| 53 | /// Position updates have id zero; a copy/start reply carries its request id. | ||
| 54 | /// Skip the other client-local updates so tests never depend on send order. | ||
| 55 | fn awaitTracked( | ||
| 56 | alloc: std.mem.Allocator, | ||
| 57 | srv: *Server, | ||
| 58 | peer: std.posix.fd_t, | ||
| 59 | gesture: u32, | ||
| 60 | id: u32, | ||
| 61 | ) !?TrackedReply { | ||
| 62 | for (0..16) |_| { | ||
| 63 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; | ||
| 64 | defer frame.deinit(alloc); | ||
| 65 | const reply = try proto.decodeSelectionReply(frame.payload); | ||
| 66 | if (reply.gesture != gesture or reply.id != id) continue; | ||
| 67 | if (reply.text.len > 128) return error.TrackedTextTooLong; | ||
| 68 | var result: TrackedReply = .{ | ||
| 69 | .id = reply.id, | ||
| 70 | .gesture = reply.gesture, | ||
| 71 | .seq = reply.seq, | ||
| 72 | .source = reply.source, | ||
| 73 | .status = reply.status, | ||
| 74 | .anchor = reply.anchor, | ||
| 75 | .active = reply.active, | ||
| 76 | }; | ||
| 77 | @memcpy(result.text[0..reply.text.len], reply.text); | ||
| 78 | result.text_len = reply.text.len; | ||
| 79 | return result; | ||
| 80 | } | ||
| 81 | return null; | ||
| 82 | } | ||
| 83 | |||
| 84 | fn writeTracked( | ||
| 85 | peer: std.posix.fd_t, | ||
| 86 | action: SelectionAction, | ||
| 87 | id: u32, | ||
| 88 | gesture: u32, | ||
| 89 | epoch: u64, | ||
| 90 | source: u64, | ||
| 91 | anchor: proto.SelectionPoint, | ||
| 92 | active: proto.SelectionPoint, | ||
| 93 | ) !void { | ||
| 94 | const bytes = proto.encodeSelectionReq(.{ | ||
| 95 | .action = action, | ||
| 96 | .id = id, | ||
| 97 | .gesture = gesture, | ||
| 98 | .epoch = epoch, | ||
| 99 | .source = source, | ||
| 100 | .anchor = anchor, | ||
| 101 | .active = active, | ||
| 102 | }); | ||
| 103 | try proto.writeFrame(peer, .selection_req, &bytes); | ||
| 104 | } | ||
| 105 | |||
| 106 | fn attachTracked(alloc: std.mem.Allocator, td: *h.TestDaemon) !std.net.Stream { | ||
| 107 | const c = try dial.dialAttach(td.sock_path, 16, 9); | ||
| 108 | errdefer c.close(); | ||
| 109 | (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoTrackedSnapshot).deinit(alloc); | ||
| 110 | const state_frame = (try awaitFrame(alloc, &td.srv, c.handle, .selection_reply, 400)) orelse return error.NoTrackedSource; | ||
| 111 | defer state_frame.deinit(alloc); | ||
| 112 | const state = try proto.decodeSelectionReply(state_frame.payload); | ||
| 113 | if (state.id != 0 or state.gesture != 0) return error.BadTrackedSource; | ||
| 114 | return c; | ||
| 115 | } | ||
| 116 | |||
| 117 | test "Server: tracked selection rejects a stale exact source even when the grid did not change" { | ||
| 118 | const alloc = std.testing.allocator; | ||
| 119 | var td = try h.TestDaemon.init(alloc, "follow-stale", .{ .shell = "/bin/cat" }); | ||
| 120 | defer td.deinit(); | ||
| 121 | const c = try attachTracked(alloc, &td); | ||
| 122 | defer c.close(); | ||
| 123 | |||
| 124 | const session = td.srv.sessions.table[0].?; | ||
| 125 | const source = session.eng.selectionSource(); | ||
| 126 | // Scroll a blank screen: row identities move but the rendered grid is | ||
| 127 | // unchanged. The source guard must still reject a delayed coordinate. | ||
| 128 | session.eng.feed("\x1b[1S"); | ||
| 129 | td.srv.sendUpdate(0); | ||
| 130 | try writeTracked(c.handle, .start, 1, 41, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 131 | const reply = (try awaitTracked(alloc, &td.srv, c.handle, 41, 1)) orelse return error.NoStaleTrackedReply; | ||
| 132 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status); | ||
| 133 | try std.testing.expectEqual(@as(usize, 0), reply.text_len); | ||
| 134 | } | ||
| 135 | |||
| 136 | test "Server: a stale start does not retire the client's existing tracked gesture" { | ||
| 137 | const alloc = std.testing.allocator; | ||
| 138 | var td = try h.TestDaemon.init(alloc, "tracked-stale-replace", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 139 | defer td.deinit(); | ||
| 140 | const c = try attachTracked(alloc, &td); | ||
| 141 | defer c.close(); | ||
| 142 | const session = td.srv.sessions.table[0].?; | ||
| 143 | |||
| 144 | session.eng.feed("KEEP"); | ||
| 145 | td.srv.sendUpdate(0); | ||
| 146 | const source = session.eng.selectionSource(); | ||
| 147 | try writeTracked(c.handle, .start, 1, 71, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 }); | ||
| 148 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 71, 1)) orelse return error.NoInitialTrackedStart; | ||
| 149 | |||
| 150 | // This changes the coordinate source after gesture 71 is owned. A late | ||
| 151 | // gesture 72 must be refused without clearing the still-live pins. | ||
| 152 | session.eng.feed("\x1b[1S"); | ||
| 153 | td.srv.sendUpdate(0); | ||
| 154 | try writeTracked(c.handle, .start, 2, 72, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 155 | const stale = (try awaitTracked(alloc, &td.srv, c.handle, 72, 2)) orelse return error.NoStaleReplacementReply; | ||
| 156 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, stale.status); | ||
| 157 | |||
| 158 | try writeTracked(c.handle, .copy, 3, 71, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 159 | const kept = (try awaitTracked(alloc, &td.srv, c.handle, 71, 3)) orelse return error.NoKeptTrackedCopy; | ||
| 160 | try std.testing.expectEqual(proto.SelectionStatus.ok, kept.status); | ||
| 161 | try std.testing.expectEqualStrings("KEEP", kept.textValue()); | ||
| 162 | } | ||
| 163 | |||
| 164 | test "Server: tracked selection tracks duplicate occurrences per client and clear ids cannot cross" { | ||
| 165 | const alloc = std.testing.allocator; | ||
| 166 | var td = try h.TestDaemon.init(alloc, "follow-clients", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 167 | defer td.deinit(); | ||
| 168 | const a = try attachTracked(alloc, &td); | ||
| 169 | defer a.close(); | ||
| 170 | const b = try attachTracked(alloc, &td); | ||
| 171 | defer b.close(); | ||
| 172 | const session = td.srv.sessions.table[0].?; | ||
| 173 | |||
| 174 | session.eng.feed("DUPLICATE\r\nBEFORE\r\nDUPLICATE\r\nAFTER"); | ||
| 175 | td.srv.sendUpdate(0); | ||
| 176 | const epoch = session.epoch; | ||
| 177 | const source = session.eng.selectionSource(); | ||
| 178 | try writeTracked(a.handle, .start, 1, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 }); | ||
| 179 | try writeTracked(b.handle, .start, 1, 202, epoch, source, .{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 }); | ||
| 180 | const started_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 1)) orelse return error.NoTrackedA; | ||
| 181 | const started_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 1)) orelse return error.NoTrackedB; | ||
| 182 | try std.testing.expectEqualStrings("DUPLICATE", started_a.textValue()); | ||
| 183 | try std.testing.expectEqualStrings("DUPLICATE", started_b.textValue()); | ||
| 184 | try std.testing.expect(started_a.anchor.row != started_b.anchor.row); | ||
| 185 | |||
| 186 | // Start at the bottom and append enough to shift both matching strings; | ||
| 187 | // no text comparison can identify which one each client intended. | ||
| 188 | session.eng.feed("\r\nTAIL-1\r\nTAIL-2\r\nTAIL-3\r\nTAIL-4\r\nTAIL-5\r\nTAIL-6"); | ||
| 189 | td.srv.sendUpdate(0); | ||
| 190 | const moved_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 0)) orelse return error.NoMovedTrackedA; | ||
| 191 | const moved_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 0)) orelse return error.NoMovedTrackedB; | ||
| 192 | try std.testing.expect(moved_a.anchor.row != moved_b.anchor.row); | ||
| 193 | |||
| 194 | // A foreign clear cannot destroy A's pins. Its following copy must still | ||
| 195 | // name A's original duplicate, not B's or a current coordinate. | ||
| 196 | try writeTracked(a.handle, .clear, 0, 999, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 197 | try writeTracked(a.handle, .copy, 2, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 198 | const copied_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 2)) orelse return error.NoTrackedCopy; | ||
| 199 | try std.testing.expectEqual(proto.SelectionStatus.ok, copied_a.status); | ||
| 200 | try std.testing.expectEqualStrings("DUPLICATE", copied_a.textValue()); | ||
| 201 | } | ||
| 202 | |||
| 203 | test "Server: tracked selection is retired by resync, resize and detach" { | ||
| 204 | const alloc = std.testing.allocator; | ||
| 205 | var td = try h.TestDaemon.init(alloc, "follow-retire", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 206 | defer td.deinit(); | ||
| 207 | var c = try attachTracked(alloc, &td); | ||
| 208 | var c_open = true; | ||
| 209 | defer if (c_open) c.close(); | ||
| 210 | const session = td.srv.sessions.table[0].?; | ||
| 211 | session.eng.feed("tracked"); | ||
| 212 | td.srv.sendUpdate(0); | ||
| 213 | try writeTracked(c.handle, .start, 1, 77, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 }); | ||
| 214 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 77, 1)) orelse return error.NoTrackedFollow; | ||
| 215 | |||
| 216 | td.srv.resyncSnapshot(0); | ||
| 217 | const resynced = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResyncFollow; | ||
| 218 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, resynced.status); | ||
| 219 | |||
| 220 | // Re-arm before a real client resize; its snapshot and following state | ||
| 221 | // must likewise retire the pins rather than projecting them through reflow. | ||
| 222 | try writeTracked(c.handle, .start, 2, 78, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 }); | ||
| 223 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 78, 2)) orelse return error.NoResizeFollowStart; | ||
| 224 | const size = proto.encodeSize(17, 9); | ||
| 225 | try proto.writeFrame(c.handle, .resize, &size); | ||
| 226 | const resized = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResizeFollow; | ||
| 227 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, resized.status); | ||
| 228 | |||
| 229 | // Socket closure takes the sole teardown path. The testing allocator at | ||
| 230 | // TestDaemon.deinit then proves its tracked pins were unregistered. | ||
| 231 | c.close(); | ||
| 232 | c_open = false; | ||
| 233 | var detached = false; | ||
| 234 | for (0..80) |_| { | ||
| 235 | try td.srv.pumpOnce(5); | ||
| 236 | if (!td.srv.hasClientsIn(0)) { | ||
| 237 | detached = true; | ||
| 238 | break; | ||
| 239 | } | ||
| 240 | } | ||
| 241 | try std.testing.expect(detached); | ||
| 28 | } | 242 | } |
| 29 | 243 | ||
| 30 | test "Server: a session-less slot still answers a well-formed selection request" { | 244 | test "Server: a session-less slot still answers a well-formed selection request" { |
| @@ -79,7 +293,7 @@ test "Server: an unencodable selection result still answers with unavailable" { | |||
| 79 | }); | 293 | }); |
| 80 | const real = td.srv.alloc; | 294 | const real = td.srv.alloc; |
| 81 | td.srv.alloc = failing.allocator(); | 295 | td.srv.alloc = failing.allocator(); |
| 82 | td.srv.queueSelectionReply(0, 99, .ok, 7, text); | 296 | td.srv.queueSelectionReply(0, .{ .id = 99, .status = .ok, .history_rows = 7, .text = text }); |
| 83 | td.srv.alloc = real; | 297 | td.srv.alloc = real; |
| 84 | try std.testing.expect(failing.has_induced_failure); | 298 | try std.testing.expect(failing.has_induced_failure); |
| 85 | 299 | ||
src/server/server_test_deliver.zig
| Old | New | ||
|---|---|---|---|
| @@ -61,8 +61,8 @@ test "Server: a stalled client does not block delivery to others" { | |||
| 61 | try std.testing.expect(td.srv.clients[1] != null); | 61 | try std.testing.expect(td.srv.clients[1] != null); |
| 62 | try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len); | 62 | try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len); |
| 63 | 63 | ||
| 64 | // ...and what it received is whole parseable frames, one snapshot per | 64 | // ...and what it received is whole parseable snapshot/state pairs, one |
| 65 | // round, not a stream truncated or interleaved by A's backpressure. | 65 | // per round, not a stream truncated or interleaved by A's backpressure. |
| 66 | var frames: usize = 0; | 66 | var frames: usize = 0; |
| 67 | while (frames < rounds) : (frames += 1) { | 67 | while (frames < rounds) : (frames += 1) { |
| 68 | const f = (try proto.readFrame(alloc, b.peer)) orelse break; | 68 | const f = (try proto.readFrame(alloc, b.peer)) orelse break; |
| @@ -70,6 +70,12 @@ test "Server: a stalled client does not block delivery to others" { | |||
| 70 | try std.testing.expectEqual(proto.MsgType.snapshot, f.type); | 70 | try std.testing.expectEqual(proto.MsgType.snapshot, f.type); |
| 71 | const p = try proto.readSnapshotPrefix(f.payload); | 71 | const p = try proto.readSnapshotPrefix(f.payload); |
| 72 | try std.testing.expectEqual(@as(u16, 80), p.cols); | 72 | try std.testing.expectEqual(@as(u16, 80), p.cols); |
| 73 | const state = (try proto.readFrame(alloc, b.peer)) orelse return error.NoSnapshotSource; | ||
| 74 | defer state.deinit(alloc); | ||
| 75 | try std.testing.expectEqual(proto.MsgType.selection_reply, state.type); | ||
| 76 | const reply = try proto.decodeSelectionReply(state.payload); | ||
| 77 | try std.testing.expectEqual(@as(u32, 0), reply.id); | ||
| 78 | try std.testing.expectEqual(@as(u32, 0), reply.gesture); | ||
| 73 | } | 79 | } |
| 74 | try std.testing.expectEqual(rounds, frames); | 80 | try std.testing.expectEqual(rounds, frames); |
| 75 | } | 81 | } |
src/server/server_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -525,7 +525,12 @@ pub const ReplicaFeed = struct { | |||
| 525 | pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { | 525 | pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { |
| 526 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | 526 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); |
| 527 | for (self.w.reject) |bad| { | 527 | for (self.w.reject) |bad| { |
| 528 | if (frame.type == bad) return error.RejectedFrameArrived; | 528 | if (frame.type == bad) { |
| 529 | // Unsolicited position metadata accompanies every grid. Only | ||
| 530 | // a correlated copy reply would leak another client's read. | ||
| 531 | if (bad == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id == 0) continue; | ||
| 532 | return error.RejectedFrameArrived; | ||
| 533 | } | ||
| 529 | } | 534 | } |
| 530 | // applyFrame ignores everything that is not state, so the stream's | 535 | // applyFrame ignores everything that is not state, so the stream's |
| 531 | // replies and marks pass through untouched. | 536 | // replies and marks pass through untouched. |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,13 +23,14 @@ const client_os = @import("client_os"); | |||
| 23 | // `paint`, which a container-level `paint` would collide with. | 23 | // `paint`, which a container-level `paint` would collide with. |
| 24 | const paint_mod = @import("paint.zig"); | 24 | const paint_mod = @import("paint.zig"); |
| 25 | const askpass = @import("client").askpass; | 25 | const askpass = @import("client").askpass; |
| 26 | const select = @import("select.zig"); | 26 | const select = @import("client").selection; |
| 27 | // The command prefix byte. Owned by keymap because the dialler in client.zig | 27 | // The command prefix byte. Owned by keymap because the dialler in client.zig |
| 28 | // watches for it with no session and no terminal in the picture. | 28 | // watches for it with no session and no terminal in the picture. |
| 29 | const detach_key = @import("client").keymap.detach_key; | 29 | const detach_key = @import("client").keymap.detach_key; |
| 30 | 30 | ||
| 31 | /// What `select` answers, in the shape `paint` asks for. The two are siblings | 31 | /// What `select` answers, in the shape `paint` asks for. Selection policy lives |
| 32 | /// that may not import one another, so somebody above both joins them. | 32 | /// in `client.selection`; this adapter converts its absolute rows to the wall's |
| 33 | /// grid rows before painting. | ||
| 33 | /// | 34 | /// |
| 34 | /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter | 35 | /// It CONVERTS as well as adapts: `select` speaks absolute rows and a painter |
| 35 | /// speaks grid rows, so the history count of the frame being painted turns one | 36 | /// speaks grid rows, so the history count of the frame being painted turns one |
| @@ -4576,6 +4577,7 @@ fn replyBytes( | |||
| 4576 | std.mem.writeInt(u32, buf[0..4], id, .little); | 4577 | std.mem.writeInt(u32, buf[0..4], id, .little); |
| 4577 | buf[4] = @intFromEnum(status); | 4578 | buf[4] = @intFromEnum(status); |
| 4578 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); | 4579 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); |
| 4580 | @memset(buf[9..proto.selection_reply_prefix_len], 0); | ||
| 4579 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); | 4581 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); |
| 4580 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; | 4582 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; |
| 4581 | } | 4583 | } |
src/tui/paint.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,8 +18,8 @@ pub const sync_end = "\x1b[?25h\x1b[?2026l"; | |||
| 18 | /// Inclusive grid columns of one row, painted inverted. | 18 | /// Inclusive grid columns of one row, painted inverted. |
| 19 | pub const Span = struct { from: u16, to: u16 }; | 19 | pub const Span = struct { from: u16, to: u16 }; |
| 20 | 20 | ||
| 21 | /// A callback, not a shape: `select.zig` is a sibling child file of the same | 21 | /// A callback, not a shape: `client.selection` owns selection state, while |
| 22 | /// module root, and neither file imports the other. | 22 | /// this file adapts its spans into painted output. |
| 23 | pub const Highlight = struct { | 23 | pub const Highlight = struct { |
| 24 | ctx: ?*anyopaque = null, | 24 | ctx: ?*anyopaque = null, |
| 25 | span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, | 25 | span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, |
src/tui/select.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,350 +0,0 @@ | |||
| 1 | //! The drag behind text selection, as pure state: press, motion, release, and | ||
| 2 | //! the span of columns highlighted on one line of one session. | ||
| 3 | //! | ||
| 4 | //! No tty, no transport, no engine, no allocation beyond its own struct — a | ||
| 5 | //! driver resolves a mouse report into `Hit` and this owns what a SEQUENCE of | ||
| 6 | //! those means, which is what lets two drivers in different layers share one | ||
| 7 | //! meaning. Resolving a terminal row to a session line stays with the layout. | ||
| 8 | //! | ||
| 9 | //! Rows are ABSOLUTE, counted from the oldest row the daemon retains: a drag | ||
| 10 | //! held while the session scrolls must keep naming the lines it started over, | ||
| 11 | //! and a terminal row renames itself the moment output moves the window. | ||
| 12 | const std = @import("std"); | ||
| 13 | |||
| 14 | /// One place in one session, as a driver has resolved a report. | ||
| 15 | pub const Hit = struct { | ||
| 16 | tile: usize, | ||
| 17 | /// Absolute row: counted from the oldest row the daemon still retains. | ||
| 18 | row: u32, | ||
| 19 | /// Grid column, zero-based. Terminal column is the same number — | ||
| 20 | /// every painter emits from column 1 with no x-offset. | ||
| 21 | col: u16, | ||
| 22 | }; | ||
| 23 | |||
| 24 | /// A zero-based terminal cell, which is not a `Hit`: it is where the POINTER | ||
| 25 | /// is, not what is under it. The two part company when the session scrolls, and | ||
| 26 | /// telling a click from a drag is the pointer's question. | ||
| 27 | pub const Cell = struct { row: u16, col: u16 }; | ||
| 28 | |||
| 29 | /// Inclusive columns, the same convention `engine.extractSelection` and | ||
| 30 | /// ghostty's `Selection` use at the other end of the wire. | ||
| 31 | pub const Span = struct { from: u16, to: u16 }; | ||
| 32 | |||
| 33 | /// A completed selection, normalized: `from` is at or before `to` in | ||
| 34 | /// reading order, whichever way the hand moved. | ||
| 35 | pub const Range = struct { | ||
| 36 | from: Hit, | ||
| 37 | to: Hit, | ||
| 38 | |||
| 39 | /// On `Range` rather than `Drag` because two get compared: a drag that moved | ||
| 40 | /// repaints the rows whose span CHANGED. `cols` is the GRID's width, and the | ||
| 41 | /// clamp is not decoration — a grid narrower than its terminal has columns a | ||
| 42 | /// pointer can reach with no cell behind them. | ||
| 43 | pub fn span(self: Range, tile: usize, row: u32, cols: u16) ?Span { | ||
| 44 | if (self.from.tile != tile) return null; | ||
| 45 | if (row < self.from.row or row > self.to.row) return null; | ||
| 46 | const from = if (row == self.from.row) self.from.col else 0; | ||
| 47 | const to = @min(if (row == self.to.row) self.to.col else cols -| 1, cols -| 1); | ||
| 48 | // A selection whose whole width fell off the grid's right edge | ||
| 49 | // highlights nothing on this row, rather than one clamped cell at | ||
| 50 | // the edge that nobody pointed at. | ||
| 51 | if (from > to) return null; | ||
| 52 | return .{ .from = from, .to = to }; | ||
| 53 | } | ||
| 54 | }; | ||
| 55 | |||
| 56 | /// What a button coming up meant. | ||
| 57 | pub const Release = union(enum) { | ||
| 58 | /// Nothing was down, or the press landed on nothing selectable. | ||
| 59 | nothing, | ||
| 60 | /// Press and release in one cell. Not a selection — tmux's | ||
| 61 | /// `MouseDown1Pane`, which a driver answers by moving its own | ||
| 62 | /// selection to the thing clicked. | ||
| 63 | click: Hit, | ||
| 64 | /// A drag that ended. The highlight STANDS after this: it is what a | ||
| 65 | /// copy is taken from, and what the next press or `clear` drops. | ||
| 66 | selection: Range, | ||
| 67 | }; | ||
| 68 | |||
| 69 | /// One button's worth of drag. Four states, because a press is not yet a | ||
| 70 | /// selection and a release is not the end of one: `.down` may still turn out to | ||
| 71 | /// be a click, and `.held` is a finished selection still on screen. | ||
| 72 | pub const Drag = struct { | ||
| 73 | const Phase = enum { idle, down, dragging, held }; | ||
| 74 | |||
| 75 | phase: Phase = .idle, | ||
| 76 | /// The cell the press was on, which is what a later motion is | ||
| 77 | /// compared against to tell a drag from a tremor. | ||
| 78 | at: Cell = .{ .row = 0, .col = 0 }, | ||
| 79 | anchor: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 80 | active: Hit = .{ .tile = 0, .row = 0, .col = 0 }, | ||
| 81 | |||
| 82 | /// Whatever was held is dropped however the press turns out: a new | ||
| 83 | /// press is a new selection, and a press on nothing (a label bar) is | ||
| 84 | /// the user putting the old one away. | ||
| 85 | pub fn press(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 86 | const h = hit orelse { | ||
| 87 | self.* = .{}; | ||
| 88 | return; | ||
| 89 | }; | ||
| 90 | self.* = .{ .phase = .down, .at = cell, .anchor = h, .active = h }; | ||
| 91 | } | ||
| 92 | |||
| 93 | /// Cell, not pixel: `?1002h` reports a CELL change, and a hand trembling | ||
| 94 | /// inside one cell still points at one line. Once it IS a drag it stays one. | ||
| 95 | /// Confined to its starting tile, or it would ask the wrong session. | ||
| 96 | pub fn motion(self: *Drag, cell: Cell, hit: ?Hit) void { | ||
| 97 | switch (self.phase) { | ||
| 98 | .idle, .held => return, | ||
| 99 | .down => { | ||
| 100 | if (cell.row == self.at.row and cell.col == self.at.col) return; | ||
| 101 | self.phase = .dragging; | ||
| 102 | }, | ||
| 103 | .dragging => {}, | ||
| 104 | } | ||
| 105 | const h = hit orelse return; | ||
| 106 | if (h.tile != self.anchor.tile) return; | ||
| 107 | self.active = h; | ||
| 108 | } | ||
| 109 | |||
| 110 | /// The button came up. See `Release` for what the three answers mean. | ||
| 111 | pub fn release(self: *Drag) Release { | ||
| 112 | switch (self.phase) { | ||
| 113 | .idle, .held => return .nothing, | ||
| 114 | .down => { | ||
| 115 | const hit = self.anchor; | ||
| 116 | self.* = .{}; | ||
| 117 | return .{ .click = hit }; | ||
| 118 | }, | ||
| 119 | .dragging => { | ||
| 120 | self.phase = .held; | ||
| 121 | return .{ .selection = self.rangeLocked() }; | ||
| 122 | }, | ||
| 123 | } | ||
| 124 | } | ||
| 125 | |||
| 126 | /// Coordinates stopped meaning what they meant. | ||
| 127 | pub fn clear(self: *Drag) void { | ||
| 128 | self.* = .{}; | ||
| 129 | } | ||
| 130 | |||
| 131 | /// A press that has not moved yet counts; `range`, by contrast, | ||
| 132 | /// answers only about what is on screen. | ||
| 133 | pub fn on(self: *const Drag) ?usize { | ||
| 134 | return if (self.phase == .idle) null else self.anchor.tile; | ||
| 135 | } | ||
| 136 | |||
| 137 | /// The selection as an ordered pair, or null while there is none. | ||
| 138 | pub fn range(self: *const Drag) ?Range { | ||
| 139 | return switch (self.phase) { | ||
| 140 | .idle, .down => null, | ||
| 141 | .dragging, .held => self.rangeLocked(), | ||
| 142 | }; | ||
| 143 | } | ||
| 144 | |||
| 145 | fn rangeLocked(self: *const Drag) Range { | ||
| 146 | const a = self.anchor; | ||
| 147 | const b = self.active; | ||
| 148 | const forward = b.row > a.row or (b.row == a.row and b.col >= a.col); | ||
| 149 | return if (forward) .{ .from = a, .to = b } else .{ .from = b, .to = a }; | ||
| 150 | } | ||
| 151 | |||
| 152 | /// `Range.span` for the live selection, or null while there is none. | ||
| 153 | pub fn span(self: *const Drag, tile: usize, row: u32, cols: u16) ?Span { | ||
| 154 | const r = self.range() orelse return null; | ||
| 155 | return r.span(tile, row, cols); | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | test "select: a press alone is a click, and highlights nothing on its way" { | ||
| 160 | var d: Drag = .{}; | ||
| 161 | const hit: Hit = .{ .tile = 1, .row = 40, .col = 7 }; | ||
| 162 | d.press(.{ .row = 12, .col = 7 }, hit); | ||
| 163 | // Nothing is highlighted while the button is merely down: a press that | ||
| 164 | // turns out to be a click must never flicker an inversion on its way. | ||
| 165 | try std.testing.expect(d.span(1, 40, 80) == null); | ||
| 166 | try std.testing.expect(d.range() == null); | ||
| 167 | const r = d.release(); | ||
| 168 | try std.testing.expectEqual(@as(usize, 1), r.click.tile); | ||
| 169 | try std.testing.expectEqual(@as(u32, 40), r.click.row); | ||
| 170 | try std.testing.expectEqual(@as(u16, 7), r.click.col); | ||
| 171 | // ...and the click leaves nothing behind to paint. | ||
| 172 | try std.testing.expect(d.range() == null); | ||
| 173 | } | ||
| 174 | |||
| 175 | test "select: a drag names its tile from the press, before it is a selection" { | ||
| 176 | var d: Drag = .{}; | ||
| 177 | try std.testing.expect(d.on() == null); | ||
| 178 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 3, .row = 5, .col = 1 }); | ||
| 179 | // Pressed, not yet dragged: nothing to paint, and still a tile whose | ||
| 180 | // coordinates a caller may have to drop. | ||
| 181 | try std.testing.expect(d.range() == null); | ||
| 182 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 183 | d.motion(.{ .row = 4, .col = 1 }, .{ .tile = 3, .row = 7, .col = 1 }); | ||
| 184 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 185 | _ = d.release(); | ||
| 186 | // Still held after the button came up, which is what a highlight is. | ||
| 187 | try std.testing.expectEqual(@as(usize, 3), d.on().?); | ||
| 188 | d.clear(); | ||
| 189 | try std.testing.expect(d.on() == null); | ||
| 190 | } | ||
| 191 | |||
| 192 | test "select: a press on nothing selectable is nothing at all" { | ||
| 193 | var d: Drag = .{}; | ||
| 194 | d.press(.{ .row = 0, .col = 3 }, null); | ||
| 195 | d.motion(.{ .row = 4, .col = 9 }, .{ .tile = 0, .row = 5, .col = 9 }); | ||
| 196 | try std.testing.expect(d.range() == null); | ||
| 197 | try std.testing.expect(d.release() == .nothing); | ||
| 198 | } | ||
| 199 | |||
| 200 | test "select: either drag direction yields the same ordered pair" { | ||
| 201 | const top: Hit = .{ .tile = 0, .row = 100, .col = 4 }; | ||
| 202 | const bot: Hit = .{ .tile = 0, .row = 103, .col = 12 }; | ||
| 203 | |||
| 204 | var down: Drag = .{}; | ||
| 205 | down.press(.{ .row = 2, .col = 4 }, top); | ||
| 206 | down.motion(.{ .row = 5, .col = 12 }, bot); | ||
| 207 | const a = down.release().selection; | ||
| 208 | |||
| 209 | var up: Drag = .{}; | ||
| 210 | up.press(.{ .row = 5, .col = 12 }, bot); | ||
| 211 | up.motion(.{ .row = 2, .col = 4 }, top); | ||
| 212 | const b = up.release().selection; | ||
| 213 | |||
| 214 | try std.testing.expectEqual(a.from, b.from); | ||
| 215 | try std.testing.expectEqual(a.to, b.to); | ||
| 216 | try std.testing.expectEqual(@as(u32, 100), a.from.row); | ||
| 217 | try std.testing.expectEqual(@as(u16, 4), a.from.col); | ||
| 218 | try std.testing.expectEqual(@as(u32, 103), a.to.row); | ||
| 219 | try std.testing.expectEqual(@as(u16, 12), a.to.col); | ||
| 220 | } | ||
| 221 | |||
| 222 | test "select: a one-row drag reads left to right whichever way the hand moved" { | ||
| 223 | var d: Drag = .{}; | ||
| 224 | d.press(.{ .row = 3, .col = 9 }, .{ .tile = 0, .row = 50, .col = 9 }); | ||
| 225 | d.motion(.{ .row = 3, .col = 2 }, .{ .tile = 0, .row = 50, .col = 2 }); | ||
| 226 | const s = d.span(0, 50, 80).?; | ||
| 227 | try std.testing.expectEqual(@as(u16, 2), s.from); | ||
| 228 | try std.testing.expectEqual(@as(u16, 9), s.to); | ||
| 229 | // One row, so neither neighbour is in it. | ||
| 230 | try std.testing.expect(d.span(0, 49, 80) == null); | ||
| 231 | try std.testing.expect(d.span(0, 51, 80) == null); | ||
| 232 | } | ||
| 233 | |||
| 234 | test "select: the ends are clipped at the anchors, and the middle is the full width" { | ||
| 235 | var d: Drag = .{}; | ||
| 236 | d.press(.{ .row = 1, .col = 30 }, .{ .tile = 2, .row = 7, .col = 30 }); | ||
| 237 | d.motion(.{ .row = 4, .col = 6 }, .{ .tile = 2, .row = 10, .col = 6 }); | ||
| 238 | |||
| 239 | const first = d.span(2, 7, 80).?; | ||
| 240 | try std.testing.expectEqual(@as(u16, 30), first.from); | ||
| 241 | try std.testing.expectEqual(@as(u16, 79), first.to); | ||
| 242 | const middle = d.span(2, 8, 80).?; | ||
| 243 | try std.testing.expectEqual(@as(u16, 0), middle.from); | ||
| 244 | try std.testing.expectEqual(@as(u16, 79), middle.to); | ||
| 245 | const last = d.span(2, 10, 80).?; | ||
| 246 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 247 | try std.testing.expectEqual(@as(u16, 6), last.to); | ||
| 248 | // Outside the range on both sides. | ||
| 249 | try std.testing.expect(d.span(2, 6, 80) == null); | ||
| 250 | try std.testing.expect(d.span(2, 11, 80) == null); | ||
| 251 | } | ||
| 252 | |||
| 253 | test "select: the highlight is one tile's, and the drag cannot leave it" { | ||
| 254 | var d: Drag = .{}; | ||
| 255 | d.press(.{ .row = 2, .col = 1 }, .{ .tile = 0, .row = 5, .col = 1 }); | ||
| 256 | // A drag onto the neighbouring stripe: it IS a drag, and the active | ||
| 257 | // end stays on the last line of the tile it started in. | ||
| 258 | d.motion(.{ .row = 9, .col = 40 }, .{ .tile = 1, .row = 200, .col = 40 }); | ||
| 259 | const s = d.span(0, 5, 80).?; | ||
| 260 | try std.testing.expectEqual(@as(u16, 1), s.from); | ||
| 261 | try std.testing.expectEqual(@as(u16, 1), s.to); | ||
| 262 | // Neither the row it strayed onto nor the tile it strayed into. | ||
| 263 | try std.testing.expect(d.span(1, 200, 80) == null); | ||
| 264 | try std.testing.expect(d.span(0, 200, 80) == null); | ||
| 265 | // The same rows, asked for as somebody else's tile, are not the answer. | ||
| 266 | try std.testing.expect(d.span(1, 5, 80) == null); | ||
| 267 | } | ||
| 268 | |||
| 269 | test "select: a pointer that never leaves the press cell has not dragged" { | ||
| 270 | var d: Drag = .{}; | ||
| 271 | d.press(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 272 | // `?1002h` reports on a cell change, but a terminal repeating the cell | ||
| 273 | // must not turn a click into an empty selection. | ||
| 274 | d.motion(.{ .row = 6, .col = 20 }, .{ .tile = 0, .row = 6, .col = 20 }); | ||
| 275 | try std.testing.expect(d.range() == null); | ||
| 276 | try std.testing.expect(d.release() == .click); | ||
| 277 | } | ||
| 278 | |||
| 279 | test "select: a drag that comes back to where it started is still a drag" { | ||
| 280 | var d: Drag = .{}; | ||
| 281 | const home: Hit = .{ .tile = 0, .row = 6, .col = 20 }; | ||
| 282 | d.press(.{ .row = 6, .col = 20 }, home); | ||
| 283 | d.motion(.{ .row = 6, .col = 25 }, .{ .tile = 0, .row = 6, .col = 25 }); | ||
| 284 | d.motion(.{ .row = 6, .col = 20 }, home); | ||
| 285 | try std.testing.expect(d.release() == .selection); | ||
| 286 | // One cell selected, and it is still on screen after the button is up. | ||
| 287 | const s = d.span(0, 6, 80).?; | ||
| 288 | try std.testing.expectEqual(@as(u16, 20), s.from); | ||
| 289 | try std.testing.expectEqual(@as(u16, 20), s.to); | ||
| 290 | } | ||
| 291 | |||
| 292 | test "select: the highlight outlives the release, and dies on clear" { | ||
| 293 | var d: Drag = .{}; | ||
| 294 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 295 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 296 | _ = d.release(); | ||
| 297 | try std.testing.expect(d.span(0, 3, 80) != null); | ||
| 298 | // A motion after the button is up is somebody else's pointer moving | ||
| 299 | // over a selection that is finished. | ||
| 300 | d.motion(.{ .row = 8, .col = 8 }, .{ .tile = 0, .row = 11, .col = 8 }); | ||
| 301 | try std.testing.expectEqual(@as(u32, 4), d.range().?.to.row); | ||
| 302 | try std.testing.expect(d.release() == .nothing); | ||
| 303 | d.clear(); | ||
| 304 | try std.testing.expect(d.range() == null); | ||
| 305 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 306 | } | ||
| 307 | |||
| 308 | test "select: a new press drops the selection the last one left" { | ||
| 309 | var d: Drag = .{}; | ||
| 310 | d.press(.{ .row = 0, .col = 0 }, .{ .tile = 0, .row = 3, .col = 0 }); | ||
| 311 | d.motion(.{ .row = 1, .col = 5 }, .{ .tile = 0, .row = 4, .col = 5 }); | ||
| 312 | _ = d.release(); | ||
| 313 | d.press(.{ .row = 7, .col = 2 }, .{ .tile = 0, .row = 10, .col = 2 }); | ||
| 314 | try std.testing.expect(d.span(0, 3, 80) == null); | ||
| 315 | try std.testing.expect(d.range() == null); | ||
| 316 | // ...and a press on nothing drops it just as thoroughly. | ||
| 317 | d.motion(.{ .row = 7, .col = 6 }, .{ .tile = 0, .row = 10, .col = 6 }); | ||
| 318 | try std.testing.expect(d.range() != null); | ||
| 319 | d.press(.{ .row = 0, .col = 0 }, null); | ||
| 320 | try std.testing.expect(d.range() == null); | ||
| 321 | } | ||
| 322 | |||
| 323 | test "select: a motion with no button down selects nothing" { | ||
| 324 | var d: Drag = .{}; | ||
| 325 | d.motion(.{ .row = 4, .col = 4 }, .{ .tile = 0, .row = 4, .col = 4 }); | ||
| 326 | try std.testing.expect(d.range() == null); | ||
| 327 | try std.testing.expect(d.release() == .nothing); | ||
| 328 | } | ||
| 329 | |||
| 330 | test "select: a span past the grid's right edge is clipped, not painted at the edge" { | ||
| 331 | // The tty is wider than the grid — latest-wins leaves that shape | ||
| 332 | // routinely — so a pointer can reach columns with no cell behind them. | ||
| 333 | var d: Drag = .{}; | ||
| 334 | d.press(.{ .row = 0, .col = 90 }, .{ .tile = 0, .row = 2, .col = 90 }); | ||
| 335 | d.motion(.{ .row = 1, .col = 95 }, .{ .tile = 0, .row = 3, .col = 95 }); | ||
| 336 | // The first row's anchor is off the grid entirely: nothing to invert, | ||
| 337 | // and NOT one cell at column 39. | ||
| 338 | try std.testing.expect(d.span(0, 2, 40) == null); | ||
| 339 | // The last row runs from the left edge to the grid's own right edge. | ||
| 340 | const last = d.span(0, 3, 40).?; | ||
| 341 | try std.testing.expectEqual(@as(u16, 0), last.from); | ||
| 342 | try std.testing.expectEqual(@as(u16, 39), last.to); | ||
| 343 | } | ||
| 344 | |||
| 345 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 346 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 347 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 348 | test { | ||
| 349 | std.testing.refAllDeclsRecursive(@This()); | ||
| 350 | } | ||
src/tui/wall_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -281,6 +281,7 @@ pub fn replyBytes( | |||
| 281 | std.mem.writeInt(u32, buf[0..4], id, .little); | 281 | std.mem.writeInt(u32, buf[0..4], id, .little); |
| 282 | buf[4] = @intFromEnum(status); | 282 | buf[4] = @intFromEnum(status); |
| 283 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); | 283 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); |
| 284 | @memset(buf[9..proto.selection_reply_prefix_len], 0); | ||
| 284 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); | 285 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); |
| 285 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; | 286 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; |
| 286 | } | 287 | } |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const spawn = @import("spawn"); | |||
| 12 | const proxy = @import("proxy"); | 12 | const proxy = @import("proxy"); |
| 13 | const grid_mod = @import("term").grid; | 13 | const grid_mod = @import("term").grid; |
| 14 | const paint = @import("paint.zig"); | 14 | const paint = @import("paint.zig"); |
| 15 | const select = client.selection; | ||
| 15 | // Counters ride out through `Shared` because a detached pump never reaches | 16 | // Counters ride out through `Shared` because a detached pump never reaches |
| 16 | // a `Core.deinit`. | 17 | // a `Core.deinit`. |
| 17 | // The chord table and the prediction hooks a focused tile shares with the | 18 | // The chord table and the prediction hooks a focused tile shares with the |
| @@ -2641,7 +2642,6 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2641 | test { | 2642 | test { |
| 2642 | _ = @import("interact.zig"); | 2643 | _ = @import("interact.zig"); |
| 2643 | _ = @import("paint.zig"); | 2644 | _ = @import("paint.zig"); |
| 2644 | _ = @import("select.zig"); | ||
| 2645 | _ = @import("predict.zig"); | 2645 | _ = @import("predict.zig"); |
| 2646 | _ = @import("wall_test_harness.zig"); | 2646 | _ = @import("wall_test_harness.zig"); |
| 2647 | _ = @import("wall_test_host.zig"); | 2647 | _ = @import("wall_test_host.zig"); |
test/native_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,208 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Application mouse reports and OSC 52 through native SDL input and real PTYs. | ||
| 3 | |||
| 4 | The FIFO's generic `mouse:KIND,X,Y,BUTTON,MODS` form is deliberately used | ||
| 5 | here: it reaches the same SDL dispatch path as a user event, while the | ||
| 6 | fixture can pin all button and modifier combinations without a compositor. | ||
| 7 | """ | ||
| 8 | import os | ||
| 9 | import shlex | ||
| 10 | import subprocess | ||
| 11 | import sys | ||
| 12 | import time | ||
| 13 | |||
| 14 | sys.dont_write_bytecode = True | ||
| 15 | from native_lifecycle import start_persistent | ||
| 16 | from native_resize import by_id | ||
| 17 | from native_selection import SelectionRig | ||
| 18 | from native_tiling import eventually, require | ||
| 19 | |||
| 20 | |||
| 21 | SHIFT = 1 | ||
| 22 | |||
| 23 | |||
| 24 | class MouseRig(SelectionRig): | ||
| 25 | def mouse(self, kind, point, button=0, mods=0): | ||
| 26 | """Inject a normalized SDL mouse event through the generic hook.""" | ||
| 27 | x, y = point.split(',') | ||
| 28 | self.send(f'mouse:{kind},{x},{y},{button},{mods}') | ||
| 29 | |||
| 30 | def mouse_cell(self, state, pane, col, row, kind, button=0, mods=0): | ||
| 31 | self.mouse(kind, self.cell_point(state, pane, col, row), button, mods) | ||
| 32 | |||
| 33 | def desktop(self): | ||
| 34 | return self.clipboard() | ||
| 35 | |||
| 36 | def primary(self): | ||
| 37 | value = self.artifact('primary', '.txt').read_text() | ||
| 38 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 39 | external = subprocess.run(['wl-paste', '--primary', '--no-newline'], env=self.env, | ||
| 40 | capture_output=True, timeout=3) | ||
| 41 | if external.returncode or external.stdout.decode() != value: | ||
| 42 | return None | ||
| 43 | return value | ||
| 44 | |||
| 45 | def wait_desktop(self, text): | ||
| 46 | eventually(lambda: self.desktop() == text, 'desktop clipboard did not become ' + repr(text)) | ||
| 47 | |||
| 48 | def wait_primary(self, text): | ||
| 49 | eventually(lambda: self.primary() == text, 'primary selection did not become ' + repr(text)) | ||
| 50 | |||
| 51 | |||
| 52 | def reader_program(rig, tag): | ||
| 53 | """A raw foreground terminal app with independent input and output oracles.""" | ||
| 54 | source = rig.root / ('mouse-reader-' + tag + '.py') | ||
| 55 | received = rig.root / ('mouse-received-' + tag + '.bin') | ||
| 56 | control = rig.root / ('mouse-control-' + tag) | ||
| 57 | source.write_text( | ||
| 58 | 'import os, select, sys, termios, tty, time\n' | ||
| 59 | 'from pathlib import Path\n' | ||
| 60 | f'received=Path({str(received)!r}); control=Path({str(control)!r})\n' | ||
| 61 | 'fd=sys.stdin.fileno(); old=termios.tcgetattr(fd); tty.setraw(fd)\n' | ||
| 62 | 'try:\n' | ||
| 63 | ' sys.stdout.write("\\033[?25l\\033[2J\\033[HAPP-MOUSE-READY\\033[2;1Halpha café omega\\033[?1002h\\033[?1006h"); sys.stdout.flush()\n' | ||
| 64 | ' seen=0\n' | ||
| 65 | ' while True:\n' | ||
| 66 | ' if control.exists():\n' | ||
| 67 | ' commands=control.read_text()[seen:]; seen += len(commands)\n' | ||
| 68 | ' for command in commands.splitlines():\n' | ||
| 69 | ' if command == "off":\n' | ||
| 70 | ' sys.stdout.write("\\033[?1002l\\033[4;1HMODE-OFF"); sys.stdout.flush(); continue\n' | ||
| 71 | ' if command == "on":\n' | ||
| 72 | ' sys.stdout.write("\\033[?1002h\\033[4;1HMODE-ON "); sys.stdout.flush(); continue\n' | ||
| 73 | ' target={"c":"c","p":"p","s":"s","bad":"c","q":"q"}.get(command)\n' | ||
| 74 | ' if target:\n' | ||
| 75 | ' value={"c":"DESKTOP-C","p":"PRIMARY-P","s":"PRIMARY-S","bad":"\\x00","q":"IGNORED-Q"}[command]\n' | ||
| 76 | ' import base64\n' | ||
| 77 | ' sys.stdout.write("\\033]52;"+target+";"+base64.b64encode(value.encode()).decode()+"\\a"); sys.stdout.flush()\n' | ||
| 78 | ' ready, _, _ = select.select([fd], [], [], .02)\n' | ||
| 79 | ' if ready:\n' | ||
| 80 | ' data=os.read(fd, 4096)\n' | ||
| 81 | ' if not data: break\n' | ||
| 82 | ' with received.open("ab") as out: out.write(data)\n' | ||
| 83 | 'finally:\n' | ||
| 84 | ' termios.tcsetattr(fd, termios.TCSADRAIN, old)\n') | ||
| 85 | return source, received, control | ||
| 86 | |||
| 87 | |||
| 88 | def start_reader(rig, pane, tag): | ||
| 89 | source, received, control = reader_program(rig, tag) | ||
| 90 | rig.focus(pane) # A real Wayland pointer serial precedes any OSC 52 write. | ||
| 91 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 92 | rig.wait_state(lambda s: 'APP-MOUSE-READY' in by_id(s)[pane]['painted_text']) | ||
| 93 | return received, control | ||
| 94 | |||
| 95 | |||
| 96 | def bytes_after(path, offset, expected, message): | ||
| 97 | eventually(lambda: path.exists() and path.stat().st_size >= offset + len(expected), message) | ||
| 98 | actual = path.read_bytes()[offset:] | ||
| 99 | require(actual == expected, f'{message}: got {actual!r}, expected {expected!r}') | ||
| 100 | |||
| 101 | |||
| 102 | def application_drag(rig, pane, other): | ||
| 103 | received, control = start_reader(rig, pane, 'direct') | ||
| 104 | state = rig.state() | ||
| 105 | # 1002 + 1006: left press, held motion, then SGR release. The cells | ||
| 106 | # are one-based on the wire and relative to the selected source pane. | ||
| 107 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 108 | rig.mouse_cell(state, pane, 4, 1, 'move') | ||
| 109 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 110 | expected = b'\033[<0;1;2M\033[<32;5;2M\033[<0;5;2m' | ||
| 111 | bytes_after(received, 0, expected, 'application press/drag/release did not reach its PTY') | ||
| 112 | |||
| 113 | # Middle/right belong to the application too. Adding Shift after | ||
| 114 | # press changes report modifiers without turning this into local copy. | ||
| 115 | for button in (1, 2): | ||
| 116 | offset = received.stat().st_size | ||
| 117 | rig.mouse_cell(state, pane, 0, 1, 'down', button=button) | ||
| 118 | rig.mouse_cell(state, pane, 4, 1, 'move', button=button, mods=SHIFT) | ||
| 119 | rig.mouse_cell(state, pane, 4, 1, 'up', button=button, mods=SHIFT) | ||
| 120 | expected = (f'\033[<{button};1;2M\033[<{button + 36};5;2M' | ||
| 121 | f'\033[<{button + 4};5;2m').encode() | ||
| 122 | bytes_after(received, offset, expected, 'application modified button drag did not reach its PTY') | ||
| 123 | |||
| 124 | # A terminal mode change cancels a held app gesture but sends one | ||
| 125 | # release in the original SGR format, so the application cannot keep | ||
| 126 | # its button state stuck. Subsequent pointer events stay local until | ||
| 127 | # the application enables reporting again. | ||
| 128 | offset = received.stat().st_size | ||
| 129 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 130 | bytes_after(received, offset, b'\033[<0;1;2M', 'application press before mode change missing') | ||
| 131 | with control.open('a') as out: | ||
| 132 | out.write('off\n') | ||
| 133 | rig.wait_state(lambda s: 'MODE-OFF' in by_id(s)[pane]['painted_text']) | ||
| 134 | eventually(lambda: received.read_bytes()[offset:] == b'\033[<0;1;2M\033[<0;1;2m', | ||
| 135 | 'mode change did not release the held application button') | ||
| 136 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 137 | time.sleep(.08) | ||
| 138 | require(received.read_bytes()[offset:] == b'\033[<0;1;2M\033[<0;1;2m', | ||
| 139 | 'stale release after mode cancellation reached the application') | ||
| 140 | with control.open('a') as out: | ||
| 141 | out.write('on\n') | ||
| 142 | rig.wait_state(lambda s: 'MODE-ON' in by_id(s)[pane]['painted_text']) | ||
| 143 | |||
| 144 | # Shift latches a local selection even while the application has mouse | ||
| 145 | # reporting. It must not append any input to the terminal application. | ||
| 146 | offset = received.stat().st_size | ||
| 147 | rig.mouse_cell(state, pane, 0, 1, 'down', mods=SHIFT) | ||
| 148 | rig.mouse_cell(state, pane, 4, 1, 'move') | ||
| 149 | rig.mouse_cell(state, pane, 4, 1, 'up') | ||
| 150 | rig.wait_desktop('alpha') | ||
| 151 | time.sleep(.12) | ||
| 152 | require(received.stat().st_size == offset, 'Shift local drag leaked application mouse bytes') | ||
| 153 | |||
| 154 | # Capture stays with the original pane. A move over another tile must | ||
| 155 | # report the source pane's clamped edge, never a neighbour coordinate. | ||
| 156 | state = rig.state() | ||
| 157 | source = by_id(state)[pane] | ||
| 158 | other_content = by_id(state)[other]['content'] | ||
| 159 | endpoint = rig.cell_point(state, other, 4, 1) | ||
| 160 | end_x = other_content['x'] + 4.5 * state['cell_w'] | ||
| 161 | end_y = other_content['y'] + 1.5 * state['cell_h'] | ||
| 162 | offset = received.stat().st_size | ||
| 163 | rig.mouse_cell(state, pane, 0, 1, 'down') | ||
| 164 | rig.mouse('move', endpoint) | ||
| 165 | rig.mouse('up', endpoint) | ||
| 166 | edge_x = max(0, min(source['cols'] - 1, int((end_x - source['content']['x']) / state['cell_w']))) + 1 | ||
| 167 | edge_y = max(0, min(source['rows'] - 1, int((end_y - source['content']['y']) / state['cell_h']))) + 1 | ||
| 168 | expected = (f'\033[<0;1;2M\033[<32;{edge_x};{edge_y}M\033[<0;{edge_x};{edge_y}m').encode() | ||
| 169 | bytes_after(received, offset, expected, 'cross-pane capture changed application source or missed release') | ||
| 170 | |||
| 171 | # OSC 52 C owns desktop clipboard; P and S update primary only. An | ||
| 172 | # unsafe decoded NUL and an unsupported target leave prior ownership. | ||
| 173 | for command, primary in (('c', None), ('p', 'PRIMARY-P'), ('s', 'PRIMARY-S')): | ||
| 174 | with control.open('a') as out: | ||
| 175 | out.write(command + '\n') | ||
| 176 | if command == 'c': | ||
| 177 | rig.wait_desktop('DESKTOP-C') | ||
| 178 | else: | ||
| 179 | rig.wait_primary(primary) | ||
| 180 | require(rig.desktop() == 'DESKTOP-C', f'OSC 52 {command} overwrote desktop clipboard') | ||
| 181 | with control.open('a') as out: | ||
| 182 | out.write('bad\nq\n') | ||
| 183 | time.sleep(.15) | ||
| 184 | require(rig.desktop() == 'DESKTOP-C' and rig.primary() == 'PRIMARY-S', | ||
| 185 | 'invalid or unsupported OSC 52 write changed clipboard ownership') | ||
| 186 | rig.ok('application mouse bytes, Shift local drag, source capture, and OSC 52 targets') | ||
| 187 | |||
| 188 | |||
| 189 | def main(): | ||
| 190 | require(len(sys.argv) == 3, 'usage: native_mouse.py MUX MUXG') | ||
| 191 | rig = MouseRig(*sys.argv[1:]) | ||
| 192 | try: | ||
| 193 | refs = start_persistent(rig) | ||
| 194 | panes = list(refs) | ||
| 195 | application_drag(rig, panes[1], panes[2]) | ||
| 196 | rig.kernel_sizes() | ||
| 197 | rig.assert_cli_untouched() | ||
| 198 | rig.quit() | ||
| 199 | print('Native mouse acceptance passed; artifacts:', rig.root, flush=True) | ||
| 200 | except BaseException: | ||
| 201 | rig.failure_artifacts() | ||
| 202 | raise | ||
| 203 | finally: | ||
| 204 | rig.close() | ||
| 205 | |||
| 206 | |||
| 207 | if __name__ == '__main__': | ||
| 208 | main() | ||
test/native_selection.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,448 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real drag events, daemon extraction, framebuffer and desktop clipboard oracles.""" | ||
| 3 | import os | ||
| 4 | import shlex | ||
| 5 | import signal | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | |||
| 10 | sys.dont_write_bytecode = True | ||
| 11 | from native_lifecycle import LifecycleRig, start_persistent | ||
| 12 | from native_resize import by_id | ||
| 13 | from native_theme import pixel | ||
| 14 | from native_tiling import eventually, require | ||
| 15 | |||
| 16 | |||
| 17 | class SelectionRig(LifecycleRig): | ||
| 18 | pointer = None | ||
| 19 | |||
| 20 | def send(self, *lines): | ||
| 21 | if self.env['SDL_VIDEO_DRIVER'] != 'wayland': | ||
| 22 | return super().send(*lines) | ||
| 23 | from wayland_pointer import Pointer | ||
| 24 | for line in lines: | ||
| 25 | kind, _, coords = line.partition(':') | ||
| 26 | if kind in ('mousedown', 'mousemove', 'mouseup', 'click'): | ||
| 27 | if self.pointer is None: | ||
| 28 | binary = os.environ.get('MUXG_TEST_POINTER') | ||
| 29 | require(binary, 'Wayland clipboard acceptance requires MUXG_TEST_POINTER') | ||
| 30 | self.pointer = Pointer(binary, self.env, self.gui.pid) | ||
| 31 | subprocess.run(['swaymsg', f'[pid={self.gui.pid}] focus'], env=self.env, | ||
| 32 | capture_output=True, check=True, timeout=3) | ||
| 33 | self.pointer.move(*map(float, coords.split(','))) | ||
| 34 | self.state() # Deliver initial window focus before testing a pane click. | ||
| 35 | self.pointer.event(kind, *map(float, coords.split(','))) | ||
| 36 | else: | ||
| 37 | super().send(line) | ||
| 38 | |||
| 39 | def close(self): | ||
| 40 | try: | ||
| 41 | if self.pointer is not None: | ||
| 42 | self.pointer.close() | ||
| 43 | self.pointer = None | ||
| 44 | finally: | ||
| 45 | super().close() | ||
| 46 | |||
| 47 | def clipboard(self): | ||
| 48 | value = self.artifact('clipboard', '.txt').read_text() | ||
| 49 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 50 | # A separate client asks the compositor, not the application hook. | ||
| 51 | external = subprocess.run(['wl-paste', '--no-newline'], env=self.env, | ||
| 52 | capture_output=True, timeout=3) | ||
| 53 | self.clipboard_observed = {'sdl': value, 'desktop': external.stdout.decode(), | ||
| 54 | 'error': external.stderr.decode()} | ||
| 55 | # SDL publishes asynchronously to the compositor. Poll for both | ||
| 56 | # clients to agree instead of treating offer propagation as failure. | ||
| 57 | if external.returncode or external.stdout.decode() != value: | ||
| 58 | return None | ||
| 59 | return value | ||
| 60 | |||
| 61 | def cell_point(self, state, pane_id, col, row): | ||
| 62 | content = by_id(state)[pane_id]['content'] | ||
| 63 | return self.point(state, content['x'] + (col + .5) * state['cell_w'], | ||
| 64 | content['y'] + (row + .5) * state['cell_h']) | ||
| 65 | |||
| 66 | def select(self, pane_id, start, finish, release=True): | ||
| 67 | state = self.state() | ||
| 68 | a, b = (self.cell_point(state, pane_id, *cell) for cell in (start, finish)) | ||
| 69 | self.send('mousedown:' + a, 'mousemove:' + b) | ||
| 70 | if release: | ||
| 71 | self.send('mouseup:' + b) | ||
| 72 | return state | ||
| 73 | |||
| 74 | def copied(self, text): | ||
| 75 | try: | ||
| 76 | eventually(lambda: self.clipboard() == text, 'clipboard did not become ' + repr(text)) | ||
| 77 | except AssertionError as error: | ||
| 78 | raise AssertionError(f'{error}; observed {getattr(self, "clipboard_observed", None)}') from error | ||
| 79 | |||
| 80 | def unchanged(self, text): | ||
| 81 | # Observe over several event-loop turns so a queued reply has a chance | ||
| 82 | # to arrive; a single immediate equality would miss a late overwrite. | ||
| 83 | until = time.monotonic() + .35 | ||
| 84 | while time.monotonic() < until: | ||
| 85 | require(self.clipboard() == text, 'cancelled selection changed clipboard') | ||
| 86 | time.sleep(.025) | ||
| 87 | |||
| 88 | |||
| 89 | def specimen(rig, pane_id, tag): | ||
| 90 | rig.focus(pane_id) | ||
| 91 | pane = by_id(rig.state())[pane_id] | ||
| 92 | require(pane['cols'] >= 22 and pane['rows'] >= 9, 'selection specimen needs 22x9 cells') | ||
| 93 | wrap = 'W' * pane['cols'] + 'RAP' | ||
| 94 | marker = 'READY-' + tag + '-' + str(rig.serial) | ||
| 95 | output = ('\033[0m\033[2J\033[H' + tag + '\033[2;1Halpha café 界 omega' | ||
| 96 | '\033[3;1Hhard one\r\nhard two ' | ||
| 97 | '\033[5;1H' + wrap + '\033[8;1H' + marker + '\033[9;1H') | ||
| 98 | escaped = output.replace('\033', '\\033').replace('\r', '\\r').replace('\n', '\\n') | ||
| 99 | rig.shell("export PS1=''; printf '%b' " + shlex.quote(escaped)) | ||
| 100 | rig.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text']) | ||
| 101 | return wrap | ||
| 102 | |||
| 103 | |||
| 104 | def cell_background(rig, state, pane_id, col, row): | ||
| 105 | rect = by_id(state)[pane_id]['content'] | ||
| 106 | # Cell corner avoids glyph ink and samples actual completed painted state. | ||
| 107 | return pixel(rig.last_pixels(), rect['x'] + col * state['cell_w'] + 1, | ||
| 108 | rect['y'] + row * state['cell_h'] + 1) | ||
| 109 | |||
| 110 | |||
| 111 | def arm_output(rig, pane_id, label, row=7): | ||
| 112 | """Shell-owned output released by a file, with no input during the drag.""" | ||
| 113 | trigger = rig.root / label | ||
| 114 | rig.focus(pane_id) | ||
| 115 | rig.shell('(while ! test -e ' + shlex.quote(str(trigger)) + | ||
| 116 | "; do sleep .02; done; printf '\\033[" + str(row) + ";1H" + label + "') &") | ||
| 117 | return trigger | ||
| 118 | |||
| 119 | |||
| 120 | def copy_shortcut(rig, pane): | ||
| 121 | """Observe copy and SIGINT independently in a real foreground PTY process.""" | ||
| 122 | rig.focus(pane) | ||
| 123 | interrupted, stop = (rig.root / name for name in ('copy-sigint', 'copy-stop')) | ||
| 124 | program = rig.root / 'copy-foreground.py' | ||
| 125 | program.write_text( | ||
| 126 | 'import signal, time\nfrom pathlib import Path\n' | ||
| 127 | f'interrupted = Path({str(interrupted)!r})\nstop = Path({str(stop)!r})\n' | ||
| 128 | 'def on_interrupt(signum, frame):\n' | ||
| 129 | ' with interrupted.open("a") as out: out.write("INT\\n")\n' | ||
| 130 | 'signal.signal(signal.SIGINT, on_interrupt)\n' | ||
| 131 | 'print("\\033[0m\\033[2J\\033[HCOPY-READY\\033[2;1Halpha café omega\\033[4;1H", end="", flush=True)\n' | ||
| 132 | 'while not stop.exists(): time.sleep(.02)\n') | ||
| 133 | rig.shell('python3 ' + shlex.quote(str(program))) | ||
| 134 | state = rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('COPY-READY')) | ||
| 135 | background = cell_background(rig, state, pane, 1, 1) | ||
| 136 | try: | ||
| 137 | rig.select(pane, (6, 1), (9, 1)) | ||
| 138 | rig.copied('café') | ||
| 139 | # Copy before release proves the shortcut invoked the copy path itself. | ||
| 140 | rig.select(pane, (0, 1), (4, 1), release=False) | ||
| 141 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 142 | 'held selection was not painted before copy shortcut') | ||
| 143 | rig.key('copy') | ||
| 144 | rig.copied('alpha') | ||
| 145 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 146 | 'copy shortcut cleared held selection') | ||
| 147 | rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) | ||
| 148 | rig.key('copy') | ||
| 149 | rig.unchanged('alpha') | ||
| 150 | require(not interrupted.exists(), 'copy shortcut sent SIGINT to foreground process') | ||
| 151 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 152 | 'copy shortcut cleared released selection') | ||
| 153 | rig.focus(pane) # Header press clears the range. | ||
| 154 | eventually(lambda: cell_background(rig, state, pane, 1, 1) == background, | ||
| 155 | 'header press did not clear selection before copy shortcut') | ||
| 156 | rig.key('copy') | ||
| 157 | rig.unchanged('alpha') | ||
| 158 | require(not interrupted.exists(), 'copy without selection sent SIGINT') | ||
| 159 | rig.key('interrupt') | ||
| 160 | eventually(lambda: interrupted.exists() and interrupted.read_text() == 'INT\n', | ||
| 161 | 'ordinary Ctrl+C did not interrupt the foreground PTY process') | ||
| 162 | rig.unchanged('alpha') | ||
| 163 | rig.ok('Ctrl+Shift+C copies and retains selection without SIGINT; plain Ctrl+C still interrupts') | ||
| 164 | finally: | ||
| 165 | stop.touch() | ||
| 166 | specimen(rig, pane, 'PANE-' + str(pane)) | ||
| 167 | |||
| 168 | |||
| 169 | def scale_selection(rig, pane): | ||
| 170 | output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') | ||
| 171 | if not output: | ||
| 172 | return | ||
| 173 | require(rig.pointer is not None, 'scale acceptance requires real Wayland input') | ||
| 174 | original = next(o['scale'] for o in rig.pointer.query('get_outputs') if o['name'] == output) | ||
| 175 | |||
| 176 | def scale(value): | ||
| 177 | response = subprocess.run(['swaymsg', '-r', 'output', output, 'scale', str(value)], | ||
| 178 | env=rig.env, capture_output=True, text=True, check=True, timeout=3) | ||
| 179 | require('"success": true' in response.stdout, 'compositor refused fixture scale') | ||
| 180 | return rig.wait_state(lambda s: abs(s['width'] / s['logical_width'] - value) < .01) | ||
| 181 | |||
| 182 | try: | ||
| 183 | for value in (1, 1.5, 2): | ||
| 184 | specimen(rig, pane, 'SCALE-' + str(value)) | ||
| 185 | rig.select(pane, (0, 1), (4, 1)) | ||
| 186 | rig.copied('alpha') | ||
| 187 | rig.select(pane, (6, 1), (9, 1), release=False) | ||
| 188 | state = scale(value) | ||
| 189 | rig.send('mouseup:' + rig.cell_point(state, pane, 9, 1)) | ||
| 190 | rig.unchanged('alpha') | ||
| 191 | specimen(rig, pane, 'SCALED-' + str(value)) | ||
| 192 | rig.select(pane, (6, 1), (9, 1)) | ||
| 193 | rig.copied('café') | ||
| 194 | rig.kernel_sizes() | ||
| 195 | finally: | ||
| 196 | scale(original) | ||
| 197 | rig.ok('real held drags cancel at 100/150/200% transitions; new drags copy and PTYs agree') | ||
| 198 | |||
| 199 | |||
| 200 | |||
| 201 | def active_output(rig, pane): | ||
| 202 | """A real PTY keeps repainting while selection and clipboard are observed.""" | ||
| 203 | rig.focus(pane) | ||
| 204 | stop = rig.root / 'counter-stop' | ||
| 205 | program = rig.root / 'selection-counter.py' | ||
| 206 | program.write_text( | ||
| 207 | 'import os, time\nfrom pathlib import Path\n' | ||
| 208 | f'stop=Path({str(stop)!r})\n' | ||
| 209 | 'os.write(1,b"\\033[?25l\\033[2J\\033[HCOUNTER-READY")\n' | ||
| 210 | 'n=0\n' | ||
| 211 | 'while not stop.exists():\n' | ||
| 212 | ' os.write(1,("\\033[2;1Halpha café omega\\033[2;21HCOUNT-%08d" % n).encode())\n' | ||
| 213 | ' n+=1; time.sleep(.025)\n' | ||
| 214 | 'print("\\r\\nCOUNTER-DONE",flush=True)\n') | ||
| 215 | tmux_socket = rig.root / 'counter-tmux.sock' | ||
| 216 | nested = os.environ.get('MUXG_TEST_TMUX') == '1' | ||
| 217 | command = 'python3 ' + shlex.quote(str(program)) | ||
| 218 | if nested: | ||
| 219 | command = ('tmux -S ' + shlex.quote(str(tmux_socket)) + | ||
| 220 | ' -f /dev/null new-session -s counter ' + shlex.quote(command)) | ||
| 221 | rig.shell(command) | ||
| 222 | def count(state): | ||
| 223 | text = by_id(state)[pane]['painted_text'] | ||
| 224 | return int(text.split('COUNT-', 1)[1][:8]) if 'COUNT-' in text else -1 | ||
| 225 | rig.wait_state(lambda state: count(state) >= 0) | ||
| 226 | wayland = rig.env['SDL_VIDEO_DRIVER'] == 'wayland' | ||
| 227 | windowed = rig.state() | ||
| 228 | original_size = (windowed['logical_width'], windowed['logical_height']) | ||
| 229 | try: | ||
| 230 | for fullscreen in ((False, True) if wayland else (False,)): | ||
| 231 | if wayland: | ||
| 232 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] fullscreen ' + | ||
| 233 | ('enable' if fullscreen else 'disable')], | ||
| 234 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 235 | rig.wait_state(lambda state: ((state['logical_width'], state['logical_height']) != | ||
| 236 | original_size) == fullscreen) | ||
| 237 | # Let resize output settle while proving the application still runs. | ||
| 238 | initial = count(rig.state()) | ||
| 239 | state = rig.wait_state(lambda state: count(state) >= initial + 8) | ||
| 240 | background = cell_background(rig, state, pane, 1, 1) | ||
| 241 | rig.select(pane, (0, 1), (4, 1), release=False) | ||
| 242 | initial = count(rig.state()) | ||
| 243 | rig.wait_state(lambda state: count(state) >= initial + 12) | ||
| 244 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 245 | 'active counter cleared the held selection') | ||
| 246 | rig.key('copy') | ||
| 247 | rig.copied('alpha') | ||
| 248 | # A second copy during the same held gesture must use the newly | ||
| 249 | # extended range. Release then registers its final, shorter range. | ||
| 250 | rig.send('mousemove:' + rig.cell_point(state, pane, 6, 1)) | ||
| 251 | # Wayland motion and the FIFO keyboard hook arrive independently. | ||
| 252 | # Observe the extended highlight before asking the other channel to copy. | ||
| 253 | eventually(lambda: cell_background(rig, rig.state(), pane, 6, 1) != background, | ||
| 254 | 'held drag did not visibly extend before copying') | ||
| 255 | rig.key('copy') | ||
| 256 | rig.copied('alpha c') | ||
| 257 | rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) | ||
| 258 | rig.copied('alpha') | ||
| 259 | initial = count(rig.state()) | ||
| 260 | rig.wait_state(lambda state: count(state) >= initial + 12) | ||
| 261 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 262 | 'active counter cleared the released highlight') | ||
| 263 | rig.key('copy') | ||
| 264 | rig.copied('alpha') | ||
| 265 | rig.ok('live PTY counter keeps advancing during held/released selection and copy' + | ||
| 266 | (' through tmux' if nested else '') + | ||
| 267 | (' in windowed and fullscreen Wayland' if wayland else '')) | ||
| 268 | finally: | ||
| 269 | stop.touch() | ||
| 270 | if nested: | ||
| 271 | subprocess.run(['tmux', '-S', str(tmux_socket), 'kill-server'], | ||
| 272 | env=rig.env, capture_output=True, timeout=3) | ||
| 273 | if wayland: | ||
| 274 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] fullscreen disable'], | ||
| 275 | env=rig.env, capture_output=True, timeout=3) | ||
| 276 | if wayland: | ||
| 277 | rig.wait_state(lambda state: (state['logical_width'], state['logical_height']) == original_size) | ||
| 278 | if nested: | ||
| 279 | rig.wait_state(lambda state: count(state) < 0) | ||
| 280 | else: | ||
| 281 | rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text']) | ||
| 282 | |||
| 283 | |||
| 284 | def exercise(rig): | ||
| 285 | refs = start_persistent(rig) | ||
| 286 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 287 | panes = list(refs) | ||
| 288 | active_output(rig, panes[1]) | ||
| 289 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} | ||
| 290 | target, neighbour = panes[1], panes[2] | ||
| 291 | copy_shortcut(rig, target) | ||
| 292 | rig.focus(panes[0]) | ||
| 293 | state = rig.state() | ||
| 294 | before = {p: cell_background(rig, state, p, 1, 1) for p in panes} | ||
| 295 | rig.select(target, (0, 1), (4, 1), release=False) | ||
| 296 | eventually(lambda: cell_background(rig, state, target, 1, 1) != before[target], | ||
| 297 | 'drag did not visibly highlight the off-origin pane') | ||
| 298 | for p in (panes[0], neighbour): | ||
| 299 | require(cell_background(rig, state, p, 1, 1) == before[p], 'highlight leaked into neighbour') | ||
| 300 | require(rig.state()['focus'] == target, 'press did not focus selected pane') | ||
| 301 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 302 | rig.copied('alpha') | ||
| 303 | rig.ok('off-origin held drag paints only its pane and release copies daemon text') | ||
| 304 | |||
| 305 | for pane in panes: | ||
| 306 | rig.select(pane, (4, 1), (0, 1)) | ||
| 307 | rig.copied('alpha') | ||
| 308 | rig.select(pane, (6, 1), (10, 1)) | ||
| 309 | rig.copied('café') | ||
| 310 | # Endpoint on the wide glyph continuation must include the whole glyph. | ||
| 311 | rig.select(pane, (10, 1), (12, 1)) | ||
| 312 | rig.copied(' 界') | ||
| 313 | rig.select(pane, (0, 2), (10, 3)) | ||
| 314 | rig.copied('hard one\nhard two') | ||
| 315 | rig.select(pane, (0, 4), (2, 5)) | ||
| 316 | rig.copied(wraps[pane]) | ||
| 317 | rig.ok('both directions, Unicode, wide continuation, trimming, hard newline and soft wrap') | ||
| 318 | |||
| 319 | baseline = wraps[panes[-1]] | ||
| 320 | state = rig.state() | ||
| 321 | same = rig.cell_point(state, target, 0, 1) | ||
| 322 | rig.send('mousedown:' + same, 'mousemove:' + same, 'mouseup:' + same) | ||
| 323 | rig.unchanged(baseline) | ||
| 324 | rig.focus(neighbour) # Header press is deliberately not terminal text. | ||
| 325 | rig.unchanged(baseline) | ||
| 326 | rig.chord('enter') | ||
| 327 | rig.picker('hosts') | ||
| 328 | rig.select(target, (0, 1), (4, 1)) | ||
| 329 | rig.unchanged(baseline) | ||
| 330 | rig.key('escape') | ||
| 331 | rig.key('escape') | ||
| 332 | rig.ok('same-cell tremor, header press and modal pointer input preserve clipboard') | ||
| 333 | |||
| 334 | # Start in one pane, enter its neighbour, then release back over the origin. | ||
| 335 | # The neighbour must not supply either the endpoint or copied text. | ||
| 336 | state = rig.select(target, (0, 1), (4, 1), release=False) | ||
| 337 | rig.send('mousemove:' + rig.cell_point(state, neighbour, 16, 1), | ||
| 338 | 'mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 339 | rig.copied('alpha') | ||
| 340 | rig.ok('cross-pane motion keeps the original session selection') | ||
| 341 | |||
| 342 | trigger = arm_output(rig, neighbour, 'NEIGHBOUR-OUTPUT') | ||
| 343 | specimen(rig, neighbour, 'PANE-' + str(neighbour)) | ||
| 344 | background = cell_background(rig, rig.state(), target, 7, 1) | ||
| 345 | state = rig.select(target, (6, 1), (9, 1), release=False) | ||
| 346 | eventually(lambda: cell_background(rig, state, target, 7, 1) != background, | ||
| 347 | 'selection was not painted before neighbouring output') | ||
| 348 | trigger.touch() | ||
| 349 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) | ||
| 350 | rig.send('mouseup:' + rig.cell_point(state, target, 9, 1)) | ||
| 351 | rig.copied('café') | ||
| 352 | trigger = arm_output(rig, target, 'SELECTED-OUTPUT', row=2) | ||
| 353 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 354 | background = cell_background(rig, rig.state(), target, 1, 1) | ||
| 355 | state = rig.select(target, (0, 1), (4, 1), release=False) | ||
| 356 | eventually(lambda: cell_background(rig, state, target, 1, 1) != background, | ||
| 357 | 'selection was not painted before selected-pane output') | ||
| 358 | trigger.touch() | ||
| 359 | rig.wait_state(lambda s: 'SELECTED-OUTPUT' in by_id(s)[target]['painted_text']) | ||
| 360 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) | ||
| 361 | rig.copied('SELEC') | ||
| 362 | rig.ok('redraw preserves selection; overwriting selected text copies the current range') | ||
| 363 | rig.shell('wait') # Reap the fixture writer before painting the next specimen. | ||
| 364 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 365 | rig.select(target, (0, 1), (4, 1)) | ||
| 366 | rig.copied('alpha') | ||
| 367 | |||
| 368 | # Pause only this fixture's real daemon. The queued selection is still | ||
| 369 | # decoded/extracted by that daemon when resumed, not by a protocol mock. | ||
| 370 | sock = refs[target][0] | ||
| 371 | daemon = next(proc for path, proc in rig.daemons if path == sock) | ||
| 372 | daemon.send_signal(signal.SIGSTOP) | ||
| 373 | try: | ||
| 374 | rig.select(target, (6, 1), (9, 1)) | ||
| 375 | rig.state() # Event queue barrier, no repaint or data repair. | ||
| 376 | rig.focus(neighbour) | ||
| 377 | finally: | ||
| 378 | daemon.send_signal(signal.SIGCONT) | ||
| 379 | rig.unchanged('alpha') | ||
| 380 | rig.ok('a delayed real-daemon reply cannot copy after a new press clears selection') | ||
| 381 | |||
| 382 | # Resize while a reply is waiting; then validate the real PTY sizes. | ||
| 383 | daemon.send_signal(signal.SIGSTOP) | ||
| 384 | try: | ||
| 385 | rig.select(target, (6, 1), (9, 1)) | ||
| 386 | rig.state() | ||
| 387 | rig.drag('beside', dx=rig.state()['cell_w'] * -2) | ||
| 388 | rig.state() # Process the resize events before allowing the reply. | ||
| 389 | finally: | ||
| 390 | daemon.send_signal(signal.SIGCONT) | ||
| 391 | rig.unchanged('alpha') | ||
| 392 | rig.kernel_sizes() | ||
| 393 | rig.ok('geometry change cancels pending copy and all PTYs match the resized panes') | ||
| 394 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 395 | state = rig.state() | ||
| 396 | blank = cell_background(rig, state, target, 16, 8) | ||
| 397 | rig.select(target, (15, 8), (18, 8), release=False) | ||
| 398 | eventually(lambda: cell_background(rig, state, target, 16, 8) != blank, | ||
| 399 | 'empty-copy fixture did not select its in-bounds blank cells') | ||
| 400 | rig.send('mouseup:' + rig.cell_point(state, target, 18, 8)) | ||
| 401 | rig.state() | ||
| 402 | rig.unchanged('alpha') | ||
| 403 | daemon.send_signal(signal.SIGSTOP) | ||
| 404 | try: | ||
| 405 | rig.select(target, (6, 1), (9, 1)) | ||
| 406 | rig.wait_state(lambda s: 'Selection unavailable' in s['notice']) | ||
| 407 | finally: | ||
| 408 | daemon.send_signal(signal.SIGCONT) | ||
| 409 | rig.unchanged('alpha') | ||
| 410 | rig.ok('empty copy and a real unanswered request preserve clipboard; timeout is visible') | ||
| 411 | scale_selection(rig, target) | ||
| 412 | specimen(rig, target, 'DETACH') | ||
| 413 | rig.select(target, (0, 1), (4, 1)) | ||
| 414 | rig.copied('alpha') | ||
| 415 | daemon.send_signal(signal.SIGSTOP) | ||
| 416 | try: | ||
| 417 | rig.select(target, (6, 1), (9, 1)) | ||
| 418 | rig.state() | ||
| 419 | rig.chord('d') | ||
| 420 | rig.wait_state(lambda s: target not in by_id(s)) | ||
| 421 | finally: | ||
| 422 | daemon.send_signal(signal.SIGCONT) | ||
| 423 | rig.unchanged('alpha') | ||
| 424 | require(rig.status(*refs[target])['cols'] > 0, 'detach ended the selected shell') | ||
| 425 | rig.ok('detach cancels copying with a stalled daemon and leaves its shell alive') | ||
| 426 | rig.assert_cli_untouched() | ||
| 427 | return refs | ||
| 428 | |||
| 429 | |||
| 430 | def main(): | ||
| 431 | require(len(sys.argv) == 3, 'usage: native_selection.py MUX MUXG') | ||
| 432 | rig = SelectionRig(*sys.argv[1:]) | ||
| 433 | try: | ||
| 434 | exercise(rig) | ||
| 435 | rig.quit() | ||
| 436 | print('Native selection acceptance passed; artifacts:', rig.root, flush=True) | ||
| 437 | except BaseException: | ||
| 438 | rig.failure_artifacts() | ||
| 439 | raise | ||
| 440 | finally: | ||
| 441 | for _, proc in rig.daemons: | ||
| 442 | if proc.poll() is None: | ||
| 443 | proc.send_signal(signal.SIGCONT) | ||
| 444 | rig.close() | ||
| 445 | |||
| 446 | |||
| 447 | if __name__ == '__main__': | ||
| 448 | main() | ||
test/native_selection_follow.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,239 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real-PTY acceptance for completed local selections that follow terminal edits. | ||
| 3 | |||
| 4 | The source panes deliberately contain duplicate text. The surrounding numbered | ||
| 5 | rows and framebuffer location make a copied duplicate insufficient evidence: | ||
| 6 | the selected *occurrence* must move with the terminal's own tracked pins. | ||
| 7 | """ | ||
| 8 | import shlex | ||
| 9 | import os | ||
| 10 | import sys | ||
| 11 | import time | ||
| 12 | |||
| 13 | sys.dont_write_bytecode = True | ||
| 14 | from native_lifecycle import start_persistent | ||
| 15 | from native_resize import by_id | ||
| 16 | from native_selection import SelectionRig, cell_background | ||
| 17 | from native_tiling import eventually, require | ||
| 18 | |||
| 19 | |||
| 20 | SHIFT = 1 | ||
| 21 | |||
| 22 | |||
| 23 | def pty_output(rig, pane, data): | ||
| 24 | """Write fixture-owned terminal output without changing GUI focus.""" | ||
| 25 | fd = os.open(rig.tty_paths[pane], os.O_WRONLY | os.O_NOCTTY) | ||
| 26 | try: | ||
| 27 | os.write(fd, data) | ||
| 28 | finally: | ||
| 29 | os.close(fd) | ||
| 30 | |||
| 31 | |||
| 32 | def row_of(state, pane, prefix): | ||
| 33 | for row, line in enumerate(by_id(state)[pane]['painted_text'].splitlines()): | ||
| 34 | if line.startswith(prefix): | ||
| 35 | return row | ||
| 36 | return None | ||
| 37 | |||
| 38 | |||
| 39 | def mouse_cell(rig, state, pane, col, row, kind, mods=0): | ||
| 40 | point = rig.cell_point(state, pane, col, row) | ||
| 41 | x, y = point.split(',') | ||
| 42 | rig.send(f'mouse:{kind},{x},{y},0,{mods}') | ||
| 43 | |||
| 44 | |||
| 45 | def local_shift_select(rig, pane, start, finish): | ||
| 46 | """Use the ordinary SDL mouse hook: an app may be reporting mouse input.""" | ||
| 47 | state = rig.state() | ||
| 48 | mouse_cell(rig, state, pane, *start, 'down', SHIFT) | ||
| 49 | mouse_cell(rig, state, pane, *finish, 'move', SHIFT) | ||
| 50 | mouse_cell(rig, state, pane, *finish, 'up', SHIFT) | ||
| 51 | return state | ||
| 52 | |||
| 53 | |||
| 54 | def shell_follow_program(rig, pane): | ||
| 55 | trigger = rig.root / 'shell-follow-go' | ||
| 56 | source = rig.root / 'shell-follow.py' | ||
| 57 | rows = by_id(rig.state())[pane]['rows'] | ||
| 58 | require(rows >= 9, 'shell follow fixture needs nine terminal rows') | ||
| 59 | source.write_text( | ||
| 60 | 'import os, time\nfrom pathlib import Path\n' | ||
| 61 | f'trigger=Path({str(trigger)!r})\n' | ||
| 62 | f'rows={rows}\n' | ||
| 63 | 'body=["SHELL-%03d" % n for n in range(rows)]\n' | ||
| 64 | 'body[rows-8]="SHELL-DUPLICATE"; body[rows-7]="SHELL-BEFORE-003"\n' | ||
| 65 | 'body[rows-5]="SHELL-DUPLICATE"; body[rows-4]="SHELL-AFTER-006"; body[-1]="SHELL-READY"\n' | ||
| 66 | 'os.write(1, ("\\033[0m\\033[2J\\033[H"+"\\r\\n".join(body)).encode())\n' | ||
| 67 | 'while not trigger.exists(): time.sleep(.01)\n' | ||
| 68 | 'for n in range(3):\n' | ||
| 69 | ' os.write(1, ("\\r\\nSHELL-TAIL-%03d" % n).encode()); time.sleep(.03)\n' | ||
| 70 | 'while True: time.sleep(60)\n') | ||
| 71 | rig.focus(pane) | ||
| 72 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 73 | state = rig.wait_state(lambda s: row_of(s, pane, 'SHELL-READY') is not None) | ||
| 74 | return trigger, state | ||
| 75 | |||
| 76 | |||
| 77 | def shell_primary_scroll(rig, target, neighbour): | ||
| 78 | trigger, state = shell_follow_program(rig, target) | ||
| 79 | after = row_of(state, target, 'SHELL-AFTER-006') | ||
| 80 | start = None if after is None else after - 1 | ||
| 81 | require(start is not None and start + 1 < by_id(state)[target]['rows'], | ||
| 82 | 'shell fixture did not leave a two-row selection in the viewport') | ||
| 83 | background = cell_background(rig, state, target, 2, start) | ||
| 84 | # Select a duplicate plus its distinct successor. Matching the word alone | ||
| 85 | # would let a stale range select a different identical line. | ||
| 86 | local_shift_select(rig, target, (0, start), (16, start + 1)) | ||
| 87 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 88 | eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background, | ||
| 89 | 'completed shell selection was not visibly painted') | ||
| 90 | |||
| 91 | # Unselected output must not move or invalidate this pane's occurrence. | ||
| 92 | pty_output(rig, neighbour, b'\033[0mNEIGHBOUR-OUTPUT\r\n') | ||
| 93 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) | ||
| 94 | rig.key('copy') | ||
| 95 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 96 | |||
| 97 | trigger.touch() | ||
| 98 | moved = rig.wait_state(lambda s: row_of(s, target, 'SHELL-TAIL-002') is not None) | ||
| 99 | after = row_of(moved, target, 'SHELL-AFTER-006') | ||
| 100 | new_row = None if after is None else after - 1 | ||
| 101 | require(new_row == start - 3, | ||
| 102 | f'shell occurrence moved to row {new_row}, expected {start - 3}') | ||
| 103 | eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background, | ||
| 104 | 'shell highlight did not follow its selected occurrence upward') | ||
| 105 | require(cell_background(rig, rig.state(), target, 2, start) == background, | ||
| 106 | 'shell highlight remained at the old viewport row') | ||
| 107 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 108 | rig.copied('COPY-SENTINEL') | ||
| 109 | rig.key('copy') | ||
| 110 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 111 | rig.ok('completed local selection follows primary-screen append through duplicate rows; neighbouring output is independent') | ||
| 112 | # Return the target pane to its shell before the alternate-screen fixture | ||
| 113 | # starts; otherwise its launch command would be consumed by the old app. | ||
| 114 | rig.key('interrupt') | ||
| 115 | time.sleep(.12) | ||
| 116 | |||
| 117 | |||
| 118 | def alt_reporter(rig, pane): | ||
| 119 | control = rig.root / 'alt-follow-control' | ||
| 120 | received = rig.root / 'alt-follow-received.bin' | ||
| 121 | source = rig.root / 'alt-follow.py' | ||
| 122 | rows = by_id(rig.state())[pane]['rows'] | ||
| 123 | require(rows >= 9, 'alt follow fixture needs nine terminal rows') | ||
| 124 | source.write_text( | ||
| 125 | 'import os, select, sys, termios, tty, time\nfrom pathlib import Path\n' | ||
| 126 | f'control=Path({str(control)!r}); received=Path({str(received)!r})\n' | ||
| 127 | 'fd=0; old=termios.tcgetattr(fd); tty.setraw(fd); seen=0\n' | ||
| 128 | f'rows={rows}\n' | ||
| 129 | 'body=["ALT-%03d" % n for n in range(rows)]\n' | ||
| 130 | 'body[rows-8]="ALT-DUPLICATE"; body[rows-7]="ALT-BEFORE-003"\n' | ||
| 131 | 'body[rows-5]="ALT-DUPLICATE"; body[rows-4]="ALT-AFTER-006"; body[-1]="ALT-READY"\n' | ||
| 132 | 'try:\n' | ||
| 133 | ' os.write(1, ("\\033[?1049h\\033[?25l\\033[2J\\033[H"+"\\r\\n".join(body)+"\\033[?1002h\\033[?1006h").encode())\n' | ||
| 134 | ' while True:\n' | ||
| 135 | ' if control.exists():\n' | ||
| 136 | ' data=control.read_text()[seen:]; seen += len(data)\n' | ||
| 137 | ' for command in data.splitlines():\n' | ||
| 138 | ' if command == "scroll": os.write(1, ("\\033[2;%dr\\033[2S\\033[r" % rows).encode())\n' | ||
| 139 | ' if command == "reset": os.write(1, b"\\033c")\n' | ||
| 140 | ' if select.select([fd], [], [], .02)[0]:\n' | ||
| 141 | ' with received.open("ab") as out: out.write(os.read(fd, 4096))\n' | ||
| 142 | 'finally: termios.tcsetattr(fd, termios.TCSADRAIN, old)\n') | ||
| 143 | rig.focus(pane) | ||
| 144 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 145 | state = rig.wait_state(lambda s: row_of(s, pane, 'ALT-READY') is not None) | ||
| 146 | return control, received, state | ||
| 147 | |||
| 148 | |||
| 149 | def alt_region_scroll(rig, target, neighbour): | ||
| 150 | control, received, state = alt_reporter(rig, target) | ||
| 151 | after = row_of(state, target, 'ALT-AFTER-006') | ||
| 152 | start = None if after is None else after - 1 | ||
| 153 | require(start is not None and start + 1 < by_id(state)[target]['rows'], 'alt fixture rows missing') | ||
| 154 | background = cell_background(rig, state, target, 2, start) | ||
| 155 | before_bytes = received.stat().st_size if received.exists() else 0 | ||
| 156 | local_shift_select(rig, target, (0, start), (14, start + 1)) | ||
| 157 | rig.copied('ALT-DUPLICATE\nALT-AFTER-006') | ||
| 158 | time.sleep(.12) | ||
| 159 | require((received.stat().st_size if received.exists() else 0) == before_bytes, | ||
| 160 | 'Shift local selection leaked mouse reports into the reporting app') | ||
| 161 | with control.open('a') as out: | ||
| 162 | out.write('scroll\n') | ||
| 163 | moved = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') == start - 1) | ||
| 164 | after = row_of(moved, target, 'ALT-AFTER-006') | ||
| 165 | new_row = None if after is None else after - 1 | ||
| 166 | eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background, | ||
| 167 | 'alt-screen region scroll did not move the highlight') | ||
| 168 | require(cell_background(rig, rig.state(), target, 2, start) == background, | ||
| 169 | 'alt-screen region scroll left highlight at its old row') | ||
| 170 | # Ctrl+Shift+C must ask the daemon for the tracked occurrence after the | ||
| 171 | # scroll, rather than reusing the old client coordinates. | ||
| 172 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 173 | rig.copied('COPY-SENTINEL') | ||
| 174 | rig.key('copy') | ||
| 175 | rig.copied('ALT-DUPLICATE\nALT-AFTER-006') | ||
| 176 | rig.ok('Shift selection in a mouse-reporting alt screen follows vertical-region scrolling and Ctrl+Shift+C copies it') | ||
| 177 | return control | ||
| 178 | |||
| 179 | |||
| 180 | def invalidation(rig, target, control, neighbour): | ||
| 181 | stable = rig.clipboard() | ||
| 182 | require(stable == 'ALT-DUPLICATE\nALT-AFTER-006', 'missing selection-copy baseline') | ||
| 183 | # A real pane geometry change makes terminal screen coordinates ambiguous. | ||
| 184 | state = rig.state() | ||
| 185 | rig.drag('beside', dx=-2 * state['cell_w']) | ||
| 186 | state = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') is not None) | ||
| 187 | rig.unchanged(stable) | ||
| 188 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 189 | rig.copied('COPY-SENTINEL') | ||
| 190 | rig.key('copy') | ||
| 191 | rig.unchanged('COPY-SENTINEL') | ||
| 192 | after = row_of(state, target, 'ALT-AFTER-006') | ||
| 193 | start = None if after is None else after - 1 | ||
| 194 | require(start is not None and start + 1 < by_id(state)[target]['rows'], | ||
| 195 | 'resized alt fixture lost its selection markers') | ||
| 196 | background = cell_background(rig, state, target, 2, start) | ||
| 197 | local_shift_select(rig, target, (0, start), (14, start + 1)) | ||
| 198 | rig.copied(stable) | ||
| 199 | eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background, | ||
| 200 | 'selection could not be recreated after resize') | ||
| 201 | # RIS resets the terminal's pages and makes Ghostty's tracked pins garbage. | ||
| 202 | with control.open('a') as out: | ||
| 203 | out.write('reset\n') | ||
| 204 | rig.wait_state(lambda s: row_of(s, target, 'ALT-DUPLICATE') is None) | ||
| 205 | rig.unchanged(stable) | ||
| 206 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 207 | rig.copied('COPY-SENTINEL') | ||
| 208 | rig.key('copy') | ||
| 209 | rig.unchanged('COPY-SENTINEL') | ||
| 210 | rig.ok('resize and terminal reset invalidate a followed selection without changing the desktop clipboard') | ||
| 211 | |||
| 212 | |||
| 213 | def exercise(rig): | ||
| 214 | refs = start_persistent(rig) | ||
| 215 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 216 | panes = list(refs) | ||
| 217 | target, neighbour = panes[1], panes[2] | ||
| 218 | shell_primary_scroll(rig, target, neighbour) | ||
| 219 | control = alt_region_scroll(rig, target, neighbour) | ||
| 220 | invalidation(rig, target, control, neighbour) | ||
| 221 | rig.assert_cli_untouched() | ||
| 222 | |||
| 223 | |||
| 224 | def main(): | ||
| 225 | require(len(sys.argv) == 3, 'usage: native_selection_follow.py MUX MUXG') | ||
| 226 | rig = SelectionRig(*sys.argv[1:]) | ||
| 227 | try: | ||
| 228 | exercise(rig) | ||
| 229 | rig.quit() | ||
| 230 | print('Native selection-follow acceptance passed; artifacts:', rig.root, flush=True) | ||
| 231 | except BaseException: | ||
| 232 | rig.failure_artifacts() | ||
| 233 | raise | ||
| 234 | finally: | ||
| 235 | rig.close() | ||
| 236 | |||
| 237 | |||
| 238 | if __name__ == '__main__': | ||
| 239 | main() | ||
test/native_tmux_mouse.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,180 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Wayland-only mouse selection shared by muxg and a real foot tmux client.""" | ||
| 3 | import os | ||
| 4 | import shlex | ||
| 5 | import shutil | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | |||
| 10 | sys.dont_write_bytecode = True | ||
| 11 | from native_resize import by_id | ||
| 12 | from native_selection import SelectionRig, cell_background | ||
| 13 | from native_tiling import eventually, require | ||
| 14 | from wayland_pointer import Pointer | ||
| 15 | |||
| 16 | |||
| 17 | def tmux(rig, socket, *args, check=True): | ||
| 18 | return subprocess.run(['tmux', '-S', str(socket), *args], env=rig.env, | ||
| 19 | capture_output=True, text=True, timeout=3, check=check) | ||
| 20 | |||
| 21 | |||
| 22 | def find_pid(tree, pid): | ||
| 23 | if tree.get('pid') == pid: | ||
| 24 | return tree | ||
| 25 | for node in tree.get('nodes', []) + tree.get('floating_nodes', []): | ||
| 26 | found = find_pid(node, pid) | ||
| 27 | if found: | ||
| 28 | return found | ||
| 29 | return None | ||
| 30 | |||
| 31 | |||
| 32 | def foot_point(pointer, pid, cols, rows, col, row): | ||
| 33 | node = find_pid(pointer.query('get_tree'), pid) | ||
| 34 | require(node is not None, 'foot window is not mapped') | ||
| 35 | rect = node['rect'] | ||
| 36 | # The fixture removes Sway decorations and foot padding, so the terminal | ||
| 37 | # grid starts at the client origin. Keep well away from either edge. | ||
| 38 | return ((col + .5) * rect['width'] / cols, | ||
| 39 | (row + .5) * rect['height'] / rows) | ||
| 40 | |||
| 41 | |||
| 42 | def pane_mode(rig, socket, target): | ||
| 43 | return tmux(rig, socket, 'display-message', '-p', '-t', target, | ||
| 44 | '#{pane_in_mode}').stdout.strip() | ||
| 45 | |||
| 46 | |||
| 47 | def buffer(rig, socket): | ||
| 48 | return tmux(rig, socket, 'show-buffer', check=False).stdout | ||
| 49 | |||
| 50 | |||
| 51 | def leave_copy_mode(rig, socket, target): | ||
| 52 | tmux(rig, socket, 'send-keys', '-t', target, '-X', 'cancel', check=False) | ||
| 53 | eventually(lambda: pane_mode(rig, socket, target) == '0', 'tmux did not leave copy mode') | ||
| 54 | |||
| 55 | |||
| 56 | def write_tmux_config(root): | ||
| 57 | config = root / 'tmux.conf' | ||
| 58 | config.write_text( | ||
| 59 | 'set -g mouse on\n' | ||
| 60 | 'set -g status off\n' | ||
| 61 | 'set -g set-clipboard on\n' | ||
| 62 | 'set -g mode-keys vi\n' | ||
| 63 | 'bind -T copy-mode MouseDragEnd1Pane send -X copy-selection-no-clear\n' | ||
| 64 | 'bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-no-clear\n') | ||
| 65 | return config | ||
| 66 | |||
| 67 | |||
| 68 | def run(mux, muxg): | ||
| 69 | if os.environ.get('MUXG_VIDEODRIVER') != 'wayland': | ||
| 70 | print('Native tmux mouse skipped: requires MUXG_VIDEODRIVER=wayland', flush=True) | ||
| 71 | return | ||
| 72 | require(shutil.which('foot') and shutil.which('tmux'), 'native tmux mouse requires foot and tmux') | ||
| 73 | pointer_binary = os.environ.get('MUXG_TEST_POINTER') | ||
| 74 | require(pointer_binary, 'native tmux mouse requires MUXG_TEST_POINTER') | ||
| 75 | |||
| 76 | rig = SelectionRig(mux, muxg) | ||
| 77 | foot = None | ||
| 78 | foot_pointer = None | ||
| 79 | socket = rig.root / 'shared.tmux.sock' | ||
| 80 | target = 'shared:0.0' | ||
| 81 | try: | ||
| 82 | sock, _ = rig.daemon('tmux') | ||
| 83 | state = rig.launch_gui(['--sock', sock, '--session', 'tmux'], 'gui') | ||
| 84 | pane = state['panes'][0]['id'] | ||
| 85 | config = write_tmux_config(rig.root) | ||
| 86 | paint = ('printf "\\033[2J\\033[HROW-000 alpha alpha\\nROW-001 bravo bravo\\n' | ||
| 87 | 'ROW-002 charlie charlie\\n"; exec sh') | ||
| 88 | command = ('exec tmux -S ' + shlex.quote(str(socket)) + ' -f ' + shlex.quote(str(config)) + | ||
| 89 | ' new-session -A -s shared ' + shlex.quote(paint)) | ||
| 90 | rig.shell(command) | ||
| 91 | state = rig.wait_state(lambda s: 'ROW-002 charlie charlie' in by_id(s)[pane]['painted_text']) | ||
| 92 | eventually(lambda: 'ROW-001 bravo bravo' in tmux(rig, socket, 'capture-pane', '-p', '-e', '-t', target).stdout, | ||
| 93 | 'tmux pane never painted the specimen') | ||
| 94 | |||
| 95 | # This is a second real terminal client of the exact same tmux server. | ||
| 96 | foot_config = rig.root / 'foot.ini' | ||
| 97 | foot_config.write_text('[main]\npad=0x0\nfont=monospace:size=12\n') | ||
| 98 | foot = rig.spawn(['foot', '-c', str(foot_config), '-a', 'muxg-tmux-mouse', | ||
| 99 | '-T', 'muxg-tmux-mouse', '-w', '960x600', | ||
| 100 | 'tmux', '-S', str(socket), 'attach', '-t', 'shared'], 'foot') | ||
| 101 | foot_pointer = Pointer(pointer_binary, rig.env, foot.pid) | ||
| 102 | def foot_ready(): | ||
| 103 | node = find_pid(foot_pointer.query('get_tree'), foot.pid) | ||
| 104 | clients = tmux(rig, socket, 'list-clients', '-F', '#{client_tty}').stdout.splitlines() | ||
| 105 | return node is not None and len(clients) >= 2 | ||
| 106 | eventually(foot_ready, 'foot did not attach as a second tmux client', seconds=10) | ||
| 107 | subprocess.run(['swaymsg', f'[pid={foot.pid}] floating enable, border none'], env=rig.env, | ||
| 108 | capture_output=True, check=True, timeout=3) | ||
| 109 | |||
| 110 | # A normal muxg drag reaches tmux's mouse binding. Its copy-mode | ||
| 111 | # selection remains visible in muxg and becomes the actual tmux buffer. | ||
| 112 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 113 | state = rig.state() | ||
| 114 | before = cell_background(rig, state, pane, 10, 0) | ||
| 115 | rig.select(pane, (8, 0), (12, 0)) | ||
| 116 | eventually(lambda: pane_mode(rig, socket, target) == '1', 'muxg drag did not enter tmux copy mode') | ||
| 117 | eventually(lambda: 'alpha' in buffer(rig, socket), 'muxg drag did not populate tmux buffer') | ||
| 118 | eventually(lambda: cell_background(rig, state, pane, 10, 0) != before, | ||
| 119 | 'tmux selection was not visibly painted by muxg') | ||
| 120 | rig.ok('ordinary muxg drag enters tmux copy mode, paints selection and copies its buffer') | ||
| 121 | |||
| 122 | leave_copy_mode(rig, socket, target) | ||
| 123 | state = rig.state() | ||
| 124 | before = cell_background(rig, state, pane, 10, 1) | ||
| 125 | subprocess.run(['swaymsg', f'[pid={foot.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 126 | clients = tmux(rig, socket, 'list-clients', '-F', '#{client_termname} #{client_width} #{client_height}').stdout.splitlines() | ||
| 127 | cols, rows = next(tuple(map(int, line.split()[1:])) for line in clients if line.startswith('foot ')) | ||
| 128 | a = foot_point(foot_pointer, foot.pid, cols, rows, 8, 1) | ||
| 129 | b = foot_point(foot_pointer, foot.pid, cols, rows, 12, 1) | ||
| 130 | foot_pointer.event('mousedown', *a) | ||
| 131 | foot_pointer.event('mousemove', *b) | ||
| 132 | foot_pointer.event('mouseup', *b) | ||
| 133 | eventually(lambda: pane_mode(rig, socket, target) == '1', 'foot drag did not enter tmux copy mode') | ||
| 134 | eventually(lambda: 'bravo' in buffer(rig, socket), 'foot drag did not populate shared tmux buffer') | ||
| 135 | eventually(lambda: cell_background(rig, state, pane, 10, 1) != before, | ||
| 136 | 'foot tmux selection did not repaint muxg') | ||
| 137 | rig.ok('ordinary foot drag shares tmux selection state and muxg repaint') | ||
| 138 | |||
| 139 | leave_copy_mode(rig, socket, target) | ||
| 140 | prior = buffer(rig, socket) | ||
| 141 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] focus'], env=rig.env, capture_output=True, check=True) | ||
| 142 | state = rig.state() | ||
| 143 | before = cell_background(rig, state, pane, 10, 0) | ||
| 144 | a = rig.cell_point(state, pane, 8, 0) | ||
| 145 | b = rig.cell_point(state, pane, 12, 0) | ||
| 146 | rig.send('mouse:down,' + a + ',0,1', 'mouse:move,' + b + ',0,0', | ||
| 147 | 'mouse:up,' + b + ',0,0') | ||
| 148 | eventually(lambda: cell_background(rig, state, pane, 10, 0) != before, | ||
| 149 | 'Shift muxg drag did not paint its local selection') | ||
| 150 | rig.copied('alpha') | ||
| 151 | time.sleep(.15) | ||
| 152 | require(pane_mode(rig, socket, target) == '0' and buffer(rig, socket) == prior, | ||
| 153 | 'Shift muxg drag changed tmux selection or buffer') | ||
| 154 | rig.ok('Shift muxg drag remains local and leaves shared tmux buffer unchanged') | ||
| 155 | rig.quit() | ||
| 156 | print('Native tmux mouse acceptance passed; artifacts:', rig.root, flush=True) | ||
| 157 | except BaseException: | ||
| 158 | rig.failure_artifacts() | ||
| 159 | raise | ||
| 160 | finally: | ||
| 161 | if foot_pointer is not None: | ||
| 162 | foot_pointer.close() | ||
| 163 | if foot is not None and foot.poll() is None: | ||
| 164 | foot.terminate() | ||
| 165 | try: | ||
| 166 | foot.wait(timeout=3) | ||
| 167 | except subprocess.TimeoutExpired: | ||
| 168 | foot.kill() | ||
| 169 | foot.wait(timeout=3) | ||
| 170 | tmux(rig, socket, 'kill-server', check=False) | ||
| 171 | rig.close() | ||
| 172 | |||
| 173 | |||
| 174 | def main(): | ||
| 175 | require(len(sys.argv) == 3, 'usage: native_tmux_mouse.py MUX MUXG') | ||
| 176 | run(*sys.argv[1:]) | ||
| 177 | |||
| 178 | |||
| 179 | if __name__ == '__main__': | ||
| 180 | main() | ||
test/native_wheel.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,316 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Wheel input through real SDL/Wayland, history pixels and independent PTY bytes.""" | ||
| 3 | import os | ||
| 4 | from pathlib import Path | ||
| 5 | import shlex | ||
| 6 | import subprocess | ||
| 7 | import sys | ||
| 8 | import time | ||
| 9 | |||
| 10 | sys.dont_write_bytecode = True | ||
| 11 | from native_lifecycle import start_persistent | ||
| 12 | from native_resize import by_id, one_divider | ||
| 13 | from native_selection import SelectionRig, cell_background | ||
| 14 | from native_tiling import eventually, require | ||
| 15 | |||
| 16 | |||
| 17 | class WheelRig(SelectionRig): | ||
| 18 | def wheel_at(self, point, delta, flipped=False, native=True): | ||
| 19 | if self.env['SDL_VIDEO_DRIVER'] == 'wayland' and native and not flipped and int(delta) == delta: | ||
| 20 | # Initialize the established compositor input adapter without a click. | ||
| 21 | self.send('mousemove:' + point) | ||
| 22 | self.pointer.wheel(*map(float, point.split(',')), int(delta)) | ||
| 23 | else: | ||
| 24 | self.send(f'wheel:{point},{delta}' + (',flipped' if flipped else '')) | ||
| 25 | |||
| 26 | def wheel(self, pane, delta, col=4, row=3, **kwargs): | ||
| 27 | state = self.state() | ||
| 28 | self.wheel_at(self.cell_point(state, pane, col, row), delta, **kwargs) | ||
| 29 | |||
| 30 | |||
| 31 | def painted(rig, pane): | ||
| 32 | return by_id(rig.state())[pane]['painted_text'] | ||
| 33 | |||
| 34 | |||
| 35 | def history(rig, pane): | ||
| 36 | rig.focus(pane) | ||
| 37 | # Known numbered rows span many screens; full markers occur only in output. | ||
| 38 | program = rig.root / f'history-{pane}.py' | ||
| 39 | program.write_text('import sys\n' | ||
| 40 | 'sys.stdout.write("\\033[?25l\\033[2J\\033[H")\n' | ||
| 41 | 'for n in range(300): print(f"HISTORY-{n:04d} alpha café")\n' | ||
| 42 | 'print("WHEEL-" + "LIVE", flush=True)\n') | ||
| 43 | rig.shell("export PS1=''; python3 " + shlex.quote(str(program))) | ||
| 44 | rig.wait_state(lambda s: 'WHEEL-LIVE' in by_id(s)[pane]['painted_text']) | ||
| 45 | return painted(rig, pane) | ||
| 46 | |||
| 47 | |||
| 48 | def first_number(text): | ||
| 49 | first = text.splitlines()[0] | ||
| 50 | require(first.startswith('HISTORY-'), 'expected a numbered history row, got ' + repr(first)) | ||
| 51 | return int(first[8:12]) | ||
| 52 | |||
| 53 | |||
| 54 | def shell_history(rig, panes): | ||
| 55 | live = {pane: history(rig, pane) for pane in panes} | ||
| 56 | target, other, focused = panes[1], panes[0], panes[2] | ||
| 57 | rig.focus(focused) | ||
| 58 | baseline = first_number(live[target]) | ||
| 59 | rig.wheel(target, 1) | ||
| 60 | state = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 3) | ||
| 61 | require(state['focus'] == focused, 'wheel moved keyboard focus') | ||
| 62 | for pane in (other, focused): | ||
| 63 | require(by_id(state)[pane]['painted_text'] == live[pane], 'wheel changed another pane') | ||
| 64 | # Inspect completed framebuffer pixels as well as the passive text snapshot. | ||
| 65 | before_pixels = rig.last_pixels()[2] | ||
| 66 | rig.wheel(target, 1) | ||
| 67 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 6) | ||
| 68 | eventually(lambda: rig.last_pixels()[2] != before_pixels, 'history text did not change framebuffer') | ||
| 69 | rig.ok('off-origin unfocused pane scrolls three rows per notch without changing neighbours or focus') | ||
| 70 | |||
| 71 | state = rig.state() | ||
| 72 | background = cell_background(rig, state, other, 1, 0) | ||
| 73 | selected = by_id(state)[other]['painted_text'].splitlines()[0][:12] | ||
| 74 | rig.select(other, (0, 0), (11, 0)) | ||
| 75 | rig.copied(selected) | ||
| 76 | eventually(lambda: cell_background(rig, state, other, 1, 0) != background, | ||
| 77 | 'selection did not paint before scrolling a neighbour') | ||
| 78 | rig.wheel(target, 1) | ||
| 79 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 9) | ||
| 80 | require(cell_background(rig, state, other, 1, 0) != background, | ||
| 81 | 'scrolling another pane cleared the selection') | ||
| 82 | rig.unchanged(selected) | ||
| 83 | rig.ok('scrolling a neighbouring pane preserves selected text and its highlight') | ||
| 84 | |||
| 85 | rig.wheel(target, -1000) | ||
| 86 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 87 | rig.wheel(target, .5, native=False) | ||
| 88 | rig.wheel(other, .5, native=False) | ||
| 89 | require(painted(rig, target) == live[target] and painted(rig, other) == live[other], | ||
| 90 | 'fractional notches leaked between panes') | ||
| 91 | rig.wheel(target, .5, native=False) | ||
| 92 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 3) | ||
| 93 | require(painted(rig, other) == live[other], 'target consumed another pane remainder') | ||
| 94 | rig.wheel(other, -.5, native=False) # cancel its remainder | ||
| 95 | rig.wheel(target, 1, flipped=True) | ||
| 96 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 97 | rig.ok('fractional wheel events accumulate per pane and flipped direction returns to live') | ||
| 98 | |||
| 99 | rig.wheel(target, 1000) | ||
| 100 | oldest = rig.wait_state(lambda s: 'HISTORY-0000' in by_id(s)[target]['painted_text']) | ||
| 101 | oldest_text = by_id(oldest)[target]['painted_text'] | ||
| 102 | rig.wheel(target, 1000) | ||
| 103 | require(painted(rig, target) == oldest_text, 'wheel moved beyond oldest history') | ||
| 104 | rig.wheel(target, -1000) | ||
| 105 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 106 | rig.wheel(target, 4) | ||
| 107 | state = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 12) | ||
| 108 | first = by_id(state)[target]['painted_text'].splitlines()[0] | ||
| 109 | background = cell_background(rig, state, target, 1, 0) | ||
| 110 | rig.select(target, (0, 0), (11, 0)) | ||
| 111 | rig.copied(first[:12]) | ||
| 112 | eventually(lambda: cell_background(rig, state, target, 1, 0) != background, | ||
| 113 | 'history selection did not paint') | ||
| 114 | rig.wheel(target, 1) | ||
| 115 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15) | ||
| 116 | eventually(lambda: cell_background(rig, state, target, 1, 3) != background and | ||
| 117 | cell_background(rig, state, target, 1, 0) == background, | ||
| 118 | 'highlight did not follow its text three rows down') | ||
| 119 | rig.wheel(target, 1000) | ||
| 120 | rig.wait_state(lambda s: 'HISTORY-0000' in by_id(s)[target]['painted_text']) | ||
| 121 | eventually(lambda: all(cell_background(rig, state, target, 1, row) == background | ||
| 122 | for row in range(by_id(state)[target]['rows'])), | ||
| 123 | 'offscreen selection left a highlight in the viewport') | ||
| 124 | rig.wheel(target, -1000) | ||
| 125 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 126 | rig.wheel(target, 4) | ||
| 127 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 12) | ||
| 128 | eventually(lambda: cell_background(rig, state, target, 1, 0) != background, | ||
| 129 | 'highlight did not return with selected history text') | ||
| 130 | rig.key('copy') | ||
| 131 | rig.unchanged(first[:12]) | ||
| 132 | rig.ok('released history highlight follows text, survives offscreen/live round trips and remains copyable') | ||
| 133 | |||
| 134 | # Keep the original anchor while a held drag moves through history. Release | ||
| 135 | # at its new screen row to copy the same source row, then repeat with an | ||
| 136 | # explicit motion to extend the selection across the newly visible rows. | ||
| 137 | held = by_id(rig.state())[target]['painted_text'].splitlines()[1] | ||
| 138 | rig.select(target, (0, 1), (11, 1), release=False) | ||
| 139 | rig.wheel(target, 1, col=11, row=1) | ||
| 140 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15) | ||
| 141 | rig.send('mouseup:' + rig.cell_point(state, target, 11, 4)) | ||
| 142 | rig.copied(held[:12]) | ||
| 143 | rig.select(target, (0, 0), (11, 0), release=False) | ||
| 144 | rig.wheel(target, 1, col=11, row=0) | ||
| 145 | moved = rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 18) | ||
| 146 | lines = by_id(moved)[target]['painted_text'].splitlines() | ||
| 147 | rig.send('mousemove:' + rig.cell_point(moved, target, 11, 4)) | ||
| 148 | rig.key('copy') | ||
| 149 | rig.copied(lines[3] + '\n' + lines[4][:12]) | ||
| 150 | rig.send('mouseup:' + rig.cell_point(moved, target, 11, 4)) | ||
| 151 | rig.copied(lines[3] + '\n' + lines[4][:12]) | ||
| 152 | rig.ok('held drag retains its text anchor and extends using the scrolled pane coordinates') | ||
| 153 | |||
| 154 | rig.wheel(target, -1000) | ||
| 155 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 156 | rig.select(target, (0, 0), (11, 0)) | ||
| 157 | rig.copied(live[target].splitlines()[0][:12]) | ||
| 158 | rig.wheel(target, 1) | ||
| 159 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 3) | ||
| 160 | eventually(lambda: cell_background(rig, state, target, 1, 3) != background, | ||
| 161 | 'live selection disappeared on entering history') | ||
| 162 | rig.wheel(target, -1) | ||
| 163 | rig.wait_state(lambda s: by_id(s)[target]['painted_text'] == live[target]) | ||
| 164 | eventually(lambda: cell_background(rig, state, target, 1, 0) != background, | ||
| 165 | 'live selection disappeared on returning from history') | ||
| 166 | rig.shell("printf 'RETURN-%s\\n' LIVE") | ||
| 167 | rig.wait_state(lambda s: 'RETURN-LIVE' in by_id(s)[target]['painted_text']) | ||
| 168 | eventually(lambda: cell_background(rig, state, target, 1, 0) == background, | ||
| 169 | 'input failed to clear the selection') | ||
| 170 | rig.ok('live selection survives history navigation; typing returns live and clears it') | ||
| 171 | |||
| 172 | |||
| 173 | def raw_reader(rig, pane): | ||
| 174 | """A real foreground PTY application changes modes and records all received bytes.""" | ||
| 175 | rig.focus(pane) | ||
| 176 | control, received, stopped = (rig.root / f'{name}-{pane}' for name in ('mode', 'received', 'stop')) | ||
| 177 | program = rig.root / f'raw-reader-{pane}.py' | ||
| 178 | program.write_text( | ||
| 179 | 'import os, select, termios, tty\nfrom pathlib import Path\n' | ||
| 180 | f'control=Path({str(control)!r}); received=Path({str(received)!r}); stop=Path({str(stopped)!r})\n' | ||
| 181 | 'old=termios.tcgetattr(0); tty.setraw(0); previous=None\n' | ||
| 182 | 'reset="\\033[?9l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1016l\\033[?1l\\033[?1049l"\n' | ||
| 183 | 'try:\n' | ||
| 184 | ' with received.open("wb", buffering=0) as output:\n' | ||
| 185 | ' while not stop.exists():\n' | ||
| 186 | ' current=control.read_text() if control.exists() else "ready|"\n' | ||
| 187 | ' if current != previous:\n' | ||
| 188 | ' tag, modes=current.split("|", 1)\n' | ||
| 189 | ' os.write(1, (reset+modes+"\\033[2J\\033[HMODE-"+tag+"\\r\\n").encode()); previous=current\n' | ||
| 190 | ' if select.select([0], [], [], .02)[0]: output.write(os.read(0, 4096))\n' | ||
| 191 | 'finally:\n' | ||
| 192 | ' os.write(1, reset.encode()); termios.tcsetattr(0, termios.TCSANOW, old)\n') | ||
| 193 | rig.shell('python3 ' + shlex.quote(str(program))) | ||
| 194 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-ready')) | ||
| 195 | return control, received, stopped | ||
| 196 | |||
| 197 | |||
| 198 | def application_wheel(rig, panes): | ||
| 199 | pane, focus = panes[1], panes[2] | ||
| 200 | control, received, stopped = raw_reader(rig, pane) | ||
| 201 | def set_mode(value): | ||
| 202 | pending = control.with_suffix('.next') | ||
| 203 | pending.write_text(value) | ||
| 204 | pending.replace(control) | ||
| 205 | rig.focus(focus) | ||
| 206 | offset = 0 | ||
| 207 | try: | ||
| 208 | cases = [('arrows', '\033[?1049h', b'\033[A' * 3, b'\033[B' * 3), | ||
| 209 | ('app-arrows', '\033[?1049h\033[?1h', b'\033OA' * 3, b'\033OB' * 3), | ||
| 210 | ('sgr', '\033[?1000h\033[?1006h', b'\033[<64;5;4M', b'\033[<65;5;4M'), | ||
| 211 | ('legacy', '\033[?1000h', b'\033[M`%$', b'\033[Ma%$'), | ||
| 212 | ('utf8', '\033[?1000h\033[?1005h', b'\033[M`%$', b'\033[Ma%$'), | ||
| 213 | ('urxvt', '\033[?1000h\033[?1015h', b'\033[96;5;4M', b'\033[97;5;4M')] | ||
| 214 | for label, modes, up, down in cases: | ||
| 215 | set_mode(label + '|' + modes) | ||
| 216 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-' + label)) | ||
| 217 | rig.wheel(pane, 1) | ||
| 218 | rig.wheel(pane, -1) | ||
| 219 | expected = up + down | ||
| 220 | eventually(lambda: received.stat().st_size >= offset + len(expected), label + ' PTY bytes missing') | ||
| 221 | time.sleep(.08) | ||
| 222 | actual = received.read_bytes()[offset:] | ||
| 223 | require(actual == expected, f'{label} PTY received {actual!r}, expected {expected!r}') | ||
| 224 | offset += len(expected) | ||
| 225 | require(rig.state()['focus'] == focus, 'application wheel changed keyboard focus') | ||
| 226 | rig.ok('independent PTY bytes prove alternate arrows, cursor-key mode and negotiated cell mouse formats') | ||
| 227 | |||
| 228 | set_mode('pixels|\033[?1000h\033[?1016h') | ||
| 229 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-pixels')) | ||
| 230 | scale_output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') | ||
| 231 | original = None | ||
| 232 | scales = (None,) | ||
| 233 | if scale_output: | ||
| 234 | original = next(o['scale'] for o in rig.pointer.query('get_outputs') if o['name'] == scale_output) | ||
| 235 | scales = (2, 1, 1.5, 2) | ||
| 236 | try: | ||
| 237 | for scale in scales: | ||
| 238 | if scale is not None: | ||
| 239 | subprocess.run(['swaymsg', 'output', scale_output, 'scale', str(scale)], | ||
| 240 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 241 | rig.wait_state(lambda s: abs(s['width'] / s['logical_width'] - scale) < .01) | ||
| 242 | rig.kernel_sizes() | ||
| 243 | state = rig.state() | ||
| 244 | point = rig.cell_point(state, pane, 4, 3) | ||
| 245 | x, y = map(float, point.split(',')) | ||
| 246 | if rig.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 247 | x, y = int(x), int(y) # virtual pointer uses logical integer coordinates | ||
| 248 | content = by_id(state)[pane]['content'] | ||
| 249 | px = int(x * state['width'] / state['logical_width']) - content['x'] + 1 | ||
| 250 | py = int(y * state['height'] / state['logical_height']) - content['y'] + 1 | ||
| 251 | rig.wheel_at(point, 1) | ||
| 252 | expected = f'\033[<64;{px};{py}M'.encode() | ||
| 253 | eventually(lambda: received.stat().st_size >= offset + len(expected), 'pixel report missing') | ||
| 254 | require(received.read_bytes()[offset:] == expected, 'pixel report not relative to pane at current DPI') | ||
| 255 | offset += len(expected) | ||
| 256 | finally: | ||
| 257 | if original is not None: | ||
| 258 | subprocess.run(['swaymsg', 'output', scale_output, 'scale', str(original)], | ||
| 259 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 260 | rig.ok('SGR pixel coordinates are pane-relative, including configured Wayland scale transitions') | ||
| 261 | |||
| 262 | rig.key('prefix') | ||
| 263 | rig.wheel(pane, 1) | ||
| 264 | time.sleep(.1) | ||
| 265 | require(received.stat().st_size == offset, 'command prefix leaked wheel input') | ||
| 266 | rig.key('escape') | ||
| 267 | rig.chord('enter') | ||
| 268 | rig.picker() | ||
| 269 | rig.wheel(pane, 1) | ||
| 270 | time.sleep(.1) | ||
| 271 | require(received.stat().st_size == offset, 'picker leaked wheel input') | ||
| 272 | rig.key('escape') | ||
| 273 | rig.wait_state(lambda s: not s.get('picker')) | ||
| 274 | rig.chord('p') | ||
| 275 | rig.wait_state(lambda s: s.get('recovery')) | ||
| 276 | rig.wheel(pane, 1) | ||
| 277 | time.sleep(.1) | ||
| 278 | require(received.stat().st_size == offset, 'recovery menu leaked wheel input') | ||
| 279 | rig.key('escape') | ||
| 280 | state = rig.wait_state(lambda s: not s.get('recovery')) | ||
| 281 | header = by_id(state)[pane]['header'] | ||
| 282 | rig.wheel_at(rig.point(state, header['x'] + header['w']/2, header['y'] + header['h']/2), 1) | ||
| 283 | rect = one_divider(state, 'beside')['rect'] | ||
| 284 | point = rig.point(state, rect['x'] + rect['w']/2, rect['y'] + rect['h']/4) | ||
| 285 | rig.wheel_at(point, 1) | ||
| 286 | rig.send('mousedown:' + point) | ||
| 287 | rig.wheel(pane, 1) | ||
| 288 | rig.send('mouseup:' + point) | ||
| 289 | time.sleep(.1) | ||
| 290 | require(received.stat().st_size == offset, 'header/divider/resize leaked wheel input') | ||
| 291 | rig.ok('picker, recovery, headers, dividers and held resize intercept wheel events') | ||
| 292 | finally: | ||
| 293 | stopped.touch() | ||
| 294 | |||
| 295 | |||
| 296 | def main(): | ||
| 297 | rig = WheelRig(*sys.argv[1:3]) | ||
| 298 | try: | ||
| 299 | refs = start_persistent(rig) | ||
| 300 | panes = list(refs) | ||
| 301 | shell_history(rig, panes) | ||
| 302 | application_wheel(rig, panes) | ||
| 303 | rig.kernel_sizes() | ||
| 304 | rig.assert_cli_untouched() | ||
| 305 | rig.quit() | ||
| 306 | rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state') | ||
| 307 | print('Wheel artifacts:', rig.root) | ||
| 308 | except Exception: | ||
| 309 | rig.failure_artifacts() | ||
| 310 | raise | ||
| 311 | finally: | ||
| 312 | rig.close() | ||
| 313 | |||
| 314 | |||
| 315 | if __name__ == '__main__': | ||
| 316 | main() | ||
test/wayland_pointer.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,69 @@ | |||
| 1 | """Drive the retained virtual-pointer helper on an isolated Sway output. | ||
| 2 | |||
| 3 | MUXG_TEST_POINTER names the helper binary (line protocol: move x y w h, | ||
| 4 | button 0/1, wheel NOTCHES; each command returns ok). A real input serial is required for | ||
| 5 | Wayland clipboard ownership; SDL-injected events cannot establish it. | ||
| 6 | """ | ||
| 7 | import json | ||
| 8 | import select | ||
| 9 | import subprocess | ||
| 10 | |||
| 11 | |||
| 12 | class Pointer: | ||
| 13 | def __init__(self, binary, env, pid): | ||
| 14 | self.env, self.pid = env, pid | ||
| 15 | self.proc = subprocess.Popen([binary], env=env, stdin=subprocess.PIPE, | ||
| 16 | stdout=subprocess.PIPE, text=True) | ||
| 17 | |||
| 18 | def command(self, line): | ||
| 19 | self.proc.stdin.write(line + '\n') | ||
| 20 | self.proc.stdin.flush() | ||
| 21 | if not select.select([self.proc.stdout], [], [], 3)[0]: | ||
| 22 | raise RuntimeError('virtual pointer command timed out') | ||
| 23 | if self.proc.stdout.readline().strip() != 'ok': | ||
| 24 | raise RuntimeError('virtual pointer command failed') | ||
| 25 | |||
| 26 | def query(self, kind): | ||
| 27 | return json.loads(subprocess.check_output(['swaymsg', '-r', '-t', kind], | ||
| 28 | env=self.env, timeout=3)) | ||
| 29 | |||
| 30 | def move(self, x, y): | ||
| 31 | def find(node): | ||
| 32 | if node.get('pid') == self.pid: | ||
| 33 | return node | ||
| 34 | for child in node.get('nodes', []) + node.get('floating_nodes', []): | ||
| 35 | found = find(child) | ||
| 36 | if found: | ||
| 37 | return found | ||
| 38 | node = find(self.query('get_tree')) | ||
| 39 | if node is None: | ||
| 40 | raise RuntimeError('owned native window is not mapped') | ||
| 41 | outputs = self.query('get_outputs') | ||
| 42 | if len(outputs) != 1 or not outputs[0]['name'].startswith('HEADLESS-'): | ||
| 43 | raise RuntimeError('pointer fixture requires one isolated headless output') | ||
| 44 | rect, screen = node['rect'], outputs[0]['rect'] | ||
| 45 | self.command(f"move {int(rect['x'] + x - screen['x'])} " | ||
| 46 | f"{int(rect['y'] + y - screen['y'])} {screen['width']} {screen['height']}") | ||
| 47 | |||
| 48 | def event(self, kind, x, y): | ||
| 49 | self.move(x, y) | ||
| 50 | if kind in ('mousedown', 'click'): | ||
| 51 | self.command('button 1') | ||
| 52 | if kind in ('mouseup', 'click'): | ||
| 53 | self.command('button 0') | ||
| 54 | |||
| 55 | def wheel(self, x, y, notches): | ||
| 56 | self.move(x, y) | ||
| 57 | self.command(f'wheel {notches}') | ||
| 58 | |||
| 59 | def close(self): | ||
| 60 | self.proc.stdin.close() | ||
| 61 | try: | ||
| 62 | if self.proc.wait(timeout=3): | ||
| 63 | raise RuntimeError('virtual pointer failed on shutdown') | ||
| 64 | except subprocess.TimeoutExpired: | ||
| 65 | self.proc.kill() | ||
| 66 | self.proc.wait(timeout=3) | ||
| 67 | raise | ||
| 68 | finally: | ||
| 69 | self.proc.stdout.close() | ||
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -1158,7 +1158,7 @@ class Tile { | |||
| 1158 | const n = this.core.mux_selection_request( | 1158 | const n = this.core.mux_selection_request( |
| 1159 | this.nextSelectionId, a.row, a.col, b.row, b.col, | 1159 | this.nextSelectionId, a.row, a.col, b.row, b.col, |
| 1160 | ); | 1160 | ); |
| 1161 | if (n === 16) { | 1161 | if (n > 0) { |
| 1162 | const selection = this.selection; | 1162 | const selection = this.selection; |
| 1163 | // Establish reply authority before send: a host is allowed to deliver | 1163 | // Establish reply authority before send: a host is allowed to deliver |
| 1164 | // a matching semantic reply synchronously from its send hook. | 1164 | // a matching semantic reply synchronously from its send hook. |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -894,7 +894,7 @@ async function verifySelectionShell(shell, html) { | |||
| 894 | const makeTile = () => { | 894 | const makeTile = () => { |
| 895 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); | 895 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); |
| 896 | const memory = { buffer: new ArrayBuffer(1024) }; | 896 | const memory = { buffer: new ArrayBuffer(1024) }; |
| 897 | let requestResult = 16; | 897 | let requestResult = 37; |
| 898 | let scrollFeedResult = 0; | 898 | let scrollFeedResult = 0; |
| 899 | let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 }; | 899 | let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 }; |
| 900 | const requestCalls = []; | 900 | const requestCalls = []; |
| @@ -913,7 +913,7 @@ async function verifySelectionShell(shell, html) { | |||
| 913 | mux_input_cap: () => 1024, | 913 | mux_input_cap: () => 1024, |
| 914 | mux_input_ptr: () => 0, | 914 | mux_input_ptr: () => 0, |
| 915 | mux_output_ptr: () => 256, | 915 | mux_output_ptr: () => 256, |
| 916 | mux_output_len: () => requestResult === 16 ? 16 : 0, | 916 | mux_output_len: () => requestResult === 37 ? 37 : 0, |
| 917 | mux_scroll_start: (pages, rows) => 30 - pages * rows, | 917 | mux_scroll_start: (pages, rows) => 30 - pages * rows, |
| 918 | mux_scroll_feed: () => scrollFeedResult, | 918 | mux_scroll_feed: () => scrollFeedResult, |
| 919 | mux_init: () => 0, | 919 | mux_init: () => 0, |
| @@ -927,14 +927,14 @@ async function verifySelectionShell(shell, html) { | |||
| 927 | mux_selection_len: () => result.len, | 927 | mux_selection_len: () => result.len, |
| 928 | mux_selection_request: (id, ar, ac, br, bc) => { | 928 | mux_selection_request: (id, ar, ac, br, bc) => { |
| 929 | requestCalls.push([id >>> 0, ar, ac, br, bc]); | 929 | requestCalls.push([id >>> 0, ar, ac, br, bc]); |
| 930 | if (requestResult !== 16) return requestResult; | 930 | if (requestResult !== 37) return requestResult; |
| 931 | const view = new DataView(memory.buffer, 256, 16); | 931 | const view = new DataView(memory.buffer, 256, 37); |
| 932 | view.setUint32(0, id, true); | 932 | view.setUint32(0, id, true); |
| 933 | view.setUint32(4, ar, true); | 933 | view.setUint32(4, ar, true); |
| 934 | view.setUint16(8, ac, true); | 934 | view.setUint16(8, ac, true); |
| 935 | view.setUint32(10, br, true); | 935 | view.setUint32(10, br, true); |
| 936 | view.setUint16(14, bc, true); | 936 | view.setUint16(14, bc, true); |
| 937 | return 16; | 937 | return 37; |
| 938 | }, | 938 | }, |
| 939 | }; | 939 | }; |
| 940 | selected.zoomed = true; | 940 | selected.zoomed = true; |
| @@ -1258,9 +1258,9 @@ async function verifySelectionShell(shell, html) { | |||
| 1258 | JSON.stringify([[1, 31, 2, 33, 5]]), | 1258 | JSON.stringify([[1, 31, 2, 33, 5]]), |
| 1259 | ); | 1259 | ); |
| 1260 | check( | 1260 | check( |
| 1261 | 'drag release sends the exact 16-byte request', | 1261 | 'drag release sends the exact 37-byte request', |
| 1262 | Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), | 1262 | Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), |
| 1263 | '010000001f0000000200210000000500', | 1263 | '010000001f0000000200210000000500000000000000000000000000000000000000000000', |
| 1264 | ); | 1264 | ); |
| 1265 | 1265 | ||
| 1266 | const reverse = makeTile(); | 1266 | const reverse = makeTile(); |
| @@ -3139,11 +3139,11 @@ async function main() { | |||
| 3139 | 3139 | ||
| 3140 | const selectionReply = (id, status, text = '', historyRows = 0) => { | 3140 | const selectionReply = (id, status, text = '', historyRows = 0) => { |
| 3141 | const body = Buffer.from(text, 'utf8'); | 3141 | const body = Buffer.from(text, 'utf8'); |
| 3142 | const reply = Buffer.alloc(9 + body.length); | 3142 | const reply = Buffer.alloc(41 + body.length); |
| 3143 | reply.writeUInt32LE(id, 0); | 3143 | reply.writeUInt32LE(id, 0); |
| 3144 | reply[4] = status; | 3144 | reply[4] = status; |
| 3145 | reply.writeUInt32LE(historyRows, 5); | 3145 | reply.writeUInt32LE(historyRows, 5); |
| 3146 | body.copy(reply, 9); | 3146 | body.copy(reply, 41); |
| 3147 | return reply; | 3147 | return reply; |
| 3148 | }; | 3148 | }; |
| 3149 | const selectionText = () => Buffer.from(mem().subarray( | 3149 | const selectionText = () => Buffer.from(mem().subarray( |
| @@ -3151,12 +3151,12 @@ async function main() { | |||
| 3151 | e.mux_selection_ptr() + e.mux_selection_len(), | 3151 | e.mux_selection_ptr() + e.mux_selection_len(), |
| 3152 | )); | 3152 | )); |
| 3153 | const firstSelectionId = 0x78563412; | 3153 | const firstSelectionId = 0x78563412; |
| 3154 | check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 16); | 3154 | check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 37); |
| 3155 | check('selection request output len', e.mux_output_len(), 16); | 3155 | check('selection request output len', e.mux_output_len(), 37); |
| 3156 | check( | 3156 | check( |
| 3157 | 'selection request golden bytes', | 3157 | 'selection request golden bytes', |
| 3158 | outBytes().toString('hex'), | 3158 | outBytes().toString('hex'), |
| 3159 | '12345678112233445566778899aabbcc', | 3159 | '12345678112233445566778899aabbcc000000000000000000000000000000000000000000', |
| 3160 | ); | 3160 | ); |
| 3161 | check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); | 3161 | check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); |
| 3162 | check('selection invalid anchor col clears output', e.mux_output_len(), 0); | 3162 | check('selection invalid anchor col clears output', e.mux_output_len(), 0); |
| @@ -3169,13 +3169,13 @@ async function main() { | |||
| 3169 | ); | 3169 | ); |
| 3170 | 3170 | ||
| 3171 | const supersededSelectionId = 0x01020304; | 3171 | const supersededSelectionId = 0x01020304; |
| 3172 | check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 16); | 3172 | check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 37); |
| 3173 | const latestSelectionId = 0x10203040; | 3173 | const latestSelectionId = 0x10203040; |
| 3174 | check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 16); | 3174 | check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 37); |
| 3175 | check( | 3175 | check( |
| 3176 | 'selection request survives enlarged input staging capacity', | 3176 | 'selection request survives enlarged input staging capacity', |
| 3177 | outBytes().toString('hex'), | 3177 | outBytes().toString('hex'), |
| 3178 | '40302010090000000a000b0000000c00', | 3178 | '40302010090000000a000b0000000c00000000000000000000000000000000000000000000', |
| 3179 | ); | 3179 | ); |
| 3180 | check( | 3180 | check( |
| 3181 | 'selection stale reply ignored', | 3181 | 'selection stale reply ignored', |
| @@ -3206,7 +3206,7 @@ async function main() { | |||
| 3206 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); | 3206 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); |
| 3207 | 3207 | ||
| 3208 | const highSelectionId = 0xfedcba98; | 3208 | const highSelectionId = 0xfedcba98; |
| 3209 | check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 16); | 3209 | check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 37); |
| 3210 | check( | 3210 | check( |
| 3211 | 'high-bit selection reply action', | 3211 | 'high-bit selection reply action', |
| 3212 | e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), | 3212 | e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), |
| @@ -3218,7 +3218,7 @@ async function main() { | |||
| 3218 | 3218 | ||
| 3219 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { | 3219 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { |
| 3220 | const id = 100 + status; | 3220 | const id = 100 + status; |
| 3221 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | 3221 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37); |
| 3222 | check( | 3222 | check( |
| 3223 | `selection ${name} action`, | 3223 | `selection ${name} action`, |
| 3224 | e.mux_client_frame(0x90, stage(selectionReply(id, status))), | 3224 | e.mux_client_frame(0x90, stage(selectionReply(id, status))), |
| @@ -3230,7 +3230,7 @@ async function main() { | |||
| 3230 | } | 3230 | } |
| 3231 | 3231 | ||
| 3232 | const populateSelection = (id) => { | 3232 | const populateSelection = (id) => { |
| 3233 | check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | 3233 | check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37); |
| 3234 | check( | 3234 | check( |
| 3235 | `selection ${id} reply`, | 3235 | `selection ${id} reply`, |
| 3236 | e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), | 3236 | e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), |
| @@ -3251,7 +3251,7 @@ async function main() { | |||
| 3251 | check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); | 3251 | check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); |
| 3252 | check('wide type clears selection getter', e.mux_selection_len(), 0); | 3252 | check('wide type clears selection getter', e.mux_selection_len(), 0); |
| 3253 | populateSelection(205); | 3253 | populateSelection(205); |
| 3254 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); | 3254 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 37); |
| 3255 | check('valid new request clears selection getter', e.mux_selection_len(), 0); | 3255 | check('valid new request clears selection getter', e.mux_selection_len(), 0); |
| 3256 | 3256 | ||
| 3257 | populateSelection(207); | 3257 | populateSelection(207); |
| @@ -3281,7 +3281,7 @@ async function main() { | |||
| 3281 | check('invalid selection request clears exposed id', e.mux_selection_id(), 0); | 3281 | check('invalid selection request clears exposed id', e.mux_selection_id(), 0); |
| 3282 | check('invalid selection request clears exposed status', e.mux_selection_status(), 3); | 3282 | check('invalid selection request clears exposed status', e.mux_selection_status(), 3); |
| 3283 | check('invalid selection request clears exposed len', e.mux_selection_len(), 0); | 3283 | check('invalid selection request clears exposed len', e.mux_selection_len(), 0); |
| 3284 | check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 16); | 3284 | check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 37); |
| 3285 | check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); | 3285 | check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); |
| 3286 | check( | 3286 | check( |
| 3287 | 'matching reply after invalid request is accepted', | 3287 | 'matching reply after invalid request is accepted', |
| @@ -3456,7 +3456,7 @@ async function main() { | |||
| 3456 | // The staging cap is pinned to the largest selection reply: its nine-byte | 3456 | // The staging cap is pinned to the largest selection reply: its nine-byte |
| 3457 | // correlation/status/history prefix plus the protocol's one-MiB text | 3457 | // correlation/status/history prefix plus the protocol's one-MiB text |
| 3458 | // maximum. | 3458 | // maximum. |
| 3459 | check('input cap', e.mux_input_cap(), 1024 * 1024 + 9); | 3459 | check('input cap', e.mux_input_cap(), 1024 * 1024 + 41); |
| 3460 | 3460 | ||
| 3461 | // --- the shell's ACTUAL call list, read out of mux.js --- | 3461 | // --- the shell's ACTUAL call list, read out of mux.js --- |
| 3462 | // Everything above pins exports this file happens to name. This pins | 3462 | // Everything above pins exports this file happens to name. This pins |
| @@ -3711,7 +3711,7 @@ async function main() { | |||
| 3711 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); | 3711 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); |
| 3712 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); | 3712 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); |
| 3713 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); | 3713 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); |
| 3714 | check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 16); | 3714 | check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 37); |
| 3715 | check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); | 3715 | check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); |
| 3716 | check('selection populated before lifecycle reset', e.mux_selection_len(), 5); | 3716 | check('selection populated before lifecycle reset', e.mux_selection_len(), 5); |
| 3717 | e.mux_deinit(); | 3717 | e.mux_deinit(); |