a73x

eedff557

feat: scroll native panes through history and applications

a73x   2026-09-06 09:28

Commit message
feat: scroll native panes through history and applications

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: