eedff557
feat: scroll native panes through history and applications
a73x 2026-09-06 09:28
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -1237,3 +1237,86 @@ release and modifier changes, and prevent reports or text leaking across panes. | |||
| 1237 | The wheel plan records the remaining order: wheel/history first, application | 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 | 1238 | mouse/clipboard next. The latter slice's planner owns carrying these criteria |
| 1239 | into implementation, tests and the demo; GUI paste remains deferred. | 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. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1213,8 +1213,12 @@ pub fn build(b: *std.Build) void { | |||
| 1213 | native_selection.addArtifactArg(mux_exe); | 1213 | native_selection.addArtifactArg(mux_exe); |
| 1214 | native_selection.addArtifactArg(muxg_exe); | 1214 | native_selection.addArtifactArg(muxg_exe); |
| 1215 | native_selection.step.dependOn(&native_theme_config.step); | 1215 | native_selection.step.dependOn(&native_theme_config.step); |
| 1216 | const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" }); | ||
| 1217 | native_wheel.addArtifactArg(mux_exe); | ||
| 1218 | native_wheel.addArtifactArg(muxg_exe); | ||
| 1219 | native_wheel.step.dependOn(&native_selection.step); | ||
| 1216 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1220 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1217 | native_e2e_step.dependOn(&native_selection.step); | 1221 | native_e2e_step.dependOn(&native_wheel.step); |
| 1218 | 1222 | ||
| 1219 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | 1223 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale |
| 1220 | // Debug daemon gives misleading latency numbers under raw terminal output. | 1224 | // Debug daemon gives misleading latency numbers under raw terminal output. |
docs/demos/native-wheel-scrolling.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,38 @@ | |||
| 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. Scrolling that pane cancels an active selection safely.</div></div> | ||
| 22 | <h2>What the recording shows</h2> | ||
| 23 | <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> | ||
| 24 | <h2>Controls</h2> | ||
| 25 | <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> | ||
| 26 | <h2>Validation</h2> | ||
| 27 | <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> | ||
| 28 | <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> | ||
| 29 | <h2>Hardware timing and earlier misses</h2> | ||
| 30 | <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> | ||
| 31 | <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> | ||
| 32 | <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> | ||
| 33 | <h2>Design and limits</h2> | ||
| 34 | <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> | ||
| 35 | <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> | ||
| 36 | <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> | ||
| 37 | <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> | ||
| 38 | </html> | ||
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,12 @@ | |||
| 1 | # Native parity — wheel scrolling | 1 | # Native parity — wheel scrolling |
| 2 | 2 | ||
| 3 | Status: opening architecture cleanup implemented, reviewed and validated; | 3 | Status: implemented and independently reviewed; CI and functional checks passed. |
| 4 | wheel behavior has not been implemented. On 2026-09-06 the user selected text | 4 | Final NVIDIA timing check passed; earlier timing misses remain documented. |
| 5 | selection as the next sprint; this wheel plan is deferred. The user authorized a fresh worktree to begin closing GUI/TUI gaps. | 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`. | ||
| 6 | This is the first functional slice of git-collab issue `8b16e26b`. | 10 | This is the first functional slice of git-collab issue `8b16e26b`. |
| 7 | 11 | ||
| 8 | ## Sprint goal | 12 | ## Sprint goal |
| @@ -51,6 +55,26 @@ behavior is outside this initial vertical scrolling slice. | |||
| 51 | 55 | ||
| 52 | ## Opening architecture findings and ownership | 56 | ## Opening architecture findings and ownership |
| 53 | 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 on a pane whose viewport moves must cancel; a new selection in history | ||
| 75 | must address the displayed history rows. Scrolling another pane must preserve | ||
| 76 | the selected pane's text. These are part of wheel acceptance, not later polish. | ||
| 77 | |||
| 54 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through | 78 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through |
| 55 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; | 79 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; |
| 56 | keep these decisions window-free so native-core tests can cover them. | 80 | keep these decisions window-free so native-core tests can cover them. |
| @@ -86,9 +110,9 @@ client/core tests, real plural-pane wheel integration, full CI/native gates and | |||
| 86 | isolated NVIDIA stress. Build both binaries in ReleaseSafe. Preserve earlier | 110 | isolated NVIDIA stress. Build both binaries in ReleaseSafe. Preserve earlier |
| 87 | NVIDIA timing failures and the offscreen-growth limitation; neither is waived. | 111 | NVIDIA timing failures and the offscreen-growth limitation; neither is waived. |
| 88 | 112 | ||
| 89 | Finish with a private Tailscale webpage containing achievements, an actual GUI | 113 | Finish with a localhost review webpage containing achievements, an actual GUI |
| 90 | video, controls, evidence and limits. Record user demo approval separately from | 114 | video, controls, evidence and limits. Record user demo approval separately from |
| 91 | checks. Reuse the existing page workflow while preserving previous routes. | 115 | checks. Publish only if explicitly requested; preserve unrelated services. |
| 92 | 116 | ||
| 93 | 117 | ||
| 94 | ## Architecture review before implementation | 118 | ## Architecture review before implementation |
| @@ -151,5 +175,62 @@ calls out this build-serialization requirement. | |||
| 151 | 175 | ||
| 152 | This refactor preserves appearance behavior and uses the approved appearance | 176 | This refactor preserves appearance behavior and uses the approved appearance |
| 153 | recordings as its visual baseline; no new visible feature or performance claim | 177 | recordings as its visual baseline; no new visible feature or performance claim |
| 154 | is made. The functional wheel sprint still requires its own real-input demo and | 178 | is made. The following functional wheel delivery supplies its own real-input |
| 155 | private review page. | 179 | recording and local review page. |
| 180 | |||
| 181 | ## Wheel implementation and closing review | ||
| 182 | |||
| 183 | `frame` translates SDL wheel coordinates and modifiers into controller input. | ||
| 184 | The controller excludes non-content regions and modes, retains fractional | ||
| 185 | notches by pane attachment, and passes semantic intent through `Runtime.wheel`. | ||
| 186 | The pump drains available mode frames before interpreting queued wheel input. | ||
| 187 | Existing keyboard/resize work before a deferred wheel can run between bounded | ||
| 188 | receive batches; the wheel and following messages retain FIFO order. | ||
| 189 | |||
| 190 | Shell scrolling uses the existing scrollback request and grid decoder. The pump | ||
| 191 | owns one optional history grid and one outstanding request; its cancelled | ||
| 192 | request remains a tombstone until the reply is drained. New transport attachment | ||
| 193 | clears that tombstone, but same-wire resync retains it. A two-second timeout | ||
| 194 | reconnects rather than risking correlation with a later reply. View origin and | ||
| 195 | grid are captured under the same lock; all selection hit/paint coordinates use | ||
| 196 | that origin. The live replica remains untouched by history decoding. | ||
| 197 | |||
| 198 | The selected distance from live stays constant as output arrives. Output or mode | ||
| 199 | changes invalidate copying and schedule a new history fetch. The previous view | ||
| 200 | can remain painted while a reply is pending, but cannot be copied as if current. | ||
| 201 | This handles ring pruning even when the wire's history-row count remains flat. | ||
| 202 | Returning to live, resizing, reconnecting and replacing an attachment invalidate | ||
| 203 | the old view. As before, the wire does not provide an atomic source-version | ||
| 204 | precondition at the daemon. | ||
| 205 | |||
| 206 | Pure wheel encoding lives in `keymap`, reusing its arrow encoder and Zig's UTF-8 | ||
| 207 | encoder. SGR pixels, SGR cells, URXVT, UTF-8 and legacy X10 are supported with | ||
| 208 | pane-relative coordinates. No runtime dependency, protocol message or terminal | ||
| 209 | wall feature was added. Closing cleanup removed the hand-written UTF-8 helper | ||
| 210 | and the old duplicated receive-loop body. Independent review checked ownership, | ||
| 211 | request cancellation, pending input-buffer transfer, coordinate bounds and | ||
| 212 | same-wire resync. The existing QUIC batching regression caught a receive-loop | ||
| 213 | change that delayed mailbox work; the final implementation preserves its | ||
| 214 | 64-frame batches and passes that regression. | ||
| 215 | |||
| 216 | A continuously readable stream can delay a wheel waiting for its pending mode | ||
| 217 | frames and the input ordered after that wheel. A continuously changing history | ||
| 218 | can likewise postpone a safe selectable refresh. The next shared-client | ||
| 219 | scheduling change owns investigating a finite protocol ordering boundary if | ||
| 220 | this is encountered; dropping input or guessing a newer mode is not the fix. | ||
| 221 | |||
| 222 | |||
| 223 | ### Delivery evidence | ||
| 224 | |||
| 225 | Final CI, client/native tests, full native integration, real Wayland wheel and | ||
| 226 | clipboard acceptance, and desktop/mobile local video playback/seek checks passed. | ||
| 227 | The final isolated NVIDIA run passed with frame p99 18.745 ms and sampled | ||
| 228 | input-to-painted upper bound 65.3 ms (5 ms polling). Earlier current/baseline | ||
| 229 | runs missed the frame gate; retained evidence does not establish the cause. | ||
| 230 | The continuous recording is 27 seconds with real Wayland pointer/wheel input, | ||
| 231 | three panes on two daemons, a separate clipboard reader and real less. | ||
| 232 | |||
| 233 | Local review page: http://127.0.0.1:18776/wheel-scrolling/ . The loopback server | ||
| 234 | is recorded in `dist/wheel-scrolling/server.json`; stop it after acceptance. | ||
| 235 | No remote publication is required or pending. Publishing is opt-in at the user's | ||
| 236 | request. Sources, recording and all validation logs remain in the worktree. | ||
src/client/keymap.zig
| Old | New | ||
|---|---|---|---|
| @@ -76,6 +76,65 @@ 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 WheelFormat = enum { x10, utf8, sgr, urxvt, sgr_pixels }; | ||
| 80 | pub const wheel_max_seq_len = 64; | ||
| 81 | |||
| 82 | /// Encode one application wheel notch. Button 64 is up and 65 is down; wheel | ||
| 83 | /// events never emit a button release. Coordinates are one-based on the wire. | ||
| 84 | pub fn encodeWheel(format: WheelFormat, up: bool, cell_x: u16, cell_y: u16, pixel_x: u32, pixel_y: u32, mods: Mods, buf: []u8) []const u8 { | ||
| 85 | std.debug.assert(buf.len >= wheel_max_seq_len); | ||
| 86 | const base: u16 = if (up) 64 else 65; | ||
| 87 | const button: u16 = base + @as(u16, @intFromBool(mods.shift)) * 4 + @as(u16, @intFromBool(mods.alt)) * 8 + @as(u16, @intFromBool(mods.ctrl)) * 16; | ||
| 88 | const x: u32 = if (format == .sgr_pixels) pixel_x else cell_x; | ||
| 89 | const y: u32 = if (format == .sgr_pixels) pixel_y else cell_y; | ||
| 90 | const bx = x +| 1; | ||
| 91 | const by = y +| 1; | ||
| 92 | switch (format) { | ||
| 93 | .sgr, .sgr_pixels => return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ button, bx, by }) catch unreachable, | ||
| 94 | .urxvt => return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ button + 32, bx, by }) catch unreachable, | ||
| 95 | .x10 => { | ||
| 96 | if (bx > 223 or by > 223) return buf[0..0]; | ||
| 97 | buf[0] = 0x1b; | ||
| 98 | buf[1] = '['; | ||
| 99 | buf[2] = 'M'; | ||
| 100 | buf[3] = @intCast(button + 32); | ||
| 101 | buf[4] = @intCast(bx + 32); | ||
| 102 | buf[5] = @intCast(by + 32); | ||
| 103 | return buf[0..6]; | ||
| 104 | }, | ||
| 105 | .utf8 => { | ||
| 106 | if (bx > 2015 or by > 2015) return buf[0..0]; | ||
| 107 | buf[0] = 0x1b; | ||
| 108 | buf[1] = '['; | ||
| 109 | buf[2] = 'M'; | ||
| 110 | var n: usize = 3; | ||
| 111 | for ([_]u21{ button + 32, @intCast(bx + 32), @intCast(by + 32) }) |cp| { | ||
| 112 | n += std.unicode.utf8Encode(cp, buf[n..]) catch unreachable; | ||
| 113 | } | ||
| 114 | return buf[0..n]; | ||
| 115 | }, | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | pub fn encodeWheelArrow(up: bool, cursor_keys: bool, buf: []u8) []const u8 { | ||
| 120 | const bytes = encode(.{ .key = if (up) .up else .down }, buf); | ||
| 121 | if (cursor_keys) buf[1] = 'O'; | ||
| 122 | return bytes; | ||
| 123 | } | ||
| 124 | |||
| 125 | test "wheel encodes negotiated SGR and legacy forms" { | ||
| 126 | var buf: [wheel_max_seq_len]u8 = undefined; | ||
| 127 | try std.testing.expectEqualStrings("\x1b[<64;4;6M", encodeWheel(.sgr, true, 3, 5, 0, 0, .{}, &buf)); | ||
| 128 | try std.testing.expectEqualStrings("\x1b[97;4;6M", encodeWheel(.urxvt, false, 3, 5, 0, 0, .{}, &buf)); | ||
| 129 | try std.testing.expectEqualStrings("\x1bOA", encodeWheelArrow(true, true, &buf)); | ||
| 130 | try std.testing.expectEqualStrings("\x1b[B", encodeWheelArrow(false, false, &buf)); | ||
| 131 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 255, 255 }, encodeWheel(.x10, true, 222, 222, 0, 0, .{}, &buf)); | ||
| 132 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.x10, true, 223, 0, 0, 0, .{}, &buf).len); | ||
| 133 | try std.testing.expectEqualStrings("\x1b[<64;225;226M", encodeWheel(.sgr_pixels, true, 0, 0, 224, 225, .{}, &buf)); | ||
| 134 | try std.testing.expectEqualSlices(u8, &.{ 27, '[', 'M', 96, 0xdf, 0xbf, 0xdf, 0xbf }, encodeWheel(.utf8, true, 2014, 2014, 0, 0, .{}, &buf)); | ||
| 135 | try std.testing.expectEqual(@as(usize, 0), encodeWheel(.utf8, true, 2015, 0, 0, 0, .{}, &buf).len); | ||
| 136 | try std.testing.expectEqualStrings("\x1b[<93;1;1M", encodeWheel(.sgr, false, 0, 0, 0, 0, .{ .shift = true, .alt = true, .ctrl = true }, &buf)); | ||
| 137 | } | ||
| 79 | 138 | ||
| 80 | /// Encode one event into `buf` (at least max_seq_len bytes), returning the | 139 | /// 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, | 140 | /// slice written. An event this table has no bytes for — a bare modifier, |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -20,12 +20,27 @@ pub const SelectionRequest = struct { | |||
| 20 | }; | 20 | }; |
| 21 | pub const Say = union(enum) { | 21 | pub const Say = union(enum) { |
| 22 | input: []const u8, | 22 | input: []const u8, |
| 23 | wheel: Wheel, | ||
| 23 | resize: proto.Size, | 24 | resize: proto.Size, |
| 24 | selection: SelectionRequest, | 25 | selection: SelectionRequest, |
| 25 | end: struct { request: u64, force: bool = false }, | 26 | end: struct { request: u64, force: bool = false }, |
| 26 | detach, | 27 | detach, |
| 27 | quit, | 28 | quit, |
| 28 | }; | 29 | }; |
| 30 | pub const Wheel = struct { | ||
| 31 | notches: i32, | ||
| 32 | col: u16, | ||
| 33 | row: u16, | ||
| 34 | pixel_x: u32, | ||
| 35 | pixel_y: u32, | ||
| 36 | mods: client.keymap.Mods = .{}, | ||
| 37 | }; | ||
| 38 | const HistoryRequest = struct { | ||
| 39 | start: u32, | ||
| 40 | size: proto.Size, | ||
| 41 | revision: u64, | ||
| 42 | until: i64, | ||
| 43 | }; | ||
| 29 | pub const SelectionResult = struct { id: u32, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; | 44 | pub const SelectionResult = struct { id: u32, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; |
| 30 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; | 45 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; |
| 31 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; | 46 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; |
| @@ -90,6 +105,15 @@ pub const Pump = struct { | |||
| 90 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation | 105 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation |
| 91 | selection_until: i64 = 0, | 106 | selection_until: i64 = 0, |
| 92 | selection_result: ?SelectionResult = null, | 107 | selection_result: ?SelectionResult = null, |
| 108 | scroll_rows: u32 = 0, // mu: requested distance from live output | ||
| 109 | history: ?*term.grid.Grid = null, | ||
| 110 | history_start: u32 = 0, | ||
| 111 | history_version: SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | ||
| 112 | history_revision: u64 = 0, | ||
| 113 | history_dirty: bool = false, | ||
| 114 | // A cancelled request stays here until its reply is drained. The wire has | ||
| 115 | // no request ID, so replacing it could accept an old same-origin reply. | ||
| 116 | history_pending: ?HistoryRequest = null, | ||
| 93 | 117 | ||
| 94 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { | 118 | pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { |
| 95 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; | 119 | if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; |
| @@ -158,6 +182,7 @@ pub const Pump = struct { | |||
| 158 | if (self.thread) |thread| thread.join(); | 182 | if (self.thread) |thread| thread.join(); |
| 159 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 183 | for (self.mailbox.items) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 160 | if (self.selection_result) |result| self.alloc.free(result.text); | 184 | if (self.selection_result) |result| self.alloc.free(result.text); |
| 185 | if (self.history) |g| g.deinit(); | ||
| 161 | self.mailbox.deinit(self.alloc); | 186 | self.mailbox.deinit(self.alloc); |
| 162 | closePipe(self.wake_pipe); | 187 | closePipe(self.wake_pipe); |
| 163 | closePipe(self.cancel_pipe); | 188 | closePipe(self.cancel_pipe); |
| @@ -167,11 +192,72 @@ pub const Pump = struct { | |||
| 167 | 192 | ||
| 168 | /// Caller holds mu while copying both this version and the displayed grid. | 193 | /// Caller holds mu while copying both this version and the displayed grid. |
| 169 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { | 194 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { |
| 195 | if (self.history != null) return self.history_version; | ||
| 170 | return .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | 196 | return .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; |
| 171 | } | 197 | } |
| 198 | pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid { | ||
| 199 | return self.history orelse self.grid; | ||
| 200 | } | ||
| 201 | pub fn viewOriginLocked(self: *const Pump) u32 { | ||
| 202 | return if (self.history != null) self.history_start else self.replica.history_rows; | ||
| 203 | } | ||
| 172 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { | 204 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { |
| 173 | return !self.closing.load(.acquire) and self.status.phase == .attached and | 205 | return !self.closing.load(.acquire) and self.status.phase == .attached and |
| 174 | self.replica.state_since_attach and std.meta.eql(version, self.selectionVersionLocked()); | 206 | self.replica.state_since_attach and version.seq == self.replica.last_seq and |
| 207 | version.history_rows == self.replica.history_rows and version.revision == self.selection_revision and | ||
| 208 | std.meta.eql(version, self.selectionVersionLocked()); | ||
| 209 | } | ||
| 210 | fn returnLiveLocked(self: *Pump) void { | ||
| 211 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; | ||
| 212 | self.scroll_rows = 0; | ||
| 213 | self.history_dirty = false; | ||
| 214 | self.history_revision +%= 1; | ||
| 215 | self.selection_revision +%= 1; | ||
| 216 | self.invalidateSelectionLocked(); | ||
| 217 | if (self.history) |g| g.deinit(); | ||
| 218 | self.history = null; | ||
| 219 | } | ||
| 220 | fn requestHistory(self: *Pump, wire: *Wire) !void { | ||
| 221 | self.mu.lock(); | ||
| 222 | const pending = self.history_pending; | ||
| 223 | if (pending) |p| { | ||
| 224 | self.mu.unlock(); | ||
| 225 | // Reconnect rather than reusing an uncorrelated stream after timeout. | ||
| 226 | if (std.time.milliTimestamp() >= p.until) return error.ConnectionTimedOut; | ||
| 227 | return; | ||
| 228 | } | ||
| 229 | if (!self.history_dirty or self.scroll_rows == 0 or !self.admitted) { | ||
| 230 | self.mu.unlock(); | ||
| 231 | return; | ||
| 232 | } | ||
| 233 | 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 }; | ||
| 234 | self.history_pending = req; | ||
| 235 | self.history_dirty = false; | ||
| 236 | self.mu.unlock(); | ||
| 237 | const bytes = proto.encodeScrollbackReq(req.start, req.size.rows); | ||
| 238 | try wire.send(.fetch_scrollback, &bytes); | ||
| 239 | } | ||
| 240 | fn historyReplyLocked(self: *Pump, payload: []const u8) !Action { | ||
| 241 | const req = self.history_pending orelse return .skip; | ||
| 242 | self.history_pending = null; | ||
| 243 | if (req.revision != self.history_revision or self.scroll_rows == 0) return .skip; | ||
| 244 | if (payload.len < 6) return error.BadPayload; | ||
| 245 | const origin = std.mem.readInt(u32, payload[0..4], .little); | ||
| 246 | const count = std.mem.readInt(u16, payload[4..6], .little); | ||
| 247 | if (count > req.size.rows or origin > self.replica.history_rows) return error.BadPayload; | ||
| 248 | const view = try term.grid.Grid.init(self.alloc, req.size.cols, req.size.rows); | ||
| 249 | errdefer view.deinit(); | ||
| 250 | var bytes = payload[6..]; | ||
| 251 | for (view.lines[0..count]) |*row| bytes = try term.grid.decodeRow(self.alloc, row, bytes, req.size.cols); | ||
| 252 | if (bytes.len != 0) return error.BadPayload; | ||
| 253 | view.cursor = .{ .x = req.size.cols, .y = req.size.rows }; | ||
| 254 | if (self.history) |old| old.deinit(); | ||
| 255 | self.history = view; | ||
| 256 | self.history_start = origin; | ||
| 257 | self.selection_revision +%= 1; | ||
| 258 | self.invalidateSelectionLocked(); | ||
| 259 | self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | ||
| 260 | return .changed; | ||
| 175 | } | 261 | } |
| 176 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { | 262 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { |
| 177 | self.mu.lock(); | 263 | self.mu.lock(); |
| @@ -223,7 +309,10 @@ pub const Pump = struct { | |||
| 223 | } | 309 | } |
| 224 | 310 | ||
| 225 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { | 311 | fn setState(self: *Pump, phase: Phase, code: u8, reason: []const u8) void { |
| 226 | if (phase != .attached) self.invalidateSelectionLocked(); | 312 | if (phase != .attached) { |
| 313 | self.invalidateSelectionLocked(); | ||
| 314 | self.returnLiveLocked(); | ||
| 315 | } | ||
| 227 | self.status.phase = phase; | 316 | self.status.phase = phase; |
| 228 | self.status.exit_code = code; | 317 | self.status.exit_code = code; |
| 229 | self.status.reason_len = @min(reason.len, self.status.reason.len); | 318 | self.status.reason_len = @min(reason.len, self.status.reason.len); |
| @@ -289,6 +378,8 @@ pub const Pump = struct { | |||
| 289 | self.mu.lock(); | 378 | self.mu.lock(); |
| 290 | self.invalidateSelectionLocked(); | 379 | self.invalidateSelectionLocked(); |
| 291 | self.selection_revision +%= 1; | 380 | self.selection_revision +%= 1; |
| 381 | self.returnLiveLocked(); | ||
| 382 | if (!fresh) self.history_pending = null; | ||
| 292 | const args = self.replica.attachArgs(); | 383 | const args = self.replica.attachArgs(); |
| 293 | self.replica.state_since_attach = false; | 384 | self.replica.state_since_attach = false; |
| 294 | self.admitted = false; | 385 | self.admitted = false; |
| @@ -297,63 +388,185 @@ pub const Pump = struct { | |||
| 297 | 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))); | 388 | 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))); |
| 298 | } | 389 | } |
| 299 | 390 | ||
| 300 | fn mail(self: *Pump, wire: *Wire) !void { | 391 | fn mail(self: *Pump, wire: *Wire, allow_wheel: bool) !void { |
| 301 | drain(self.wake_pipe[0]); | 392 | drain(self.wake_pipe[0]); |
| 302 | self.mailbox_mu.lock(); | 393 | self.mailbox_mu.lock(); |
| 303 | var messages = self.mailbox; | 394 | var messages = self.mailbox; |
| 304 | self.mailbox = .empty; | 395 | self.mailbox = .empty; |
| 305 | self.mailbox_mu.unlock(); | 396 | self.mailbox_mu.unlock(); |
| 397 | var consumed = messages.items.len; | ||
| 306 | defer { | 398 | defer { |
| 307 | for (messages.items) |msg| if (msg == .input) self.alloc.free(msg.input); | 399 | for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 308 | messages.deinit(self.alloc); | 400 | messages.deinit(self.alloc); |
| 309 | } | 401 | } |
| 310 | for (messages.items) |msg| switch (msg) { | 402 | for (messages.items, 0..) |msg, i| { |
| 311 | .end => |req| { | 403 | if (msg == .wheel and !allow_wheel) { |
| 312 | self.expireEnd(); | 404 | // Keep the wheel and following input ordered, while allowing |
| 313 | self.mu.lock(); | 405 | // earlier resize/key/end work between bounded receive batches. |
| 314 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; | 406 | self.mailbox_mu.lock(); |
| 315 | self.mu.unlock(); | 407 | defer self.mailbox_mu.unlock(); |
| 316 | if (!pending) continue; | 408 | try self.mailbox.insertSlice(self.alloc, 0, messages.items[i..]); |
| 317 | self.mu.lock(); | 409 | consumed = i; |
| 318 | const admitted = self.admitted; | 410 | ring(self.wake_pipe[1], 1); |
| 319 | self.mu.unlock(); | 411 | break; |
| 320 | if (!admitted) { | 412 | } |
| 321 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); | 413 | switch (msg) { |
| 322 | continue; | 414 | .end => |req| { |
| 323 | } | 415 | self.expireEnd(); |
| 324 | var buf: [proto.end_req_max_len]u8 = undefined; | 416 | self.mu.lock(); |
| 325 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); | 417 | const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending; |
| 326 | }, | 418 | self.mu.unlock(); |
| 327 | .input => |bytes| try wire.send(.input, bytes), | 419 | if (!pending) continue; |
| 328 | .selection => |req| { | 420 | self.mu.lock(); |
| 329 | self.mu.lock(); | 421 | const admitted = self.admitted; |
| 330 | const payload = self.beginSelectionLocked(req); | 422 | self.mu.unlock(); |
| 331 | self.mu.unlock(); | 423 | if (!admitted) { |
| 332 | if (payload) |bytes| try wire.send(.selection_req, &bytes); | 424 | self.finishPendingEnd("Attachment changed before End; retry to check the session"); |
| 333 | }, | 425 | continue; |
| 334 | .resize => |size| { | 426 | } |
| 335 | self.mu.lock(); | 427 | var buf: [proto.end_req_max_len]u8 = undefined; |
| 336 | self.invalidateSelectionLocked(); | 428 | try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session)); |
| 337 | self.selection_revision +%= 1; | 429 | }, |
| 338 | self.mu.unlock(); | 430 | .input => |bytes| { |
| 339 | self.opts.cols = size.cols; | 431 | self.mu.lock(); |
| 340 | self.opts.rows = size.rows; | 432 | const scrolled = self.scroll_rows != 0; |
| 341 | if (!self.opts.existing_only or self.admitted) { | 433 | self.returnLiveLocked(); |
| 342 | const buf = proto.encodeSize(size.cols, size.rows); | 434 | self.mu.unlock(); |
| 343 | try wire.send(.resize, &buf); | 435 | if (scrolled) self.wake(); |
| 436 | try wire.send(.input, bytes); | ||
| 437 | }, | ||
| 438 | .wheel => |wheel| try self.routeWheel(wire, wheel), | ||
| 439 | .selection => |req| { | ||
| 440 | self.mu.lock(); | ||
| 441 | const payload = self.beginSelectionLocked(req); | ||
| 442 | self.mu.unlock(); | ||
| 443 | if (payload) |bytes| try wire.send(.selection_req, &bytes); | ||
| 444 | }, | ||
| 445 | .resize => |size| { | ||
| 446 | self.mu.lock(); | ||
| 447 | self.invalidateSelectionLocked(); | ||
| 448 | self.selection_revision +%= 1; | ||
| 449 | self.returnLiveLocked(); | ||
| 450 | self.mu.unlock(); | ||
| 451 | self.opts.cols = size.cols; | ||
| 452 | self.opts.rows = size.rows; | ||
| 453 | if (!self.opts.existing_only or self.admitted) { | ||
| 454 | const buf = proto.encodeSize(size.cols, size.rows); | ||
| 455 | try wire.send(.resize, &buf); | ||
| 456 | } | ||
| 457 | }, | ||
| 458 | .quit, .detach => unreachable, | ||
| 459 | } | ||
| 460 | } | ||
| 461 | } | ||
| 462 | |||
| 463 | fn routeWheel(self: *Pump, wire: *Wire, wheel: Wheel) !void { | ||
| 464 | self.mu.lock(); | ||
| 465 | if (!self.admitted or self.status.phase != .attached or wheel.notches == 0) { | ||
| 466 | self.mu.unlock(); | ||
| 467 | return; | ||
| 468 | } | ||
| 469 | const modes = self.core.terminal_modes; | ||
| 470 | const arrows = modes.alt_screen and self.scroll_rows == 0; | ||
| 471 | if (!modes.appMouse() and !arrows) { | ||
| 472 | const rows: u32 = @intCast(@min(@as(u64, @abs(wheel.notches)) * 3, std.math.maxInt(u32))); | ||
| 473 | const next = if (wheel.notches > 0) @min(self.scroll_rows +| rows, self.replica.history_rows) else self.scroll_rows -| rows; | ||
| 474 | if (next != self.scroll_rows) { | ||
| 475 | if (next == 0) self.returnLiveLocked() else { | ||
| 476 | self.scroll_rows = next; | ||
| 477 | self.history_revision +%= 1; | ||
| 478 | self.history_dirty = true; | ||
| 479 | self.selection_revision +%= 1; | ||
| 480 | self.invalidateSelectionLocked(); | ||
| 344 | } | 481 | } |
| 345 | }, | 482 | } |
| 346 | .quit, .detach => unreachable, | 483 | self.mu.unlock(); |
| 347 | }; | 484 | self.wake(); |
| 485 | return; | ||
| 486 | } | ||
| 487 | self.returnLiveLocked(); | ||
| 488 | self.invalidateSelectionLocked(); | ||
| 489 | self.selection_revision +%= 1; | ||
| 490 | self.mu.unlock(); | ||
| 491 | self.wake(); | ||
| 492 | var seq: [client.keymap.wheel_max_seq_len]u8 = undefined; | ||
| 493 | const bytes = if (modes.appMouse()) client.keymap.encodeWheel( | ||
| 494 | 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, | ||
| 495 | wheel.notches > 0, | ||
| 496 | wheel.col, | ||
| 497 | wheel.row, | ||
| 498 | wheel.pixel_x, | ||
| 499 | wheel.pixel_y, | ||
| 500 | wheel.mods, | ||
| 501 | &seq, | ||
| 502 | ) else client.keymap.encodeWheelArrow(wheel.notches > 0, modes.cursor_keys, &seq); | ||
| 503 | if (bytes.len == 0) return; | ||
| 504 | // Bound a single mailbox event even if an input adapter supplies an | ||
| 505 | // extreme delta. Ordinary wheels are one or a few notches per event. | ||
| 506 | var left: u32 = @as(u32, @intCast(@min(@abs(wheel.notches), 1024))) * @as(u32, if (modes.appMouse()) 1 else 3); | ||
| 507 | var batch: [1024]u8 = undefined; | ||
| 508 | while (left != 0) { | ||
| 509 | const n = @min(left, batch.len / bytes.len); | ||
| 510 | for (0..n) |i| @memcpy(batch[i * bytes.len ..][0..bytes.len], bytes); | ||
| 511 | try wire.send(.input, batch[0 .. n * bytes.len]); | ||
| 512 | left -= @intCast(n); | ||
| 513 | } | ||
| 514 | } | ||
| 515 | |||
| 516 | /// Drain ready stream bytes before interpreting semantic mouse intent. | ||
| 517 | /// A partial header is progress, not proof that the next mode frame is absent. | ||
| 518 | const ReadState = enum { idle, busy, closed, ended }; | ||
| 519 | fn receiveReady(self: *Pump, wire: *Wire) !ReadState { | ||
| 520 | var changed = false; | ||
| 521 | defer if (changed) self.wake(); | ||
| 522 | var frames: usize = 0; | ||
| 523 | for (0..256) |_| { | ||
| 524 | var fd = [_]std.posix.pollfd{.{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }}; | ||
| 525 | _ = try std.posix.poll(&fd, 0); | ||
| 526 | if (fd[0].revents == 0 and wire.tr.link != .quic) return .idle; | ||
| 527 | const before = wire.input.items.len; | ||
| 528 | switch (try wire.read()) { | ||
| 529 | .closed => return .closed, | ||
| 530 | .incomplete => if (wire.input.items.len == before) return .idle, | ||
| 531 | .frame => |frame| { | ||
| 532 | defer frame.deinit(self.alloc); | ||
| 533 | const action = try self.onFrame(frame.type, frame.payload); | ||
| 534 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { | ||
| 535 | self.admitted = true; | ||
| 536 | if (self.opts.existing_only) { | ||
| 537 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | ||
| 538 | try wire.send(.resize, &size); | ||
| 539 | } | ||
| 540 | } | ||
| 541 | changed = changed or action != .skip; | ||
| 542 | switch (action) { | ||
| 543 | .resync => try self.attach(wire, true), | ||
| 544 | .end => return .ended, | ||
| 545 | else => {}, | ||
| 546 | } | ||
| 547 | frames += 1; | ||
| 548 | if (frames == 64) return .busy; | ||
| 549 | }, | ||
| 550 | } | ||
| 551 | } | ||
| 552 | return .busy; | ||
| 348 | } | 553 | } |
| 349 | 554 | ||
| 350 | fn connected(self: *Pump, wire: *Wire) !bool { | 555 | fn connected(self: *Pump, wire: *Wire) !bool { |
| 351 | try self.attach(wire, false); | 556 | try self.attach(wire, false); |
| 352 | var eager = wire.tr.link == .quic; | ||
| 353 | while (true) { | 557 | while (true) { |
| 354 | self.expireEnd(); | 558 | self.expireEnd(); |
| 355 | self.expireSelection(); | 559 | self.expireSelection(); |
| 356 | try self.mail(wire); | 560 | wire.tr.service(); |
| 561 | var busy = false; | ||
| 562 | if (!self.closing.load(.acquire)) switch (try self.receiveReady(wire)) { | ||
| 563 | .closed => return false, | ||
| 564 | .ended => return true, | ||
| 565 | .busy => busy = true, | ||
| 566 | .idle => {}, | ||
| 567 | }; | ||
| 568 | try self.mail(wire, !busy); | ||
| 569 | try self.requestHistory(wire); | ||
| 357 | if (self.closing.load(.acquire)) { | 570 | if (self.closing.load(.acquire)) { |
| 358 | try wire.send(.detach, ""); | 571 | try wire.send(.detach, ""); |
| 359 | // A responsive peer receives detach; a stalled peer cannot | 572 | // A responsive peer receives detach; a stalled peer cannot |
| @@ -373,41 +586,10 @@ pub const Pump = struct { | |||
| 373 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, | 586 | .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, |
| 374 | .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, | 587 | .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, |
| 375 | }; | 588 | }; |
| 376 | _ = try std.posix.poll(&fds, self.endWaitMs(wire.tr.timeoutMs(if (eager) 0 else 1000))); | 589 | _ = try std.posix.poll(&fds, self.endWaitMs(wire.tr.timeoutMs(if (busy) 0 else 1000))); |
| 377 | wire.tr.service(); | 590 | wire.tr.service(); |
| 378 | if (fds[2].revents != 0) wire.tr.drainErr(); | 591 | if (fds[2].revents != 0) wire.tr.drainErr(); |
| 379 | if (fds[3].revents != 0) try wire.flush(); | 592 | if (fds[3].revents != 0) try wire.flush(); |
| 380 | eager = false; | ||
| 381 | if (fds[0].revents != 0 or wire.tr.link == .quic) { | ||
| 382 | var changed = false; | ||
| 383 | defer if (changed) self.wake(); | ||
| 384 | const budget: usize = if (wire.tr.link == .quic) 64 else 1; | ||
| 385 | for (0..budget) |i| { | ||
| 386 | const incoming = try wire.read(); | ||
| 387 | switch (incoming) { | ||
| 388 | .closed => return false, | ||
| 389 | .incomplete => break, | ||
| 390 | .frame => |frame| { | ||
| 391 | defer frame.deinit(self.alloc); | ||
| 392 | const action = try self.onFrame(frame.type, frame.payload); | ||
| 393 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { | ||
| 394 | self.admitted = true; | ||
| 395 | if (self.opts.existing_only) { | ||
| 396 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | ||
| 397 | try wire.send(.resize, &size); | ||
| 398 | } | ||
| 399 | } | ||
| 400 | changed = changed or action != .skip; | ||
| 401 | switch (action) { | ||
| 402 | .resync => try self.attach(wire, true), | ||
| 403 | .end => return true, | ||
| 404 | else => {}, | ||
| 405 | } | ||
| 406 | if (i + 1 == budget and wire.tr.link == .quic) eager = true; | ||
| 407 | }, | ||
| 408 | } | ||
| 409 | } | ||
| 410 | } | ||
| 411 | } | 593 | } |
| 412 | } | 594 | } |
| 413 | 595 | ||
| @@ -416,6 +598,7 @@ pub const Pump = struct { | |||
| 416 | self.mu.lock(); | 598 | self.mu.lock(); |
| 417 | defer self.mu.unlock(); | 599 | defer self.mu.unlock(); |
| 418 | switch (kind) { | 600 | switch (kind) { |
| 601 | .scrollback_chunk => return self.historyReplyLocked(payload), | ||
| 419 | .end_reply => { | 602 | .end_reply => { |
| 420 | if (self.status.ending.phase != .pending) return .skip; | 603 | if (self.status.ending.phase != .pending) return .skip; |
| 421 | if (proto.parseEndReply(payload)) |reply| { | 604 | if (proto.parseEndReply(payload)) |reply| { |
| @@ -425,12 +608,20 @@ pub const Pump = struct { | |||
| 425 | }, | 608 | }, |
| 426 | .snapshot, .delta => { | 609 | .snapshot, .delta => { |
| 427 | const begin = std.time.nanoTimestamp(); | 610 | const begin = std.time.nanoTimestamp(); |
| 611 | const old_epoch = self.replica.session_epoch; | ||
| 428 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { | 612 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { |
| 429 | error.BadPayload => return .skip, | 613 | error.BadPayload => return .skip, |
| 430 | else => return err, | 614 | else => return err, |
| 431 | }; | 615 | }; |
| 432 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | 616 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); |
| 433 | self.invalidateSelectionLocked(); | 617 | self.invalidateSelectionLocked(); |
| 618 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); | ||
| 619 | if (self.scroll_rows != 0) { | ||
| 620 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); | ||
| 621 | self.history_revision +%= 1; | ||
| 622 | self.history_dirty = self.scroll_rows != 0; | ||
| 623 | if (self.scroll_rows == 0) self.returnLiveLocked(); | ||
| 624 | } | ||
| 434 | if (applied == .resync) return .resync; | 625 | if (applied == .resync) return .resync; |
| 435 | if (kind == .snapshot) self.snapshot_ready = true; | 626 | if (kind == .snapshot) self.snapshot_ready = true; |
| 436 | self.setState(.attached, 0, ""); | 627 | self.setState(.attached, 0, ""); |
| @@ -470,6 +661,10 @@ pub const Pump = struct { | |||
| 470 | .state => { | 661 | .state => { |
| 471 | self.invalidateSelectionLocked(); | 662 | self.invalidateSelectionLocked(); |
| 472 | self.selection_revision +%= 1; | 663 | self.selection_revision +%= 1; |
| 664 | if (self.scroll_rows != 0) { | ||
| 665 | self.history_revision +%= 1; | ||
| 666 | self.history_dirty = true; | ||
| 667 | } | ||
| 473 | return .changed; | 668 | return .changed; |
| 474 | }, | 669 | }, |
| 475 | else => return .skip, | 670 | else => return .skip, |
| @@ -509,6 +704,7 @@ pub const Pump = struct { | |||
| 509 | var wait: i64 = cap; | 704 | var wait: i64 = cap; |
| 510 | if (self.status.ending.phase == .pending) wait = @min(wait, @max(0, self.end_until - now)); | 705 | if (self.status.ending.phase == .pending) wait = @min(wait, @max(0, self.end_until - now)); |
| 511 | if (self.selection_pending != null) wait = @min(wait, @max(0, self.selection_until - now)); | 706 | if (self.selection_pending != null) wait = @min(wait, @max(0, self.selection_until - now)); |
| 707 | if (self.history_pending) |req| wait = @min(wait, @max(0, req.until - now)); | ||
| 512 | return @intCast(wait); | 708 | return @intCast(wait); |
| 513 | } | 709 | } |
| 514 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { | 710 | fn finishPendingEnd(self: *Pump, reason: []const u8) void { |
| @@ -1221,3 +1417,117 @@ test "selection mode changes, cancellation and timeout discard later replies" { | |||
| 1221 | try std.testing.expect(p.takeSelection() == null); | 1417 | try std.testing.expect(p.takeSelection() == null); |
| 1222 | try std.testing.expect(p.core.pending_selection_id == null); | 1418 | try std.testing.expect(p.core.pending_selection_id == null); |
| 1223 | } | 1419 | } |
| 1420 | |||
| 1421 | test "wheel history tombstones, refresh, resize and timeout preserve the live replica" { | ||
| 1422 | const p = try selectionTestPump(); | ||
| 1423 | defer p.stop(); | ||
| 1424 | p.replica.history_rows = 20; | ||
| 1425 | p.admitted = true; | ||
| 1426 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1427 | defer closePipe(incoming); | ||
| 1428 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1429 | defer closePipe(outgoing); | ||
| 1430 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 1431 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 1432 | defer wire.deinit(); | ||
| 1433 | const up: Wheel = .{ .notches = 1, .col = 2, .row = 1, .pixel_x = 22, .pixel_y = 18 }; | ||
| 1434 | try p.routeWheel(&wire, up); | ||
| 1435 | try p.requestHistory(&wire); | ||
| 1436 | const first = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1437 | defer first.deinit(std.testing.allocator); | ||
| 1438 | try std.testing.expectEqual(proto.MsgType.fetch_scrollback, first.type); | ||
| 1439 | try std.testing.expectEqualDeep(proto.ScrollbackReq{ .start = 17, .count = 3 }, try proto.decodeScrollbackReq(first.payload)); | ||
| 1440 | const revision = p.history_pending.?.revision; | ||
| 1441 | try p.say(.{ .input = "live" }); | ||
| 1442 | try p.mail(&wire, true); | ||
| 1443 | const typed = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1444 | defer typed.deinit(std.testing.allocator); | ||
| 1445 | try std.testing.expectEqualStrings("live", typed.payload); | ||
| 1446 | try std.testing.expect(p.history_pending != null); // Keep the cancelled on-wire request. | ||
| 1447 | try p.routeWheel(&wire, up); | ||
| 1448 | try p.requestHistory(&wire); | ||
| 1449 | try std.testing.expectEqual(revision, p.history_pending.?.revision); | ||
| 1450 | const chunk = [_]u8{ 17, 0, 0, 0, 1, 0, 0, 0 }; // one valid blank CellRow | ||
| 1451 | try std.testing.expectEqual(Pump.Action.skip, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1452 | try std.testing.expect(p.history == null); | ||
| 1453 | try p.requestHistory(&wire); | ||
| 1454 | const fresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1455 | defer fresh.deinit(std.testing.allocator); | ||
| 1456 | try std.testing.expect(p.history_pending.?.revision != revision); | ||
| 1457 | try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1458 | try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); | ||
| 1459 | try std.testing.expect(p.viewGridLocked() != p.grid); | ||
| 1460 | try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); | ||
| 1461 | try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); | ||
| 1462 | var newer = testSnapshot(); | ||
| 1463 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1464 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1465 | try std.testing.expect(p.history_dirty); | ||
| 1466 | try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked())); | ||
| 1467 | try p.requestHistory(&wire); | ||
| 1468 | const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1469 | defer refresh.deinit(std.testing.allocator); | ||
| 1470 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1471 | try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); | ||
| 1472 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true })); | ||
| 1473 | try std.testing.expect(p.history != null and p.history_dirty); | ||
| 1474 | try p.requestHistory(&wire); | ||
| 1475 | const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1476 | defer before_resize.deinit(std.testing.allocator); | ||
| 1477 | try p.say(.{ .resize = .{ .cols = 12, .rows = 4 } }); | ||
| 1478 | try p.mail(&wire, true); | ||
| 1479 | const resized = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1480 | defer resized.deinit(std.testing.allocator); | ||
| 1481 | try std.testing.expectEqual(proto.MsgType.resize, resized.type); | ||
| 1482 | try std.testing.expect(p.history_pending != null and p.history == null); | ||
| 1483 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1484 | try std.testing.expect(p.history == null); | ||
| 1485 | try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); | ||
| 1486 | p.scroll_rows = 3; | ||
| 1487 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1488 | try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire)); | ||
| 1489 | try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 })); | ||
| 1490 | try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); | ||
| 1491 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1492 | try p.attach(&wire, true); | ||
| 1493 | try std.testing.expect(p.history_pending != null); | ||
| 1494 | try std.testing.expectEqual(Pump.Action.skip, try p.onFrame(.scrollback_chunk, &chunk)); | ||
| 1495 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | ||
| 1496 | try p.attach(&wire, false); | ||
| 1497 | try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0); | ||
| 1498 | } | ||
| 1499 | |||
| 1500 | test "ready terminal mode frames precede queued wheel input through the actual wire" { | ||
| 1501 | const p = try selectionTestPump(); | ||
| 1502 | defer p.stop(); | ||
| 1503 | p.admitted = true; | ||
| 1504 | const incoming = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1505 | defer closePipe(incoming); | ||
| 1506 | const outgoing = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); | ||
| 1507 | defer closePipe(outgoing); | ||
| 1508 | var tr: client.Transport = .{ .link = .{ .pipe = .{ .child = std.process.Child.init(&.{"unused"}, std.testing.allocator), .r = incoming[0], .w = outgoing[1] } } }; | ||
| 1509 | var wire = try Wire.init(std.testing.allocator, &tr); | ||
| 1510 | defer wire.deinit(); | ||
| 1511 | const up: Wheel = .{ .notches = 1, .col = 4, .row = 3, .pixel_x = 422, .pixel_y = 318 }; | ||
| 1512 | try p.say(.{ .wheel = up }); | ||
| 1513 | // More complete frames than one drain budget. Callers must defer the | ||
| 1514 | // mailbox until the final mode frame has been admitted in FIFO order. | ||
| 1515 | for (0..150) |_| try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false })); | ||
| 1516 | try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .alt_screen = true, .cursor_keys = true })); | ||
| 1517 | try std.testing.expectEqual(Pump.ReadState.busy, try p.receiveReady(&wire)); | ||
| 1518 | try p.mail(&wire, false); | ||
| 1519 | try std.testing.expectEqual(@as(usize, 1), p.mailbox.items.len); | ||
| 1520 | try std.testing.expectEqual(Pump.ReadState.busy, try p.receiveReady(&wire)); | ||
| 1521 | try std.testing.expectEqual(Pump.ReadState.idle, try p.receiveReady(&wire)); | ||
| 1522 | try p.mail(&wire, true); | ||
| 1523 | const arrows = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1524 | defer arrows.deinit(std.testing.allocator); | ||
| 1525 | try std.testing.expectEqualStrings("\x1bOA\x1bOA\x1bOA", arrows.payload); | ||
| 1526 | try proto.writeFrame(incoming[1], .term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .mouse_normal = true, .mouse_sgr_pixels = true })); | ||
| 1527 | try std.testing.expectEqual(Pump.ReadState.idle, try p.receiveReady(&wire)); | ||
| 1528 | try p.say(.{ .wheel = up }); | ||
| 1529 | try p.mail(&wire, true); | ||
| 1530 | const pixels = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1531 | defer pixels.deinit(std.testing.allocator); | ||
| 1532 | try std.testing.expectEqualStrings("\x1b[<64;423;319M", pixels.payload); | ||
| 1533 | } | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -88,6 +88,7 @@ pub const Hook = union(enum) { | |||
| 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 }, |
| 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, |
| @@ -107,6 +108,14 @@ pub fn parseHook(line: []const u8) ?Hook { | |||
| 107 | } | 108 | } |
| 108 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; | 109 | if (std.mem.startsWith(u8, line, "capture-last:")) return .{ .capture_last = line[13..] }; |
| 109 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; | 110 | if (std.mem.startsWith(u8, line, "clipboard:")) return .{ .clipboard = line[10..] }; |
| 111 | if (std.mem.startsWith(u8, line, "wheel:")) { | ||
| 112 | var it = std.mem.splitScalar(u8, line[6..], ','); | ||
| 113 | const x = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 114 | const y = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 115 | const delta = std.fmt.parseFloat(f32, it.next() orelse return null) catch return null; | ||
| 116 | const flipped = if (it.next()) |v| std.mem.eql(u8, v, "flipped") else false; | ||
| 117 | return .{ .wheel = .{ .x = x, .y = y, .delta = delta, .flipped = flipped } }; | ||
| 118 | } | ||
| 110 | if (std.mem.startsWith(u8, line, "click:")) { | 119 | if (std.mem.startsWith(u8, line, "click:")) { |
| 111 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; | 120 | const comma = std.mem.indexOfScalar(u8, line, ',') orelse return null; |
| 112 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; | 121 | return .{ .click = .{ .x = std.fmt.parseFloat(f32, line[6..comma]) catch return null, .y = std.fmt.parseFloat(f32, line[comma + 1 ..]) catch return null } }; |
| @@ -265,6 +274,16 @@ const HookReader = struct { | |||
| 265 | ev.button.y = at.y; | 274 | ev.button.y = at.y; |
| 266 | } | 275 | } |
| 267 | }, | 276 | }, |
| 277 | .wheel => |at| { | ||
| 278 | ev.wheel.type = c.SDL_EVENT_MOUSE_WHEEL; | ||
| 279 | ev.wheel.mouse_x = at.x; | ||
| 280 | ev.wheel.mouse_y = at.y; | ||
| 281 | ev.wheel.x = 0; | ||
| 282 | ev.wheel.y = at.delta; | ||
| 283 | ev.wheel.integer_x = 0; | ||
| 284 | ev.wheel.integer_y = 0; | ||
| 285 | ev.wheel.direction = if (at.flipped) c.SDL_MOUSEWHEEL_FLIPPED else c.SDL_MOUSEWHEEL_NORMAL; | ||
| 286 | }, | ||
| 268 | .state => |path| { | 287 | .state => |path| { |
| 269 | const copy = try self.alloc.dupe(u8, path); | 288 | const copy = try self.alloc.dupe(u8, path); |
| 270 | if (self.state) |old| self.alloc.free(old); | 289 | if (self.state) |old| self.alloc.free(old); |
| @@ -340,7 +359,7 @@ const Events = struct { | |||
| 340 | /// Commit events sample the actual drawable even if its resize notice | 359 | /// Commit events sample the actual drawable even if its resize notice |
| 341 | /// is still behind this event in SDL's bounded queue. | 360 | /// is still behind this event in SDL's bounded queue. |
| 342 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { | 361 | fn dispatch(self: *Events, ev: c.SDL_Event) !bool { |
| 343 | 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 or self.ui.selection_drag.buttonHeld()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { | 362 | 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.drag != null or self.ui.selection_drag.buttonHeld()) and (ev.type == c.SDL_EVENT_MOUSE_MOTION or ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP))) { |
| 344 | self.geometry_dirty = true; | 363 | self.geometry_dirty = true; |
| 345 | try self.refreshGeometry(); | 364 | try self.refreshGeometry(); |
| 346 | } | 365 | } |
| @@ -366,6 +385,19 @@ const Events = struct { | |||
| 366 | try self.ui.pointerDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h)); | 385 | try self.ui.pointerDown(at.x, at.y, grabPixels(w, self.ui.fb_w), grabPixels(h, self.ui.fb_h)); |
| 367 | } | 386 | } |
| 368 | }, | 387 | }, |
| 388 | c.SDL_EVENT_MOUSE_WHEEL => { | ||
| 389 | var w: c_int = 0; | ||
| 390 | var h: c_int = 0; | ||
| 391 | if (c.SDL_GetWindowSize(self.win, &w, &h)) { | ||
| 392 | const at = physicalPoint(ev.wheel.mouse_x, ev.wheel.mouse_y, w, h, self.ui.fb_w, self.ui.fb_h); | ||
| 393 | const mods = c.SDL_GetModState(); | ||
| 394 | try self.ui.wheel(at.x, at.y, ev.wheel.y, ev.wheel.direction == c.SDL_MOUSEWHEEL_FLIPPED, .{ | ||
| 395 | .shift = mods & c.SDL_KMOD_SHIFT != 0, | ||
| 396 | .ctrl = mods & c.SDL_KMOD_CTRL != 0, | ||
| 397 | .alt = mods & c.SDL_KMOD_ALT != 0, | ||
| 398 | }); | ||
| 399 | } | ||
| 400 | }, | ||
| 369 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { | 401 | c.SDL_EVENT_MOUSE_MOTION, c.SDL_EVENT_MOUSE_BUTTON_UP => { |
| 370 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; | 402 | if (ev.type == c.SDL_EVENT_MOUSE_BUTTON_UP and ev.button.button != c.SDL_BUTTON_LEFT) return true; |
| 371 | if (self.ui.drag != null or self.ui.selection_drag.buttonHeld()) { | 403 | if (self.ui.drag != null or self.ui.selection_drag.buttonHeld()) { |
| @@ -663,7 +695,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { | |||
| 663 | const bg_start = lists.backgrounds.items.len; | 695 | const bg_start = lists.backgrounds.items.len; |
| 664 | const fg_start = lists.foregrounds.items.len; | 696 | const fg_start = lists.foregrounds.items.len; |
| 665 | for (0..grid.rows) |y| { | 697 | for (0..grid.rows) |y| { |
| 666 | ctx.selection = if (events.ui.selectedSpan(p.id, live.snapshot_version.history_rows + @as(u32, @intCast(y)), grid.cols)) |s| .{ .from = s.from, .to = s.to } else null; | 698 | 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; |
| 667 | visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; | 699 | visible_blink = (try quads.rowInstances(&lists, alloc, grid.row(@intCast(y)), grid.cols, 0, @intCast(y), ctx)) or visible_blink; |
| 668 | } | 700 | } |
| 669 | 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)); | 701 | 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)); |
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -23,6 +23,17 @@ pub const KeyDown = struct { | |||
| 23 | terminal: ?keymap.Event = null, | 23 | terminal: ?keymap.Event = null, |
| 24 | }; | 24 | }; |
| 25 | 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 | }; | ||
| 26 | 37 | ||
| 27 | pub const Recovery = struct { | 38 | pub const Recovery = struct { |
| 28 | kind: enum { recovery, force_end }, | 39 | kind: enum { recovery, force_end }, |
| @@ -82,6 +93,7 @@ pub const Controller = struct { | |||
| 82 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, | 93 | modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty, |
| 83 | 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, |
| 84 | selection_drag: client.selection.Drag = .{}, | 95 | selection_drag: client.selection.Drag = .{}, |
| 96 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), | ||
| 85 | selection_key: ?model.Attachment = null, | 97 | selection_key: ?model.Attachment = null, |
| 86 | selection_request: u32 = 0, | 98 | selection_request: u32 = 0, |
| 87 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | 99 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, |
| @@ -113,7 +125,7 @@ pub const Controller = struct { | |||
| 113 | const col: u16 = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x - p.content.x) / self.metrics.cell_w)); | 125 | const col: u16 = @intCast(@min(@as(u32, std.math.maxInt(u16)), (x - p.content.x) / self.metrics.cell_w)); |
| 114 | if (local_row >= live.snapshot.rows or col >= live.snapshot.cols) return null; | 126 | if (local_row >= live.snapshot.rows or col >= live.snapshot.cols) return null; |
| 115 | const actual_col = if (live.snapshot.row(@intCast(local_row)).cells[col].wide == .spacer_tail and col > 0) col - 1 else col; | 127 | const actual_col = if (live.snapshot.row(@intCast(local_row)).cells[col].wide == .spacer_tail and col > 0) col - 1 else col; |
| 116 | return .{ .tile = @intCast(id), .row = live.snapshot_version.history_rows + local_row, .col = actual_col }; | 128 | return .{ .tile = @intCast(id), .row = live.view_origin + local_row, .col = actual_col }; |
| 117 | } | 129 | } |
| 118 | fn cellFor(self: *Controller, x: u32, y: u32) client.selection.Cell { | 130 | fn cellFor(self: *Controller, x: u32, y: u32) client.selection.Cell { |
| 119 | const h = self.hit(x, y) orelse return self.selection_drag.at; | 131 | const h = self.hit(x, y) orelse return self.selection_drag.at; |
| @@ -331,7 +343,7 @@ pub const Controller = struct { | |||
| 331 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; | 343 | self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id; |
| 332 | _ = self.rt.workspace.focus(id); | 344 | _ = self.rt.workspace.focus(id); |
| 333 | const point = self.hit(x, y); | 345 | const point = self.hit(x, y); |
| 334 | self.selection_drag.press(.{ .row = if (point) |h| @intCast(h.row - self.rt.get(id).?.snapshot_version.history_rows) else 0, .col = if (point) |h| h.col else 0 }, point); | 346 | 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); |
| 335 | if (point) |h| { | 347 | if (point) |h| { |
| 336 | const live = self.rt.get(h.tile).?; | 348 | const live = self.rt.get(h.tile).?; |
| 337 | self.selection_key = live.key; | 349 | self.selection_key = live.key; |
| @@ -397,6 +409,37 @@ pub const Controller = struct { | |||
| 397 | try self.relayout(); | 409 | try self.relayout(); |
| 398 | } | 410 | } |
| 399 | } | 411 | } |
| 412 | /// Semantic wheel entry point. Transport routing is deliberately deferred | ||
| 413 | /// until the pump has sampled the pane's current terminal modes. | ||
| 414 | pub fn wheel(self: *Controller, x: u32, y: u32, delta: f32, flipped: bool, mods: keymap.Mods) !void { | ||
| 415 | if (self.picker != null or self.recovery != null or self.resize_mode or self.command_mode or self.drag != null) return; | ||
| 416 | const id = self.layout.hit(x, y) orelse return; | ||
| 417 | const placement = self.layout.get(id) orelse return; | ||
| 418 | 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; | ||
| 419 | const live = self.rt.get(id) orelse return; | ||
| 420 | const slot = found: { | ||
| 421 | for (&self.wheel_remainder) |*candidate| { | ||
| 422 | if (candidate.key != null and candidate.key.?.pane == live.key.pane) break :found candidate; | ||
| 423 | } | ||
| 424 | for (&self.wheel_remainder) |*candidate| { | ||
| 425 | if (candidate.key == null or self.rt.get(candidate.key.?.pane) == null) break :found candidate; | ||
| 426 | } | ||
| 427 | return; | ||
| 428 | }; | ||
| 429 | if (slot.key == null or !std.meta.eql(slot.key.?, live.key)) { | ||
| 430 | slot.* = .{ .key = live.key, .wheel = .{} }; | ||
| 431 | } | ||
| 432 | const notches = slot.wheel.notches(delta, flipped); | ||
| 433 | if (notches == 0) return; | ||
| 434 | try self.rt.wheel(live.key, .{ | ||
| 435 | .notches = notches, | ||
| 436 | .col = @intCast(@min((x - placement.content.x) / self.metrics.cell_w, placement.cols - 1)), | ||
| 437 | .row = @intCast(@min((y - placement.content.y) / self.metrics.cell_h, placement.rows - 1)), | ||
| 438 | .pixel_x = x - placement.content.x, | ||
| 439 | .pixel_y = y - placement.content.y, | ||
| 440 | .mods = mods, | ||
| 441 | }); | ||
| 442 | } | ||
| 400 | pub fn cancelDrag(self: *Controller) void { | 443 | pub fn cancelDrag(self: *Controller) void { |
| 401 | if (self.drag == null) return; | 444 | if (self.drag == null) return; |
| 402 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; | 445 | self.intent_dirty = self.intent_dirty or self.drag.?.changed; |
| @@ -663,6 +706,13 @@ test "beginEnd records pending request without opening an ending modal" { | |||
| 663 | ui.pending_end = null; | 706 | ui.pending_end = null; |
| 664 | } | 707 | } |
| 665 | 708 | ||
| 709 | test "wheel retains fractional deltas and emits whole notches" { | ||
| 710 | var wheel: Wheel = .{}; | ||
| 711 | try std.testing.expectEqual(@as(i32, 0), wheel.notches(0.4, false)); | ||
| 712 | try std.testing.expectEqual(@as(i32, 1), wheel.notches(0.6, false)); | ||
| 713 | try std.testing.expectEqual(@as(i32, -1), wheel.notches(1, true)); | ||
| 714 | } | ||
| 715 | |||
| 666 | test "selection hit keeps absolute history and pane-local pointer cells" { | 716 | test "selection hit keeps absolute history and pane-local pointer cells" { |
| 667 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | 717 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); |
| 668 | defer rt.deinit(); | 718 | defer rt.deinit(); |
| @@ -675,7 +725,7 @@ test "selection hit keeps absolute history and pane-local pointer cells" { | |||
| 675 | for (ui.layout.items()) |placement| { | 725 | for (ui.layout.items()) |placement| { |
| 676 | const live = rt.get(placement.id).?; | 726 | const live = rt.get(placement.id).?; |
| 677 | try live.snapshot.resize(22, 9); | 727 | try live.snapshot.resize(22, 9); |
| 678 | live.snapshot_version.history_rows = if (placement.id == first) 70_000 else 3; | 728 | live.view_origin = if (placement.id == first) 70_000 else 3; |
| 679 | } | 729 | } |
| 680 | const a = ui.layout.get(first).?; | 730 | const a = ui.layout.get(first).?; |
| 681 | const b = ui.layout.get(second).?; | 731 | const b = ui.layout.get(second).?; |
| @@ -695,3 +745,23 @@ test "selection hit keeps absolute history and pane-local pointer cells" { | |||
| 695 | try std.testing.expect(!ui.selection_drag.buttonHeld()); | 745 | try std.testing.expect(!ui.selection_drag.buttonHeld()); |
| 696 | try std.testing.expect(ui.selection_drag.range() == null); | 746 | try std.testing.expect(ui.selection_drag.range() == null); |
| 697 | } | 747 | } |
| 748 | |||
| 749 | test "wheel reuses an existing pane remainder before a vacant earlier slot" { | ||
| 750 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 751 | defer rt.deinit(); | ||
| 752 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 753 | const id = try rt.add(.{ .via = "cat" }, "fraction", 800, 600, metrics); | ||
| 754 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 755 | defer ui.deinit(); | ||
| 756 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 757 | const live = rt.get(id).?; | ||
| 758 | ui.wheel_remainder[0] = .{ .key = .{ .pane = id + 1, .generation = 1 }, .wheel = .{ .fraction = 0.75 } }; | ||
| 759 | ui.wheel_remainder[1] = .{ .key = live.key, .wheel = .{ .fraction = 0.5 } }; | ||
| 760 | const rect = ui.layout.get(id).?.content; | ||
| 761 | try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{}); | ||
| 762 | try std.testing.expectEqual(@as(f32, 0), ui.wheel_remainder[1].wheel.fraction); | ||
| 763 | try std.testing.expectEqual(@as(f32, 0.75), ui.wheel_remainder[0].wheel.fraction); | ||
| 764 | ui.wheel_remainder[1].key.?.generation +%= 1; | ||
| 765 | try ui.wheel(rect.x + 4, rect.y + 8, 0.5, false, .{}); | ||
| 766 | try std.testing.expectEqual(@as(f32, 0.5), ui.wheel_remainder[1].wheel.fraction); | ||
| 767 | } | ||
src/gui/runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,7 @@ pub const Live = struct { | |||
| 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 }, | 14 | snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, |
| 15 | painted_seq: u64 = 0, | 15 | painted_seq: u64 = 0, |
| 16 | view_origin: u32 = 0, | ||
| 16 | status: client.session_pump.State = .{}, | 17 | status: client.session_pump.State = .{}, |
| 17 | size: term.protocol.Size, | 18 | size: term.protocol.Size, |
| 18 | notify: Notify, | 19 | notify: Notify, |
| @@ -46,10 +47,11 @@ pub const Live = struct { | |||
| 46 | defer self.pump.mu.unlock(); | 47 | defer self.pump.mu.unlock(); |
| 47 | if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0; | 48 | if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0; |
| 48 | self.preserve_snapshot = false; | 49 | self.preserve_snapshot = false; |
| 49 | const src = self.pump.grid; | 50 | const src = self.pump.viewGridLocked(); |
| 50 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); | 51 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); |
| 51 | self.snapshot_seq = self.pump.replica.last_seq; | 52 | self.snapshot_seq = self.pump.replica.last_seq; |
| 52 | self.snapshot_version = self.pump.selectionVersionLocked(); | 53 | self.snapshot_version = self.pump.selectionVersionLocked(); |
| 54 | self.view_origin = self.pump.viewOriginLocked(); | ||
| 53 | return self.pump.last_apply_us; | 55 | return self.pump.last_apply_us; |
| 54 | } | 56 | } |
| 55 | }; | 57 | }; |
| @@ -132,6 +134,7 @@ pub const Runtime = struct { | |||
| 132 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); | 134 | try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows); |
| 133 | live.snapshot_seq = old.snapshot_seq; | 135 | live.snapshot_seq = old.snapshot_seq; |
| 134 | live.snapshot_version = old.snapshot_version; | 136 | live.snapshot_version = old.snapshot_version; |
| 137 | live.view_origin = old.view_origin; | ||
| 135 | live.preserve_snapshot = true; | 138 | live.preserve_snapshot = true; |
| 136 | } | 139 | } |
| 137 | for (&self.lives) |*slot| if (slot.* == old) { | 140 | for (&self.lives) |*slot| if (slot.* == old) { |
| @@ -174,6 +177,12 @@ pub const Runtime = struct { | |||
| 174 | } | 177 | } |
| 175 | } | 178 | } |
| 176 | 179 | ||
| 180 | pub fn wheel(self: *Runtime, key: model.Attachment, event: client.session_pump.Wheel) !void { | ||
| 181 | const live = self.get(key.pane) orelse return; | ||
| 182 | if (!self.accepts(key) or live.status.phase != .attached) return; | ||
| 183 | try live.pump.say(.{ .wheel = event }); | ||
| 184 | } | ||
| 185 | |||
| 177 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { | 186 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { |
| 178 | const live = self.get(key.pane) orelse return error.MissingPane; | 187 | const live = self.get(key.pane) orelse return error.MissingPane; |
| 179 | if (!self.accepts(key)) return error.StaleAttachment; | 188 | if (!self.accepts(key)) return error.StaleAttachment; |
test/native_wheel.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,269 @@ | |||
| 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 | rig.select(target, (0, 0), (11, 0)) | ||
| 110 | rig.copied(first[:12]) | ||
| 111 | rig.wheel(target, 1) | ||
| 112 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 15) | ||
| 113 | rig.unchanged(first[:12]) | ||
| 114 | rig.select(target, (0, 0), (11, 0), release=False) | ||
| 115 | held_state = rig.state() | ||
| 116 | rig.wheel(target, 1) | ||
| 117 | rig.wait_state(lambda s: first_number(by_id(s)[target]['painted_text']) == baseline - 18) | ||
| 118 | rig.send('mouseup:' + rig.cell_point(held_state, target, 11, 0)) | ||
| 119 | rig.unchanged(first[:12]) | ||
| 120 | rig.focus(target) | ||
| 121 | rig.shell("printf 'RETURN-%s\\n' LIVE") | ||
| 122 | rig.wait_state(lambda s: 'RETURN-LIVE' in by_id(s)[target]['painted_text']) | ||
| 123 | rig.ok('history bounds, history selection/copy, held-drag cancellation and typing back to live') | ||
| 124 | |||
| 125 | |||
| 126 | def raw_reader(rig, pane): | ||
| 127 | """A real foreground PTY application changes modes and records all received bytes.""" | ||
| 128 | rig.focus(pane) | ||
| 129 | control, received, stopped = (rig.root / f'{name}-{pane}' for name in ('mode', 'received', 'stop')) | ||
| 130 | program = rig.root / f'raw-reader-{pane}.py' | ||
| 131 | program.write_text( | ||
| 132 | 'import os, select, termios, tty\nfrom pathlib import Path\n' | ||
| 133 | f'control=Path({str(control)!r}); received=Path({str(received)!r}); stop=Path({str(stopped)!r})\n' | ||
| 134 | 'old=termios.tcgetattr(0); tty.setraw(0); previous=None\n' | ||
| 135 | 'reset="\\033[?9l\\033[?1000l\\033[?1002l\\033[?1003l\\033[?1005l\\033[?1006l\\033[?1015l\\033[?1016l\\033[?1l\\033[?1049l"\n' | ||
| 136 | 'try:\n' | ||
| 137 | ' with received.open("wb", buffering=0) as output:\n' | ||
| 138 | ' while not stop.exists():\n' | ||
| 139 | ' current=control.read_text() if control.exists() else "ready|"\n' | ||
| 140 | ' if current != previous:\n' | ||
| 141 | ' tag, modes=current.split("|", 1)\n' | ||
| 142 | ' os.write(1, (reset+modes+"\\033[2J\\033[HMODE-"+tag+"\\r\\n").encode()); previous=current\n' | ||
| 143 | ' if select.select([0], [], [], .02)[0]: output.write(os.read(0, 4096))\n' | ||
| 144 | 'finally:\n' | ||
| 145 | ' os.write(1, reset.encode()); termios.tcsetattr(0, termios.TCSANOW, old)\n') | ||
| 146 | rig.shell('python3 ' + shlex.quote(str(program))) | ||
| 147 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-ready')) | ||
| 148 | return control, received, stopped | ||
| 149 | |||
| 150 | |||
| 151 | def application_wheel(rig, panes): | ||
| 152 | pane, focus = panes[1], panes[2] | ||
| 153 | control, received, stopped = raw_reader(rig, pane) | ||
| 154 | def set_mode(value): | ||
| 155 | pending = control.with_suffix('.next') | ||
| 156 | pending.write_text(value) | ||
| 157 | pending.replace(control) | ||
| 158 | rig.focus(focus) | ||
| 159 | offset = 0 | ||
| 160 | try: | ||
| 161 | cases = [('arrows', '\033[?1049h', b'\033[A' * 3, b'\033[B' * 3), | ||
| 162 | ('app-arrows', '\033[?1049h\033[?1h', b'\033OA' * 3, b'\033OB' * 3), | ||
| 163 | ('sgr', '\033[?1000h\033[?1006h', b'\033[<64;5;4M', b'\033[<65;5;4M'), | ||
| 164 | ('legacy', '\033[?1000h', b'\033[M`%$', b'\033[Ma%$'), | ||
| 165 | ('utf8', '\033[?1000h\033[?1005h', b'\033[M`%$', b'\033[Ma%$'), | ||
| 166 | ('urxvt', '\033[?1000h\033[?1015h', b'\033[96;5;4M', b'\033[97;5;4M')] | ||
| 167 | for label, modes, up, down in cases: | ||
| 168 | set_mode(label + '|' + modes) | ||
| 169 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-' + label)) | ||
| 170 | rig.wheel(pane, 1) | ||
| 171 | rig.wheel(pane, -1) | ||
| 172 | expected = up + down | ||
| 173 | eventually(lambda: received.stat().st_size >= offset + len(expected), label + ' PTY bytes missing') | ||
| 174 | time.sleep(.08) | ||
| 175 | actual = received.read_bytes()[offset:] | ||
| 176 | require(actual == expected, f'{label} PTY received {actual!r}, expected {expected!r}') | ||
| 177 | offset += len(expected) | ||
| 178 | require(rig.state()['focus'] == focus, 'application wheel changed keyboard focus') | ||
| 179 | rig.ok('independent PTY bytes prove alternate arrows, cursor-key mode and negotiated cell mouse formats') | ||
| 180 | |||
| 181 | set_mode('pixels|\033[?1000h\033[?1016h') | ||
| 182 | rig.wait_state(lambda s: by_id(s)[pane]['painted_text'].startswith('MODE-pixels')) | ||
| 183 | scale_output = os.environ.get('MUXG_TEST_SCALE_OUTPUT') | ||
| 184 | original = None | ||
| 185 | scales = (None,) | ||
| 186 | if scale_output: | ||
| 187 | original = next(o['scale'] for o in rig.pointer.query('get_outputs') if o['name'] == scale_output) | ||
| 188 | scales = (2, 1, 1.5, 2) | ||
| 189 | try: | ||
| 190 | for scale in scales: | ||
| 191 | if scale is not None: | ||
| 192 | subprocess.run(['swaymsg', 'output', scale_output, 'scale', str(scale)], | ||
| 193 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 194 | rig.wait_state(lambda s: abs(s['width'] / s['logical_width'] - scale) < .01) | ||
| 195 | rig.kernel_sizes() | ||
| 196 | state = rig.state() | ||
| 197 | point = rig.cell_point(state, pane, 4, 3) | ||
| 198 | x, y = map(float, point.split(',')) | ||
| 199 | if rig.env['SDL_VIDEO_DRIVER'] == 'wayland': | ||
| 200 | x, y = int(x), int(y) # virtual pointer uses logical integer coordinates | ||
| 201 | content = by_id(state)[pane]['content'] | ||
| 202 | px = int(x * state['width'] / state['logical_width']) - content['x'] + 1 | ||
| 203 | py = int(y * state['height'] / state['logical_height']) - content['y'] + 1 | ||
| 204 | rig.wheel_at(point, 1) | ||
| 205 | expected = f'\033[<64;{px};{py}M'.encode() | ||
| 206 | eventually(lambda: received.stat().st_size >= offset + len(expected), 'pixel report missing') | ||
| 207 | require(received.read_bytes()[offset:] == expected, 'pixel report not relative to pane at current DPI') | ||
| 208 | offset += len(expected) | ||
| 209 | finally: | ||
| 210 | if original is not None: | ||
| 211 | subprocess.run(['swaymsg', 'output', scale_output, 'scale', str(original)], | ||
| 212 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 213 | rig.ok('SGR pixel coordinates are pane-relative, including configured Wayland scale transitions') | ||
| 214 | |||
| 215 | rig.key('prefix') | ||
| 216 | rig.wheel(pane, 1) | ||
| 217 | time.sleep(.1) | ||
| 218 | require(received.stat().st_size == offset, 'command prefix leaked wheel input') | ||
| 219 | rig.key('escape') | ||
| 220 | rig.chord('enter') | ||
| 221 | rig.picker() | ||
| 222 | rig.wheel(pane, 1) | ||
| 223 | time.sleep(.1) | ||
| 224 | require(received.stat().st_size == offset, 'picker leaked wheel input') | ||
| 225 | rig.key('escape') | ||
| 226 | rig.wait_state(lambda s: not s.get('picker')) | ||
| 227 | rig.chord('p') | ||
| 228 | rig.wait_state(lambda s: s.get('recovery')) | ||
| 229 | rig.wheel(pane, 1) | ||
| 230 | time.sleep(.1) | ||
| 231 | require(received.stat().st_size == offset, 'recovery menu leaked wheel input') | ||
| 232 | rig.key('escape') | ||
| 233 | state = rig.wait_state(lambda s: not s.get('recovery')) | ||
| 234 | header = by_id(state)[pane]['header'] | ||
| 235 | rig.wheel_at(rig.point(state, header['x'] + header['w']/2, header['y'] + header['h']/2), 1) | ||
| 236 | rect = one_divider(state, 'beside')['rect'] | ||
| 237 | point = rig.point(state, rect['x'] + rect['w']/2, rect['y'] + rect['h']/4) | ||
| 238 | rig.wheel_at(point, 1) | ||
| 239 | rig.send('mousedown:' + point) | ||
| 240 | rig.wheel(pane, 1) | ||
| 241 | rig.send('mouseup:' + point) | ||
| 242 | time.sleep(.1) | ||
| 243 | require(received.stat().st_size == offset, 'header/divider/resize leaked wheel input') | ||
| 244 | rig.ok('picker, recovery, headers, dividers and held resize intercept wheel events') | ||
| 245 | finally: | ||
| 246 | stopped.touch() | ||
| 247 | |||
| 248 | |||
| 249 | def main(): | ||
| 250 | rig = WheelRig(*sys.argv[1:3]) | ||
| 251 | try: | ||
| 252 | refs = start_persistent(rig) | ||
| 253 | panes = list(refs) | ||
| 254 | shell_history(rig, panes) | ||
| 255 | application_wheel(rig, panes) | ||
| 256 | rig.kernel_sizes() | ||
| 257 | rig.assert_cli_untouched() | ||
| 258 | rig.quit() | ||
| 259 | rig.ok('three real PTYs retain geometry, persistent identities and terminal layout state') | ||
| 260 | print('Wheel artifacts:', rig.root) | ||
| 261 | except Exception: | ||
| 262 | rig.failure_artifacts() | ||
| 263 | raise | ||
| 264 | finally: | ||
| 265 | rig.close() | ||
| 266 | |||
| 267 | |||
| 268 | if __name__ == '__main__': | ||
| 269 | main() | ||
test/wayland_pointer.py
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | """Drive the retained virtual-pointer helper on an isolated Sway output. | 1 | """Drive the retained virtual-pointer helper on an isolated Sway output. |
| 2 | 2 | ||
| 3 | MUXG_TEST_POINTER names the helper binary (line protocol: move x y w h, | 3 | MUXG_TEST_POINTER names the helper binary (line protocol: move x y w h, |
| 4 | button 0/1; each command returns ok). A real input serial is required for | 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. | 5 | Wayland clipboard ownership; SDL-injected events cannot establish it. |
| 6 | """ | 6 | """ |
| 7 | import json | 7 | import json |
| @@ -52,6 +52,10 @@ class Pointer: | |||
| 52 | if kind in ('mouseup', 'click'): | 52 | if kind in ('mouseup', 'click'): |
| 53 | self.command('button 0') | 53 | self.command('button 0') |
| 54 | 54 | ||
| 55 | def wheel(self, x, y, notches): | ||
| 56 | self.move(x, y) | ||
| 57 | self.command(f'wheel {notches}') | ||
| 58 | |||
| 55 | def close(self): | 59 | def close(self): |
| 56 | self.proc.stdin.close() | 60 | self.proc.stdin.close() |
| 57 | try: | 61 | try: |