a73x

ea49739c

merge: integrate gui text selection sprint

a73x   2026-09-06 13:45

Commit message
merge: integrate gui text selection sprint

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 &amp; 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 &amp; 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(&copy);
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();