0716f07b
feat: keep native selections anchored as output scrolls
a73x 2026-09-06 13:25
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -1516,3 +1516,60 @@ preserved. Recording, page and test evidence remain; cleanup is recorded in | |||
| 1516 | `dist/application-mouse/accepted-demo-teardown.json`. The existing rendering | 1516 | `dist/application-mouse/accepted-demo-teardown.json`. The existing rendering |
| 1517 | budget issue and selection-identity/paste follow-ups remain open. No next sprint | 1517 | budget issue and selection-identity/paste follow-ups remain open. No next sprint |
| 1518 | was started by this sign-off. | 1518 | was started by this sign-off. |
| 1519 | |||
| 1520 | |||
| 1521 | ### Selection follows terminal output — 2026-09-06 | ||
| 1522 | |||
| 1523 | Completed native selections now follow their original terminal occurrence | ||
| 1524 | through primary history and vertical scroll regions. Ctrl+Shift+C resolves the | ||
| 1525 | tracked endpoints; a held copy uses its current range and release registers the | ||
| 1526 | final range. One selection request/reply now covers extract/start/copy/clear for | ||
| 1527 | native, TUI and browser callers. Daemon and clients must be rebuilt together; | ||
| 1528 | compatibility was explicitly excluded. | ||
| 1529 | |||
| 1530 | The daemon owns Ghostty pins and text extraction, the pump owns correlation and | ||
| 1531 | attachment lifetime, and the GUI receives position and grid together. Opening | ||
| 1532 | inspection needed no separate cleanup. Closing cleanup removed the parallel | ||
| 1533 | protocol and unused state. Independent Terra review approved these boundaries; | ||
| 1534 | Luna handled bounded implementation/fixtures, and root integrated, reviewed and | ||
| 1535 | tested actual behavior. Ghostty owns ordinary page movement and eviction. Its | ||
| 1536 | partial-region row-copy/rotation paths need explicit external-pin movement; | ||
| 1537 | REP now reuses the normal print hook rather than cancelling ordinary redraws. | ||
| 1538 | |||
| 1539 | Review fixed an extraction lifetime bug, pin-allocation cleanup, alternate-screen | ||
| 1540 | allocator reuse, and partial-region endpoint movement. Assertions that a pin | ||
| 1541 | still existed hid wrong coordinates: retained selections now assert both moved | ||
| 1542 | coordinates and extracted distinct text. Validate library behavior against the | ||
| 1543 | pinned implementation and compiler, not assumptions about APIs or privacy. | ||
| 1544 | Re-copy acceptance first replaces the clipboard with a sentinel; unchanged | ||
| 1545 | clipboard text alone proves nothing. Fixture header clicks clear selection, so | ||
| 1546 | neighbour output uses the owned PTY directly. Wait for visible Wayland motion | ||
| 1547 | before a separate FIFO key injection; neither channel orders the other. | ||
| 1548 | |||
| 1549 | Final CI passed all 117 e2e scenarios, 10 agent checks and throughput. Native | ||
| 1550 | units/integration and actual NVIDIA Wayland selection, wheel, scale, foot/tmux | ||
| 1551 | and live tmux counter checks passed. A prior CI run compiled while source was | ||
| 1552 | changing; coordinator action: hold the freeze through the entire CI command, | ||
| 1553 | including later agent/throughput builds, not only its check stage. Early failure | ||
| 1554 | logs are retained alongside the successful final runs. | ||
| 1555 | |||
| 1556 | The raw-output frame gate still misses its existing 20 ms budget: 20.716 ms p99. | ||
| 1557 | Output and reopen progressed; sampled input-to-painted upper bounds were at most | ||
| 1558 | 60.4 ms including polling. Do not claim a performance pass or rerun until green. | ||
| 1559 | The 25-second actual GUI demo and review page remain on localhost, pending user | ||
| 1560 | product acceptance. The owned compositor and fixture apps are stopped; stop only | ||
| 1561 | server PID 257482 after verifying ownership on acceptance. Preserve unrelated | ||
| 1562 | demos. Full evidence and teardown are in the selection-follow sprint plan. | ||
| 1563 | |||
| 1564 | Next owners/triggers: | ||
| 1565 | |||
| 1566 | - Renderer owner: resolve the retained NVIDIA presentation budget variability | ||
| 1567 | using the same isolated raw-output fixture; retain the budget and baseline. | ||
| 1568 | - Selection owner, when continuing this feature: held-drag movement, reflow, | ||
| 1569 | character edits, rectangular margins and ranges crossing scroll-region | ||
| 1570 | boundaries need explicit policies. They currently cancel conservatively; | ||
| 1571 | preserve existing clipboard contents and add coordinate-plus-text oracles. | ||
| 1572 | - GUI input owner: paste and composition remain separate slices; ligatures are | ||
| 1573 | still deferred. Platform owner: Linux evidence does not establish macOS. | ||
| 1574 | - Coordinator, on acceptance: record product review separately from architecture | ||
| 1575 | and tests, verify/stop this demo server, and only then begin an authorized slice. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1213,10 +1213,14 @@ 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_selection_follow = b.addSystemCommand(&.{ "python3", "-B", "test/native_selection_follow.py" }); | ||
| 1217 | native_selection_follow.addArtifactArg(mux_exe); | ||
| 1218 | native_selection_follow.addArtifactArg(muxg_exe); | ||
| 1219 | native_selection_follow.step.dependOn(&native_selection.step); | ||
| 1216 | const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" }); | 1220 | const native_wheel = b.addSystemCommand(&.{ "python3", "-B", "test/native_wheel.py" }); |
| 1217 | native_wheel.addArtifactArg(mux_exe); | 1221 | native_wheel.addArtifactArg(mux_exe); |
| 1218 | native_wheel.addArtifactArg(muxg_exe); | 1222 | native_wheel.addArtifactArg(muxg_exe); |
| 1219 | native_wheel.step.dependOn(&native_selection.step); | 1223 | native_wheel.step.dependOn(&native_selection_follow.step); |
| 1220 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); | 1224 | const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); |
| 1221 | const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); | 1225 | const native_mouse = b.addSystemCommand(&.{ "python3", "-B", "test/native_mouse.py" }); |
| 1222 | native_mouse.addArtifactArg(mux_exe); | 1226 | native_mouse.addArtifactArg(mux_exe); |
docs/demos/native-selection-follow.html
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,46 @@ | |||
| 1 | <!doctype html> | ||
| 2 | <html lang="en"> | ||
| 3 | <head> | ||
| 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | <title>Selection follow through terminal output</title> | ||
| 7 | <style> | ||
| 8 | :root { color-scheme:dark; --bg:#101318; --panel:#181d25; --ink:#edf2f7; --muted:#aab5c2; --accent:#7dd3fc; --line:#303947; } | ||
| 9 | * { box-sizing:border-box; } body { margin:0; background:var(--bg); color:var(--ink); font:16px/1.55 system-ui,sans-serif; } | ||
| 10 | main { max-width:1000px; margin:auto; padding:3rem 1.25rem 5rem; } h1 { margin:0 0 .5rem; font-size:clamp(2rem,5vw,3.5rem); line-height:1.05; } h2 { margin-top:2rem; font-size:1.25rem; color:var(--accent); } p { color:var(--muted); } .lede { font-size:1.15rem; max-width:70ch; } | ||
| 11 | .card { margin-top:2rem; padding:1rem; background:var(--panel); border:1px solid var(--line); border-radius:12px; } video { display:block; width:100%; max-height:70vh; background:#080a0d; border-radius:8px; } a { color:var(--accent); } ul { color:var(--muted); padding-left:1.25rem; } li+li { margin-top:.4rem; } code { color:var(--ink); } | ||
| 12 | .status { display:inline-block; padding:.2rem .55rem; border:1px solid #a87927; border-radius:999px; color:#ffd580; font-size:.85rem; } | ||
| 13 | </style> | ||
| 14 | </head> | ||
| 15 | <body> | ||
| 16 | <main> | ||
| 17 | <span class="status">Functional checks passed · product review pending</span> | ||
| 18 | <h1>Selection follows terminal output</h1> | ||
| 19 | <p class="lede">A completed selection keeps naming the same terminal occurrence when new output scrolls it upward. The recording uses a duplicate marker and its distinct successor so a stale coordinate or the other duplicate cannot pass the check.</p> | ||
| 20 | <section class="card"> | ||
| 21 | <video src="demo.mp4" poster="preview.png" controls playsinline preload="metadata"></video> | ||
| 22 | <p><a href="demo.mp4" download>Download the recording</a> · 25 seconds · 960 × 600 · Linux NVIDIA Wayland · no audio</p> | ||
| 23 | </section> | ||
| 24 | <h2>What the recording shows</h2> | ||
| 25 | <ul> | ||
| 26 | <li>A notes pane sits on the left, the source pane has a nonzero origin on the right, and a third live pane remains active.</li> | ||
| 27 | <li>An ordinary Wayland pointer drag selects <code>SOURCE-DUPLICATE</code> and its <code>SOURCE-AFTER-006</code> successor while another duplicate appears elsewhere.</li> | ||
| 28 | <li>Three source rows arrive at one-second intervals. The selected occurrence moves with the terminal output, and its original framebuffer position clears.</li> | ||
| 29 | <li>An idle pane writes a clipboard sentinel through OSC 52. Notes reads that sentinel with independent <code>wl-paste</code>; Ctrl+Shift+C in the source pane then restores the followed selection, read back visibly in notes.</li> | ||
| 30 | </ul> | ||
| 31 | <h2>Controls and input</h2> | ||
| 32 | <p>Ordinary drag selects locally when the application has not requested mouse reporting. Hold Shift at press to select locally inside a mouse-reporting application. Ctrl+Shift+C asks the daemon to copy the completed tracked selection. The video uses genuine Wayland pointer input with a visible cursor; fixture output is emitted through fixture-owned PTYs.</p> | ||
| 33 | <h2>Validation</h2> | ||
| 34 | <p>Native unit and integration gates passed. Real NVIDIA Wayland checks passed for selection, scrolling, scale transitions, shared foot/tmux mouse behavior, and live tmux counters in windowed and fullscreen modes. Web ABI: 798 passed, 0 failed. Full CI passed: 117 end-to-end scenarios, 10 agent checks and throughput. Architectural review has no open blocker; product acceptance is pending.</p> | ||
| 35 | <p>Rebuild both the daemon and client: they share the new selection protocol, with one request/reply path and no compatibility fallback.</p> | ||
| 36 | <h2>Limits and known debt</h2> | ||
| 37 | <ul> | ||
| 38 | <li>Completed selections follow output. A held drag still cancels if its coordinate source moves; copying during a held drag remains supported. Resize/reflow, screen/mode changes and reset clear selection. Character insertion/deletion, rectangular margins and a range crossing a partially scrolled region also clear it. Paste, IME and ligatures are deferred.</li> | ||
| 39 | <li>The alternate screen and mouse-reporting scenario belongs to separate automation and is not claimed by this video.</li> | ||
| 40 | <li>Evidence is Linux-only. macOS behavior remains unverified.</li> | ||
| 41 | <li>The NVIDIA raw-output frame gate remains red: p99 20.7 ms against 20 ms. Output continued and reopen succeeded; sampled input-to-painted time stayed at or below 60.4 ms (including 5 ms polling). Prior-sprint measurements also missed this budget; no performance improvement is claimed.</li> | ||
| 42 | <li>Recording metadata: continuous capture, no cuts, no audio, 5 fps at 200% scale. The recording is a continuous 25-second capture.</li> | ||
| 43 | </ul> | ||
| 44 | </main> | ||
| 45 | </body> | ||
| 46 | </html> | ||
docs/superpowers/plans/2026-09-06-native-selection-follow.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,128 @@ | |||
| 1 | # Native selection follows output | ||
| 2 | |||
| 3 | Status: implemented, functionally validated and architecturally reviewed. | ||
| 4 | Product acceptance pending; existing NVIDIA frame-time debt remains open. | ||
| 5 | |||
| 6 | Goal: a completed local selection follows the same terminal text as output | ||
| 7 | scrolls. This includes Shift+drag in an application using mouse reporting. | ||
| 8 | The existing desktop clipboard stays unchanged until another copy; copying | ||
| 9 | again resolves the tracked text, rather than its former screen position. | ||
| 10 | |||
| 11 | ## Scope and ownership | ||
| 12 | |||
| 13 | | Behavior | Reuse | Owner | | ||
| 14 | | --- | --- | --- | | ||
| 15 | | Terminal identity | Pinned Ghostty Selection.track and PageList pins | Engine wrapper, one owned selection per attached server client | | ||
| 16 | | Text extraction | Engine.extractSelection and existing formatter | Engine | | ||
| 17 | | Correlation and compatibility | Framed protocol, server client slots | Shared protocol and server | | ||
| 18 | | Position and clipboard delivery | Session pump mutex, request tickets and attachment lifetime | Shared client | | ||
| 19 | | Drag and highlight | Existing selection.Drag and pane-relative hit testing | Native interaction | | ||
| 20 | |||
| 21 | No second terminal parser, text matching, or global Ghostty Screen.selection. | ||
| 22 | Opening inspection found no independent cleanup needed; the engine adapter | ||
| 23 | belongs to the feature. Source stays frozen throughout validation before commit. | ||
| 24 | |||
| 25 | The existing selection request/reply now carries a gesture ID, copy request ID, | ||
| 26 | terminal sequence and authoritative source token. Registering against a stale | ||
| 27 | source is refused. Source tokens must advance even when identical rows produce | ||
| 28 | no visual delta. Positions are delivered after the matching terminal frame; | ||
| 29 | the renderer hides an unmatched position until its corresponding state arrives. | ||
| 30 | Copy uses the daemon's tracked endpoints. The user explicitly excluded backward | ||
| 31 | compatibility: clients require the updated daemon. One action field selects | ||
| 32 | extract/start/copy/clear; TUI and browser extraction use the same exchange. | ||
| 33 | There is no parallel legacy request or capability fallback. | ||
| 34 | |||
| 35 | The released range becomes the durable selection. Ctrl+Shift+C during a held | ||
| 36 | drag still copies the current range; later copies and release register their | ||
| 37 | current endpoints. Output moving during a held drag | ||
| 38 | retains conservative cancellation; tracking an unfinished gesture is a later | ||
| 39 | slice. Resize/reflow, screen replacement, disconnection and discarded endpoints | ||
| 40 | invalidate selection and preserve the clipboard. GUI paste and ligatures remain | ||
| 41 | outside this slice. A remote daemon needs rebuilding alongside the client. | ||
| 42 | Character insertion/deletion and rectangular margins retire local selection; | ||
| 43 | a linear range crossing a partially scrolled region also retires rather than | ||
| 44 | claiming to represent unchanged text. | ||
| 45 | |||
| 46 | ## Acceptance | ||
| 47 | |||
| 48 | - Select a line in a pane with nonzero origin; append output until it moves up. | ||
| 49 | Its highlight follows it, and copying again returns the intended text. | ||
| 50 | - Exercise primary history and alternate-screen vertical scrolling, including | ||
| 51 | repeated identical rows and a scroll region. Position follows engine identity. | ||
| 52 | - Shift-local selection sends no application mouse gesture and follows output. | ||
| 53 | - Two client selections remain independent; another pane's output has no effect. | ||
| 54 | - Discarded endpoints, resize, screen switch and reconnect clear the highlight | ||
| 55 | without replacing the clipboard. Late requests cannot select unrelated text. | ||
| 56 | - Retain existing viewport-wheel selection, soft-wrap extraction and app mouse | ||
| 57 | behavior. Verify real GUI pixels and clipboard, plus engine/wire boundaries. | ||
| 58 | |||
| 59 | ## Delivery | ||
| 60 | |||
| 61 | Use the repository sprint gates and a localhost review page with an actual GUI | ||
| 62 | recording. Publishing is optional when requested. Product acceptance remains | ||
| 63 | separate from tests and architectural review. Carry forward the existing NVIDIA | ||
| 64 | frame-time budget miss; retain evidence rather than rerunning until green. | ||
| 65 | |||
| 66 | ## Integrated architecture and review | ||
| 67 | |||
| 68 | The engine adapter owns one pair of Ghostty tracked pins per attached client. | ||
| 69 | Ghostty owns page growth, history eviction, Unicode width and wrapping. Partial | ||
| 70 | vertical operations that copy/rotate row contents need explicit endpoint moves; | ||
| 71 | the adapter uses parsed actions and rebinds the existing pins after those moves. | ||
| 72 | REP delegates each repeated character to the existing print hook using Ghostty's | ||
| 73 | previous character. No client parses VT and no duplicate terminal state exists. | ||
| 74 | |||
| 75 | The server owns tracker teardown, source validation and copy extraction. The | ||
| 76 | pump owns queued/sent correlation, attachment cancellation and matching position | ||
| 77 | metadata to the displayed snapshot or history chunk. The renderer receives grid, | ||
| 78 | origin and position together. During a sequence gap it hides unmatched geometry | ||
| 79 | without destroying the gesture. Clipboard delivery remains separate from | ||
| 80 | unsolicited position updates. | ||
| 81 | |||
| 82 | Closing cleanup consolidated selection into one action-based wire exchange and | ||
| 83 | removed unused follow-state bookkeeping. Direct TUI/browser extraction reuses | ||
| 84 | the same codecs and formatter. Review resolved extraction lifetime, tracked-pin | ||
| 85 | allocation cleanup, alternate-screen allocator reuse, discarded-row tracking, | ||
| 86 | partial-region movement and held-copy regressions. The independent Terra review | ||
| 87 | has no remaining architectural blocker; final gate results remain separate. | ||
| 88 | |||
| 89 | ## Final evidence and handoff | ||
| 90 | |||
| 91 | - `dist/selection-follow/ci-final.log`: full CI passed, including check, | ||
| 92 | 117 e2e scenarios / 38 convergence points, 10 agent checks and throughput. | ||
| 93 | - `native-final-rerun.log`: ReleaseSafe native-core/native tests and the full | ||
| 94 | native integration chain passed. Its offscreen tmux skip is supplemented by | ||
| 95 | the separate actual Wayland fixture below. | ||
| 96 | - `wayland-final.log`: selection, follow-selection, wheel and real | ||
| 97 | 200%/100%/150%/200% scale checks passed with the matched release binaries. | ||
| 98 | - `wayland-tmux-final.log`: three shared foot/tmux mouse checkpoints passed. | ||
| 99 | A later counter assertion exposed ordering between real Wayland motion and | ||
| 100 | the FIFO key hook; the fixture now observes the extended highlight first. | ||
| 101 | `wayland-tmux-counter-final.log` passes held/released copying through tmux in | ||
| 102 | both windowed and fullscreen modes with that independent pixel observation. | ||
| 103 | - `stress-final.log` and retained `stress-result.json`: raw output continued | ||
| 104 | for 30.1 seconds (120.5 MB read), reopen succeeded, and 186 sampled input-to- | ||
| 105 | painted observations stayed at or below 60.4 ms including 5 ms polling. | ||
| 106 | The frame gate remains **red**: p99 20.576/20.716 ms against 20 ms. This is the | ||
| 107 | previously recorded NVIDIA presentation debt, not a performance pass. | ||
| 108 | - Earlier failing logs remain. They include an existing PTY exit timing flake, | ||
| 109 | actual implementation failures fixed before delivery, fixture mistakes, and | ||
| 110 | a CI agent compile caught during a source change. Final CI is a separate, | ||
| 111 | successful frozen-source run. No red result was overwritten as a pass. | ||
| 112 | |||
| 113 | Matched demo binaries were built with `make install | ||
| 114 | INSTDIR=dist/native-follow-release BINDIR=dist/native-follow-bin`; daemon and | ||
| 115 | GUI both use ReleaseSafe. The maintained page is | ||
| 116 | [the selection-follow review page](../../demos/native-selection-follow.html). | ||
| 117 | Its 25-second, 960×600 H.264 recording is continuous at 5 fps, no audio, on | ||
| 118 | NVIDIA Wayland at 200% scale. It shows a real pointer drag, three appended rows, | ||
| 119 | neighbour output and independently read clipboard replacement/recopy. | ||
| 120 | Video decoding, page loading and HTTP byte ranges were verified. | ||
| 121 | |||
| 122 | Local review URL: <http://localhost:18778/selection-follow/>. No publication was | ||
| 123 | requested or created. Server PID 257482 is recorded in | ||
| 124 | `dist/selection-follow/server.json`; after product acceptance verify its command | ||
| 125 | still names this worktree's `dist/selection-follow/serve-page.py`, then run | ||
| 126 | `kill 257482`. Recording and test rigs closed; owned compositor PID 141229 was | ||
| 127 | verified and stopped (`fixture-teardown.json`). Preserve unrelated services and | ||
| 128 | retain page/video/check evidence. Product sign-off has not yet been given. | ||
src/client/client_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -312,12 +312,13 @@ test "client core begins selection with exact request bytes" { | |||
| 312 | }; | 312 | }; |
| 313 | 313 | ||
| 314 | const encoded = core.beginSelection(req); | 314 | const encoded = core.beginSelection(req); |
| 315 | try std.testing.expectEqualSlices(u8, &.{ | 315 | const expected = [_]u8{ |
| 316 | 0x12, 0x34, 0x56, 0x78, | 316 | 0x12, 0x34, 0x56, 0x78, |
| 317 | 0x11, 0x22, 0x33, 0x44, | 317 | 0x11, 0x22, 0x33, 0x44, |
| 318 | 0x55, 0x66, 0x77, 0x88, | 318 | 0x55, 0x66, 0x77, 0x88, |
| 319 | 0x99, 0xaa, 0xbb, 0xcc, | 319 | 0x99, 0xaa, 0xbb, 0xcc, |
| 320 | }, &encoded); | 320 | } ++ ([_]u8{0} ** 21); |
| 321 | try std.testing.expectEqualSlices(u8, &expected, &encoded); | ||
| 321 | try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); | 322 | try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded)); |
| 322 | try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); | 323 | try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id); |
| 323 | } | 324 | } |
| @@ -329,8 +330,15 @@ test "client core ignores stale selection reply then accepts matching reply once | |||
| 329 | .anchor = .{ .row = 1, .col = 2 }, | 330 | .anchor = .{ .row = 1, .col = 2 }, |
| 330 | .active = .{ .row = 3, .col = 4 }, | 331 | .active = .{ .row = 3, .col = 4 }, |
| 331 | }); | 332 | }); |
| 332 | const stale = [_]u8{ 21, 0, 0, 0, 0, 0, 0, 0, 0, 'n', 'o' }; | 333 | var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); |
| 333 | const matching = [_]u8{ 22, 0, 0, 0, 0, 8, 0, 0, 0, 'o', 'k' }; | 334 | std.mem.writeInt(u32, stale[0..4], 21, .little); |
| 335 | stale[proto.selection_reply_prefix_len..][0] = 'n'; | ||
| 336 | stale[proto.selection_reply_prefix_len..][1] = 'o'; | ||
| 337 | var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); | ||
| 338 | std.mem.writeInt(u32, matching[0..4], 22, .little); | ||
| 339 | std.mem.writeInt(u32, matching[5..9], 8, .little); | ||
| 340 | matching[proto.selection_reply_prefix_len..][0] = 'o'; | ||
| 341 | matching[proto.selection_reply_prefix_len..][1] = 'k'; | ||
| 334 | 342 | ||
| 335 | try expectIgnored(core.receive(.selection_reply, &stale)); | 343 | try expectIgnored(core.receive(.selection_reply, &stale)); |
| 336 | try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); | 344 | try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id); |
| @@ -352,9 +360,15 @@ test "client core latest selection begin replaces the older pending id" { | |||
| 352 | .active = .{ .row = 4, .col = 5 }, | 360 | .active = .{ .row = 4, .col = 5 }, |
| 353 | }); | 361 | }); |
| 354 | 362 | ||
| 355 | try expectIgnored(core.receive(.selection_reply, &.{ 7, 0, 0, 0, 0, 0, 0, 0, 0, 'x' })); | 363 | var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 364 | std.mem.writeInt(u32, stale[0..4], 7, .little); | ||
| 365 | stale[proto.selection_reply_prefix_len] = 'x'; | ||
| 366 | try expectIgnored(core.receive(.selection_reply, &stale)); | ||
| 356 | try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); | 367 | try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id); |
| 357 | try expectSelection(core.receive(.selection_reply, &.{ 8, 0, 0, 0, 0, 0, 0, 0, 0, 'y' }), 8, .ok, 0, "y"); | 368 | var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 369 | std.mem.writeInt(u32, matching[0..4], 8, .little); | ||
| 370 | matching[proto.selection_reply_prefix_len] = 'y'; | ||
| 371 | try expectSelection(core.receive(.selection_reply, &matching), 8, .ok, 0, "y"); | ||
| 358 | } | 372 | } |
| 359 | 373 | ||
| 360 | test "client core malformed matching selection reply preserves pending request" { | 374 | test "client core malformed matching selection reply preserves pending request" { |
| @@ -365,9 +379,15 @@ test "client core malformed matching selection reply preserves pending request" | |||
| 365 | .active = .{ .row = 0, .col = 0 }, | 379 | .active = .{ .row = 0, .col = 0 }, |
| 366 | }); | 380 | }); |
| 367 | 381 | ||
| 368 | try expectIgnored(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); | 382 | var malformed = [_]u8{0} ** (proto.selection_reply_prefix_len + 1); |
| 383 | std.mem.writeInt(u32, malformed[0..4], 9, .little); | ||
| 384 | malformed[4] = @intFromEnum(proto.SelectionStatus.invalid); | ||
| 385 | malformed[proto.selection_reply_prefix_len] = 'x'; | ||
| 386 | try expectIgnored(core.receive(.selection_reply, &malformed)); | ||
| 369 | try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); | 387 | try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id); |
| 370 | try expectSelection(core.receive(.selection_reply, &.{ 9, 0, 0, 0, 0, 0, 0, 0, 0 }), 9, .ok, 0, ""); | 388 | var matching = [_]u8{0} ** proto.selection_reply_prefix_len; |
| 389 | std.mem.writeInt(u32, matching[0..4], 9, .little); | ||
| 390 | try expectSelection(core.receive(.selection_reply, &matching), 9, .ok, 0, ""); | ||
| 371 | } | 391 | } |
| 372 | 392 | ||
| 373 | test "client core delivers every matching non-ok selection status with empty text" { | 393 | test "client core delivers every matching non-ok selection status with empty text" { |
| @@ -380,8 +400,9 @@ test "client core delivers every matching non-ok selection status with empty tex | |||
| 380 | .anchor = .{ .row = 0, .col = 0 }, | 400 | .anchor = .{ .row = 0, .col = 0 }, |
| 381 | .active = .{ .row = 0, .col = 0 }, | 401 | .active = .{ .row = 0, .col = 0 }, |
| 382 | }); | 402 | }); |
| 383 | var payload = [_]u8{ 0, 0, 0, 0, @intFromEnum(status), 0, 0, 0, 0 }; | 403 | var payload = [_]u8{0} ** proto.selection_reply_prefix_len; |
| 384 | std.mem.writeInt(u32, payload[0..4], id, .little); | 404 | std.mem.writeInt(u32, payload[0..4], id, .little); |
| 405 | payload[4] = @intFromEnum(status); | ||
| 385 | 406 | ||
| 386 | try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, ""); | 407 | try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, ""); |
| 387 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); | 408 | try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id); |
| @@ -395,7 +416,11 @@ test "client core selection reply text borrows the frame payload" { | |||
| 395 | .anchor = .{ .row = 0, .col = 0 }, | 416 | .anchor = .{ .row = 0, .col = 0 }, |
| 396 | .active = .{ .row = 0, .col = 0 }, | 417 | .active = .{ .row = 0, .col = 0 }, |
| 397 | }); | 418 | }); |
| 398 | var payload = [_]u8{ 1, 0, 0, 0, 0, 4, 0, 0, 0, 'h', 'i' }; | 419 | var payload = [_]u8{0} ** (proto.selection_reply_prefix_len + 2); |
| 420 | std.mem.writeInt(u32, payload[0..4], 1, .little); | ||
| 421 | std.mem.writeInt(u32, payload[5..9], 4, .little); | ||
| 422 | payload[proto.selection_reply_prefix_len..][0] = 'h'; | ||
| 423 | payload[proto.selection_reply_prefix_len..][1] = 'i'; | ||
| 399 | 424 | ||
| 400 | const result = core.receive(.selection_reply, &payload); | 425 | const result = core.receive(.selection_reply, &payload); |
| 401 | payload[proto.selection_reply_prefix_len] = 'H'; | 426 | payload[proto.selection_reply_prefix_len] = 'H'; |
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,9 +12,10 @@ const Wire = @import("buffered_wire.zig").Wire; | |||
| 12 | /// Live selection keeps its range through redraws and copies current text. | 12 | /// Live selection keeps its range through redraws and copies current text. |
| 13 | /// History views retain exact source freshness because their cells are cached. | 13 | /// History views retain exact source freshness because their cells are cached. |
| 14 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. | 14 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. |
| 15 | pub const SelectionVersion = struct { seq: u64, history_rows: u32, epoch: u64, revision: u64, history: bool = false }; | 15 | pub const SelectionVersion = struct { seq: u64, source: u64 = 0, history_rows: u32, epoch: u64, revision: u64, history: bool = false }; |
| 16 | pub const SelectionRequest = struct { | 16 | pub const SelectionRequest = struct { |
| 17 | id: u32, | 17 | id: u32, |
| 18 | gesture: u32 = 0, | ||
| 18 | anchor: proto.SelectionPoint, | 19 | anchor: proto.SelectionPoint, |
| 19 | active: proto.SelectionPoint, | 20 | active: proto.SelectionPoint, |
| 20 | version: SelectionVersion, | 21 | version: SelectionVersion, |
| @@ -59,7 +60,16 @@ const HistoryRequest = struct { | |||
| 59 | revision: u64, | 60 | revision: u64, |
| 60 | until: i64, | 61 | until: i64, |
| 61 | }; | 62 | }; |
| 62 | pub const SelectionResult = struct { id: u32, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; | 63 | pub const SelectionResult = struct { id: u32, gesture: u32 = 0, status: proto.SelectionStatus, text: []u8, version: SelectionVersion }; |
| 64 | pub const FollowPosition = struct { | ||
| 65 | id: u32, | ||
| 66 | seq: u64, | ||
| 67 | source: u64, | ||
| 68 | history_rows: u32, | ||
| 69 | status: proto.SelectionStatus, | ||
| 70 | anchor: proto.SelectionPoint, | ||
| 71 | active: proto.SelectionPoint, | ||
| 72 | }; | ||
| 63 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; | 73 | pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; |
| 64 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; | 74 | pub const EndPhase = enum { idle, pending, accepted, refused, unknown }; |
| 65 | pub const EndState = struct { | 75 | pub const EndState = struct { |
| @@ -119,10 +129,17 @@ pub const Pump = struct { | |||
| 119 | admitted: bool = false, | 129 | admitted: bool = false, |
| 120 | end_until: i64 = 0, // guarded by mu with status.ending | 130 | end_until: i64 = 0, // guarded by mu with status.ending |
| 121 | selection_pending: ?SelectionRequest = null, | 131 | selection_pending: ?SelectionRequest = null, |
| 132 | selection_pending_copy: bool = false, | ||
| 122 | selection_revision: u64 = 0, // mu: connection, geometry and terminal modes | 133 | selection_revision: u64 = 0, // mu: connection, geometry and terminal modes |
| 123 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation | 134 | selection_ticket: u64 = 0, // mu: queued and sent request cancellation |
| 124 | selection_until: i64 = 0, | 135 | selection_until: i64 = 0, |
| 125 | selection_result: ?SelectionResult = null, | 136 | selection_result: ?SelectionResult = null, |
| 137 | selection_gesture: u32 = 0, | ||
| 138 | selection_clear: ?u32 = null, | ||
| 139 | follow_position: ?FollowPosition = null, | ||
| 140 | follow_source: u64 = 0, | ||
| 141 | follow_seq: u64 = 0, | ||
| 142 | history_waiting_metadata: bool = false, | ||
| 126 | scroll_rows: u32 = 0, // mu: requested distance from live output | 143 | scroll_rows: u32 = 0, // mu: requested distance from live output |
| 127 | history: ?*term.grid.Grid = null, | 144 | history: ?*term.grid.Grid = null, |
| 128 | history_start: u32 = 0, | 145 | history_start: u32 = 0, |
| @@ -161,10 +178,13 @@ pub const Pump = struct { | |||
| 161 | .selection => |req| { | 178 | .selection => |req| { |
| 162 | self.mu.lock(); | 179 | self.mu.lock(); |
| 163 | defer self.mu.unlock(); | 180 | defer self.mu.unlock(); |
| 164 | self.invalidateSelectionLocked(); | 181 | self.cancelPendingSelectionLocked(); |
| 165 | var owned = req; | 182 | var owned = req; |
| 183 | if (owned.gesture == 0) owned.gesture = owned.id; | ||
| 166 | owned.ticket = self.selection_ticket; | 184 | owned.ticket = self.selection_ticket; |
| 167 | try self.mailbox.append(self.alloc, .{ .selection = owned }); | 185 | try self.mailbox.append(self.alloc, .{ .selection = owned }); |
| 186 | self.selection_pending = owned; | ||
| 187 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; | ||
| 168 | }, | 188 | }, |
| 169 | .end => |req| { | 189 | .end => |req| { |
| 170 | self.mu.lock(); | 190 | self.mu.lock(); |
| @@ -247,7 +267,7 @@ pub const Pump = struct { | |||
| 247 | /// Caller holds mu while copying both this version and the displayed grid. | 267 | /// Caller holds mu while copying both this version and the displayed grid. |
| 248 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { | 268 | pub fn selectionVersionLocked(self: *const Pump) SelectionVersion { |
| 249 | if (self.history != null) return self.history_version; | 269 | if (self.history != null) return self.history_version; |
| 250 | return .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | 270 | return .{ .seq = self.replica.last_seq, .source = if (self.follow_seq == self.replica.last_seq) self.follow_source else 0, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; |
| 251 | } | 271 | } |
| 252 | pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid { | 272 | pub fn viewGridLocked(self: *const Pump) *const term.grid.Grid { |
| 253 | return self.history orelse self.grid; | 273 | return self.history orelse self.grid; |
| @@ -255,13 +275,23 @@ pub const Pump = struct { | |||
| 255 | pub fn viewOriginLocked(self: *const Pump) u32 { | 275 | pub fn viewOriginLocked(self: *const Pump) u32 { |
| 256 | return if (self.history != null) self.history_start else self.replica.history_rows; | 276 | return if (self.history != null) self.history_start else self.replica.history_rows; |
| 257 | } | 277 | } |
| 278 | pub fn followPositionLocked(self: *const Pump) ?FollowPosition { | ||
| 279 | const position = self.follow_position orelse return null; | ||
| 280 | if (position.id != self.selection_gesture or position.status != .ok or position.seq != self.replica.last_seq) return null; | ||
| 281 | if (self.history != null and (self.history_version.seq != position.seq or | ||
| 282 | self.history_version.source != position.source)) return null; | ||
| 283 | return position; | ||
| 284 | } | ||
| 258 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { | 285 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { |
| 286 | return self.selectionAliveLocked(version) and | ||
| 287 | version.history_rows == self.replica.history_rows and | ||
| 288 | version.source != 0 and version.source == self.follow_source and | ||
| 289 | (!version.history or version.seq == self.replica.last_seq); | ||
| 290 | } | ||
| 291 | fn selectionAliveLocked(self: *const Pump, version: SelectionVersion) bool { | ||
| 259 | return !self.closing.load(.acquire) and self.status.phase == .attached and | 292 | return !self.closing.load(.acquire) and self.status.phase == .attached and |
| 260 | self.replica.state_since_attach and version.epoch == self.replica.session_epoch and | 293 | self.replica.state_since_attach and version.epoch == self.replica.session_epoch and |
| 261 | version.history_rows == self.replica.history_rows and version.revision == self.selection_revision and | 294 | version.revision == self.selection_revision; |
| 262 | (!version.history or version.seq == self.replica.last_seq) and | ||
| 263 | (self.history == null or (self.history_version.seq == self.replica.last_seq and | ||
| 264 | self.history_version.revision == self.selection_revision)); | ||
| 265 | } | 295 | } |
| 266 | fn returnLiveLocked(self: *Pump) void { | 296 | fn returnLiveLocked(self: *Pump) void { |
| 267 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; | 297 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; |
| @@ -270,6 +300,7 @@ pub const Pump = struct { | |||
| 270 | self.history_revision +%= 1; | 300 | self.history_revision +%= 1; |
| 271 | if (self.history) |g| g.deinit(); | 301 | if (self.history) |g| g.deinit(); |
| 272 | self.history = null; | 302 | self.history = null; |
| 303 | self.history_waiting_metadata = false; | ||
| 273 | } | 304 | } |
| 274 | fn requestHistory(self: *Pump, wire: *Wire) !void { | 305 | fn requestHistory(self: *Pump, wire: *Wire) !void { |
| 275 | self.mu.lock(); | 306 | self.mu.lock(); |
| @@ -309,6 +340,7 @@ pub const Pump = struct { | |||
| 309 | self.history = view; | 340 | self.history = view; |
| 310 | self.history_start = origin; | 341 | self.history_start = origin; |
| 311 | self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision, .history = true }; | 342 | self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision, .history = true }; |
| 343 | self.history_waiting_metadata = true; | ||
| 312 | return .changed; | 344 | return .changed; |
| 313 | } | 345 | } |
| 314 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { | 346 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { |
| @@ -316,6 +348,19 @@ pub const Pump = struct { | |||
| 316 | defer self.mu.unlock(); | 348 | defer self.mu.unlock(); |
| 317 | return self.selectionFreshLocked(version); | 349 | return self.selectionFreshLocked(version); |
| 318 | } | 350 | } |
| 351 | pub fn selectionAlive(self: *Pump, gesture: u32, version: SelectionVersion) bool { | ||
| 352 | self.mu.lock(); | ||
| 353 | defer self.mu.unlock(); | ||
| 354 | if (!self.selectionAliveLocked(version)) return false; | ||
| 355 | if (self.selection_pending) |pending| { | ||
| 356 | if ((if (pending.gesture == 0) pending.id else pending.gesture) == gesture) return true; | ||
| 357 | } | ||
| 358 | if (self.selection_result) |result| if (result.gesture == gesture) return true; | ||
| 359 | if (self.follow_position) |position| { | ||
| 360 | if (self.selection_gesture == gesture and position.id == gesture and position.status == .ok) return true; | ||
| 361 | } | ||
| 362 | return false; | ||
| 363 | } | ||
| 319 | /// Validate again under the same lock that transfers ownership. A newer | 364 | /// Validate again under the same lock that transfers ownership. A newer |
| 320 | /// frame after reply decoding must not leave an old copy ready for the UI. | 365 | /// frame after reply decoding must not leave an old copy ready for the UI. |
| 321 | pub fn takeSelection(self: *Pump) ?SelectionResult { | 366 | pub fn takeSelection(self: *Pump) ?SelectionResult { |
| @@ -323,15 +368,22 @@ pub const Pump = struct { | |||
| 323 | defer self.mu.unlock(); | 368 | defer self.mu.unlock(); |
| 324 | const result = self.selection_result orelse return null; | 369 | const result = self.selection_result orelse return null; |
| 325 | self.selection_result = null; | 370 | self.selection_result = null; |
| 326 | if (!self.selectionFreshLocked(result.version)) { | 371 | if (!self.selectionAliveLocked(result.version)) { |
| 327 | self.alloc.free(result.text); | 372 | self.alloc.free(result.text); |
| 328 | return null; | 373 | return null; |
| 329 | } | 374 | } |
| 330 | return result; | 375 | return result; |
| 331 | } | 376 | } |
| 332 | fn invalidateSelectionLocked(self: *Pump) void { | 377 | fn invalidateSelectionLocked(self: *Pump) void { |
| 378 | if (self.selection_gesture != 0) self.selection_clear = self.selection_gesture; | ||
| 379 | self.selection_gesture = 0; | ||
| 380 | self.follow_position = null; | ||
| 381 | self.cancelPendingSelectionLocked(); | ||
| 382 | } | ||
| 383 | fn cancelPendingSelectionLocked(self: *Pump) void { | ||
| 333 | self.selection_ticket +%= 1; | 384 | self.selection_ticket +%= 1; |
| 334 | self.selection_pending = null; | 385 | self.selection_pending = null; |
| 386 | self.selection_pending_copy = false; | ||
| 335 | self.selection_until = 0; | 387 | self.selection_until = 0; |
| 336 | self.core.pending_selection_id = null; | 388 | self.core.pending_selection_id = null; |
| 337 | if (self.selection_result) |result| self.alloc.free(result.text); | 389 | if (self.selection_result) |result| self.alloc.free(result.text); |
| @@ -339,14 +391,48 @@ pub const Pump = struct { | |||
| 339 | } | 391 | } |
| 340 | pub fn cancelSelection(self: *Pump) void { | 392 | pub fn cancelSelection(self: *Pump) void { |
| 341 | self.mu.lock(); | 393 | self.mu.lock(); |
| 342 | defer self.mu.unlock(); | ||
| 343 | self.invalidateSelectionLocked(); | 394 | self.invalidateSelectionLocked(); |
| 395 | self.mu.unlock(); | ||
| 396 | ring(self.wake_pipe[1], 1); | ||
| 397 | } | ||
| 398 | fn flushSelectionClear(self: *Pump, wire: *Wire) !void { | ||
| 399 | self.mu.lock(); | ||
| 400 | const gesture = self.selection_clear; | ||
| 401 | self.selection_clear = null; | ||
| 402 | self.mu.unlock(); | ||
| 403 | if (gesture) |id| { | ||
| 404 | const clear = proto.encodeSelectionReq(.{ | ||
| 405 | .action = .clear, | ||
| 406 | .id = 0, | ||
| 407 | .gesture = id, | ||
| 408 | .epoch = 0, | ||
| 409 | .source = 0, | ||
| 410 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 411 | .active = .{ .row = 0, .col = 0 }, | ||
| 412 | }); | ||
| 413 | try wire.send(.selection_req, &clear); | ||
| 414 | } | ||
| 344 | } | 415 | } |
| 345 | fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { | 416 | fn beginSelectionLocked(self: *Pump, req: SelectionRequest) ?[proto.selection_req_len]u8 { |
| 346 | if (req.ticket != self.selection_ticket or !self.selectionFreshLocked(req.version)) return null; | 417 | const gesture = if (req.gesture == 0) req.id else req.gesture; |
| 418 | const action: @FieldType(proto.SelectionReq, "action") = | ||
| 419 | if (self.selection_gesture == gesture) .copy else .start; | ||
| 420 | if (req.ticket != self.selection_ticket or | ||
| 421 | (if (action == .copy) !self.selectionAliveLocked(req.version) else !self.selectionFreshLocked(req.version))) return null; | ||
| 422 | self.selection_gesture = gesture; | ||
| 423 | if (action == .start) self.follow_position = null; | ||
| 347 | self.selection_pending = req; | 424 | self.selection_pending = req; |
| 425 | self.selection_pending_copy = action == .copy; | ||
| 348 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; | 426 | self.selection_until = std.time.milliTimestamp() + self.opts.selection_timeout_ms; |
| 349 | return self.core.beginSelection(.{ .id = req.id, .anchor = req.anchor, .active = req.active }); | 427 | return proto.encodeSelectionReq(.{ |
| 428 | .action = action, | ||
| 429 | .id = req.id, | ||
| 430 | .gesture = gesture, | ||
| 431 | .epoch = req.version.epoch, | ||
| 432 | .source = req.version.source, | ||
| 433 | .anchor = req.anchor, | ||
| 434 | .active = req.active, | ||
| 435 | }); | ||
| 350 | } | 436 | } |
| 351 | 437 | ||
| 352 | fn wake(self: *Pump) void { | 438 | fn wake(self: *Pump) void { |
| @@ -435,6 +521,12 @@ pub const Pump = struct { | |||
| 435 | self.clearClipboardLocked(); | 521 | self.clearClipboardLocked(); |
| 436 | self.invalidateSelectionLocked(); | 522 | self.invalidateSelectionLocked(); |
| 437 | self.selection_revision +%= 1; | 523 | self.selection_revision +%= 1; |
| 524 | self.follow_position = null; | ||
| 525 | self.follow_source = 0; | ||
| 526 | self.follow_seq = 0; | ||
| 527 | self.history_waiting_metadata = false; | ||
| 528 | self.selection_gesture = 0; | ||
| 529 | self.selection_clear = null; | ||
| 438 | self.returnLiveLocked(); | 530 | self.returnLiveLocked(); |
| 439 | if (!fresh) self.history_pending = null; | 531 | if (!fresh) self.history_pending = null; |
| 440 | const args = self.replica.attachArgs(); | 532 | const args = self.replica.attachArgs(); |
| @@ -456,6 +548,7 @@ pub const Pump = struct { | |||
| 456 | for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); | 548 | for (messages.items[0..consumed]) |msg| if (msg == .input) self.alloc.free(msg.input); |
| 457 | messages.deinit(self.alloc); | 549 | messages.deinit(self.alloc); |
| 458 | } | 550 | } |
| 551 | try self.flushSelectionClear(wire); | ||
| 459 | for (messages.items, 0..) |msg, i| { | 552 | for (messages.items, 0..) |msg, i| { |
| 460 | if (msg == .wheel and !allow_wheel) { | 553 | if (msg == .wheel and !allow_wheel) { |
| 461 | // Keep the wheel and following input ordered, while allowing | 554 | // Keep the wheel and following input ordered, while allowing |
| @@ -512,7 +605,10 @@ pub const Pump = struct { | |||
| 512 | self.mu.unlock(); | 605 | self.mu.unlock(); |
| 513 | self.opts.cols = size.cols; | 606 | self.opts.cols = size.cols; |
| 514 | self.opts.rows = size.rows; | 607 | self.opts.rows = size.rows; |
| 515 | if (!self.opts.existing_only or self.admitted) { | 608 | self.mu.lock(); |
| 609 | const admitted = self.admitted; | ||
| 610 | self.mu.unlock(); | ||
| 611 | if (!self.opts.existing_only or admitted) { | ||
| 516 | const buf = proto.encodeSize(size.cols, size.rows); | 612 | const buf = proto.encodeSize(size.cols, size.rows); |
| 517 | try wire.send(.resize, &buf); | 613 | try wire.send(.resize, &buf); |
| 518 | } | 614 | } |
| @@ -520,6 +616,10 @@ pub const Pump = struct { | |||
| 520 | .quit, .detach => unreachable, | 616 | .quit, .detach => unreachable, |
| 521 | } | 617 | } |
| 522 | } | 618 | } |
| 619 | // Input/resize can retire a tracker while this batch is being sent. | ||
| 620 | // Flush after the batch as well so a quiet connection still receives | ||
| 621 | // the bounded clear request. | ||
| 622 | try self.flushSelectionClear(wire); | ||
| 523 | } | 623 | } |
| 524 | 624 | ||
| 525 | fn writeMouse(wire: *Wire, event: Mouse) !bool { | 625 | fn writeMouse(wire: *Wire, event: Mouse) !bool { |
| @@ -648,12 +748,16 @@ pub const Pump = struct { | |||
| 648 | .frame => |frame| { | 748 | .frame => |frame| { |
| 649 | defer frame.deinit(self.alloc); | 749 | defer frame.deinit(self.alloc); |
| 650 | const action = try self.onFrame(frame.type, frame.payload); | 750 | const action = try self.onFrame(frame.type, frame.payload); |
| 751 | var admitted_now = false; | ||
| 752 | self.mu.lock(); | ||
| 651 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { | 753 | if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) { |
| 652 | self.admitted = true; | 754 | self.admitted = true; |
| 653 | if (self.opts.existing_only) { | 755 | admitted_now = true; |
| 654 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | 756 | } |
| 655 | try wire.send(.resize, &size); | 757 | self.mu.unlock(); |
| 656 | } | 758 | if (admitted_now and self.opts.existing_only) { |
| 759 | const size = proto.encodeSize(self.opts.cols, self.opts.rows); | ||
| 760 | try wire.send(.resize, &size); | ||
| 657 | } | 761 | } |
| 658 | changed = changed or action != .skip; | 762 | changed = changed or action != .skip; |
| 659 | switch (action) { | 763 | switch (action) { |
| @@ -715,6 +819,7 @@ pub const Pump = struct { | |||
| 715 | fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { | 819 | fn onFrame(self: *Pump, kind: proto.MsgType, payload: []const u8) !Action { |
| 716 | self.mu.lock(); | 820 | self.mu.lock(); |
| 717 | defer self.mu.unlock(); | 821 | defer self.mu.unlock(); |
| 822 | if (kind != .selection_reply) self.history_waiting_metadata = false; | ||
| 718 | switch (kind) { | 823 | switch (kind) { |
| 719 | .scrollback_chunk => return self.historyReplyLocked(payload), | 824 | .scrollback_chunk => return self.historyReplyLocked(payload), |
| 720 | .end_reply => { | 825 | .end_reply => { |
| @@ -727,7 +832,6 @@ pub const Pump = struct { | |||
| 727 | .snapshot, .delta => { | 832 | .snapshot, .delta => { |
| 728 | const begin = std.time.nanoTimestamp(); | 833 | const begin = std.time.nanoTimestamp(); |
| 729 | const old_epoch = self.replica.session_epoch; | 834 | const old_epoch = self.replica.session_epoch; |
| 730 | const old_history_rows = self.replica.history_rows; | ||
| 731 | const old_cols = self.grid.cols; | 835 | const old_cols = self.grid.cols; |
| 732 | const old_rows = self.grid.rows; | 836 | const old_rows = self.grid.rows; |
| 733 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { | 837 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { |
| @@ -735,8 +839,10 @@ pub const Pump = struct { | |||
| 735 | else => return err, | 839 | else => return err, |
| 736 | }; | 840 | }; |
| 737 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | 841 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); |
| 738 | if (applied == .resync or self.history != null or | 842 | // Output and scrollback growth preserve a tracked source. Only |
| 739 | self.replica.session_epoch != old_epoch or self.replica.history_rows != old_history_rows or | 843 | // a resync, session epoch, or geometry change invalidates its |
| 844 | // coordinates; the follow-state reply remaps ordinary output. | ||
| 845 | if (applied == .resync or self.replica.session_epoch != old_epoch or | ||
| 740 | self.grid.cols != old_cols or self.grid.rows != old_rows) | 846 | self.grid.cols != old_cols or self.grid.rows != old_rows) |
| 741 | { | 847 | { |
| 742 | self.selection_revision +%= 1; | 848 | self.selection_revision +%= 1; |
| @@ -765,20 +871,54 @@ pub const Pump = struct { | |||
| 765 | return .end; | 871 | return .end; |
| 766 | }, | 872 | }, |
| 767 | .selection_reply => { | 873 | .selection_reply => { |
| 768 | const pending = self.selection_pending orelse return .skip; | 874 | const reply = proto.decodeSelectionReply(payload) catch return .skip; |
| 769 | const result = self.core.receive(kind, payload); | 875 | if (reply.seq == self.replica.last_seq) { |
| 770 | const reply = switch (result) { | 876 | self.follow_seq = reply.seq; |
| 771 | .reply => |r| r.selection, | 877 | self.follow_source = reply.source; |
| 772 | else => return .skip, | 878 | // The server emits this position metadata immediately after |
| 773 | }; | 879 | // a history chunk. Bind that chunk to the source that was |
| 774 | if (!self.selectionFreshLocked(pending.version) or reply.history_rows != pending.version.history_rows) { | 880 | // actually rendered; never borrow a later live source. |
| 881 | if (self.history != null and self.history_waiting_metadata and reply.id == 0) { | ||
| 882 | self.history_version.source = reply.source; | ||
| 883 | self.history_version.seq = reply.seq; | ||
| 884 | self.history_version.history_rows = reply.history_rows; | ||
| 885 | self.history_waiting_metadata = false; | ||
| 886 | } | ||
| 887 | if (reply.gesture != 0) { | ||
| 888 | const matches = self.selection_gesture == reply.gesture; | ||
| 889 | if (matches) self.follow_position = .{ | ||
| 890 | .id = reply.gesture, | ||
| 891 | .seq = reply.seq, | ||
| 892 | .source = reply.source, | ||
| 893 | .history_rows = reply.history_rows, | ||
| 894 | .status = if (reply.status == .too_large) .ok else reply.status, | ||
| 895 | .anchor = reply.anchor, | ||
| 896 | .active = reply.active, | ||
| 897 | }; | ||
| 898 | } else if (self.selection_gesture != 0) { | ||
| 899 | // A zero-gesture state is the daemon's explicit | ||
| 900 | // tracker retirement (metadata before a gesture has | ||
| 901 | // no tracker to retire). | ||
| 902 | self.follow_position = null; | ||
| 903 | } | ||
| 904 | } | ||
| 905 | const pending = self.selection_pending orelse return .changed; | ||
| 906 | const gesture = if (pending.gesture == 0) pending.id else pending.gesture; | ||
| 907 | if (reply.id != pending.id or reply.gesture != gesture) return .changed; | ||
| 908 | const pending_alive = if (self.selection_pending_copy) | ||
| 909 | self.selectionAliveLocked(pending.version) | ||
| 910 | else | ||
| 911 | self.selectionFreshLocked(pending.version); | ||
| 912 | if (!pending_alive or reply.seq != self.replica.last_seq or | ||
| 913 | (!self.selection_pending_copy and pending.version.source != reply.source)) | ||
| 914 | { | ||
| 775 | self.selection_pending = null; | 915 | self.selection_pending = null; |
| 776 | return .skip; | 916 | return .changed; |
| 777 | } | 917 | } |
| 778 | self.selection_pending = null; | ||
| 779 | const text = try self.alloc.dupe(u8, reply.text); | 918 | const text = try self.alloc.dupe(u8, reply.text); |
| 919 | self.selection_pending = null; | ||
| 780 | if (self.selection_result) |old| self.alloc.free(old.text); | 920 | if (self.selection_result) |old| self.alloc.free(old.text); |
| 781 | self.selection_result = .{ .id = reply.id, .status = reply.status, .text = text, .version = pending.version }; | 921 | self.selection_result = .{ .id = reply.id, .gesture = gesture, .status = reply.status, .text = text, .version = pending.version }; |
| 782 | return .changed; | 922 | return .changed; |
| 783 | }, | 923 | }, |
| 784 | else => switch (self.core.receive(kind, payload)) { | 924 | else => switch (self.core.receive(kind, payload)) { |
| @@ -833,7 +973,7 @@ pub const Pump = struct { | |||
| 833 | if (expired) { | 973 | if (expired) { |
| 834 | const pending = self.selection_pending.?; | 974 | const pending = self.selection_pending.?; |
| 835 | self.invalidateSelectionLocked(); | 975 | self.invalidateSelectionLocked(); |
| 836 | self.selection_result = .{ .id = pending.id, .status = .unavailable, .text = &.{}, .version = pending.version }; | 976 | self.selection_result = .{ .id = pending.id, .gesture = if (pending.gesture == 0) pending.id else pending.gesture, .status = .unavailable, .text = &.{}, .version = pending.version }; |
| 837 | } | 977 | } |
| 838 | self.mu.unlock(); | 978 | self.mu.unlock(); |
| 839 | if (expired) self.wake(); | 979 | if (expired) self.wake(); |
| @@ -1473,6 +1613,10 @@ fn selectionTestPump() !*Pump { | |||
| 1473 | const p = try a.create(Pump); | 1613 | const p = try a.create(Pump); |
| 1474 | p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp }; | 1614 | p.* = .{ .alloc = a, .opts = .{ .target = .{ .sock = "unused" }, .cols = 11, .rows = 3 }, .grid = g, .replica = .init(a, g), .wake_pipe = wp, .cancel_pipe = cp }; |
| 1475 | _ = try p.onFrame(.snapshot, &testSnapshot()); | 1615 | _ = try p.onFrame(.snapshot, &testSnapshot()); |
| 1616 | // The unified native path requires a nonzero follow source. Tests model | ||
| 1617 | // the metadata frame that the daemon sends after the initial snapshot. | ||
| 1618 | p.follow_seq = p.replica.last_seq; | ||
| 1619 | p.follow_source = 1; | ||
| 1476 | return p; | 1620 | return p; |
| 1477 | } | 1621 | } |
| 1478 | fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest { | 1622 | fn selectionTestQueue(p: *Pump, id: u32) !SelectionRequest { |
| @@ -1492,11 +1636,57 @@ fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void { | |||
| 1492 | defer payload.deinit(std.testing.allocator); | 1636 | defer payload.deinit(std.testing.allocator); |
| 1493 | p.mu.lock(); | 1637 | p.mu.lock(); |
| 1494 | const history_rows = p.replica.history_rows; | 1638 | const history_rows = p.replica.history_rows; |
| 1639 | const gesture = if (p.selection_pending) |pending| if (pending.gesture == 0) pending.id else pending.gesture else id; | ||
| 1495 | p.mu.unlock(); | 1640 | p.mu.unlock(); |
| 1496 | try proto.encodeSelectionReply(&payload, std.testing.allocator, id, .ok, history_rows, text); | 1641 | try proto.encodeSelectionReply(&payload, std.testing.allocator, .{ .id = id, .gesture = gesture, .seq = p.replica.last_seq, .source = p.follow_source, .history_rows = history_rows, .status = .ok, .text = text }); |
| 1497 | _ = try p.onFrame(.selection_reply, payload.items); | 1642 | _ = try p.onFrame(.selection_reply, payload.items); |
| 1498 | @memset(payload.items, 0xaa); // The published result must own its text. | 1643 | @memset(payload.items, 0xaa); // The published result must own its text. |
| 1499 | } | 1644 | } |
| 1645 | fn selectionTestFollowMetadata(p: *Pump, source: u64) !void { | ||
| 1646 | var payload: std.ArrayList(u8) = .empty; | ||
| 1647 | defer payload.deinit(std.testing.allocator); | ||
| 1648 | try proto.encodeSelectionReply(&payload, std.testing.allocator, .{ | ||
| 1649 | .id = 0, | ||
| 1650 | .gesture = 0, | ||
| 1651 | .seq = p.replica.last_seq, | ||
| 1652 | .source = source, | ||
| 1653 | .history_rows = p.replica.history_rows, | ||
| 1654 | .status = .ok, | ||
| 1655 | .text = "", | ||
| 1656 | }); | ||
| 1657 | _ = try p.onFrame(.selection_reply, payload.items); | ||
| 1658 | } | ||
| 1659 | |||
| 1660 | test "follow selection sends a source-bound start then a gesture-bound copy" { | ||
| 1661 | const p = try selectionTestPump(); | ||
| 1662 | defer p.stop(); | ||
| 1663 | p.admitted = true; | ||
| 1664 | p.follow_seq = p.replica.last_seq; | ||
| 1665 | p.follow_source = 91; | ||
| 1666 | |||
| 1667 | const version = p.selectionVersionLocked(); | ||
| 1668 | const first: SelectionRequest = .{ | ||
| 1669 | .id = 7, | ||
| 1670 | .gesture = 42, | ||
| 1671 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1672 | .active = .{ .row = 1, .col = 3 }, | ||
| 1673 | .version = version, | ||
| 1674 | .ticket = p.selection_ticket, | ||
| 1675 | }; | ||
| 1676 | const start = p.beginSelectionLocked(first).?; | ||
| 1677 | const start_req = try proto.decodeSelectionReq(&start); | ||
| 1678 | try std.testing.expectEqual(.start, start_req.action); | ||
| 1679 | try std.testing.expectEqual(@as(u32, 7), start_req.id); | ||
| 1680 | try std.testing.expectEqual(@as(u32, 42), start_req.gesture); | ||
| 1681 | try std.testing.expectEqual(@as(u64, 91), start_req.source); | ||
| 1682 | |||
| 1683 | const second = SelectionRequest{ .id = 8, .gesture = 42, .anchor = first.anchor, .active = first.active, .version = version, .ticket = p.selection_ticket }; | ||
| 1684 | const copy = p.beginSelectionLocked(second).?; | ||
| 1685 | const copy_req = try proto.decodeSelectionReq(©); | ||
| 1686 | try std.testing.expectEqual(.copy, copy_req.action); | ||
| 1687 | try std.testing.expectEqual(@as(u32, 8), copy_req.id); | ||
| 1688 | try std.testing.expectEqual(@as(u32, 42), copy_req.gesture); | ||
| 1689 | } | ||
| 1500 | 1690 | ||
| 1501 | test "selection cancels queued work but retains live requests through redraw" { | 1691 | test "selection cancels queued work but retains live requests through redraw" { |
| 1502 | const p = try selectionTestPump(); | 1692 | const p = try selectionTestPump(); |
| @@ -1508,6 +1698,8 @@ test "selection cancels queued work but retains live requests through redraw" { | |||
| 1508 | var newer = testSnapshot(); | 1698 | var newer = testSnapshot(); |
| 1509 | std.mem.writeInt(u64, newer[0..8], 38, .little); | 1699 | std.mem.writeInt(u64, newer[0..8], 38, .little); |
| 1510 | _ = try p.onFrame(.snapshot, &newer); | 1700 | _ = try p.onFrame(.snapshot, &newer); |
| 1701 | p.follow_seq = p.replica.last_seq; | ||
| 1702 | p.follow_source = 1; | ||
| 1511 | try std.testing.expect(selectionTestBegin(p, before_redraw) != null); | 1703 | try std.testing.expect(selectionTestBegin(p, before_redraw) != null); |
| 1512 | const current = try selectionTestQueue(p, 3); | 1704 | const current = try selectionTestQueue(p, 3); |
| 1513 | const bytes = selectionTestBegin(p, current).?; | 1705 | const bytes = selectionTestBegin(p, current).?; |
| @@ -1548,6 +1740,8 @@ test "live selection survives pending redraw; history and geometry remain guarde | |||
| 1548 | var newer = testSnapshot(); | 1740 | var newer = testSnapshot(); |
| 1549 | std.mem.writeInt(u64, newer[0..8], 38, .little); | 1741 | std.mem.writeInt(u64, newer[0..8], 38, .little); |
| 1550 | _ = try p.onFrame(.snapshot, &newer); | 1742 | _ = try p.onFrame(.snapshot, &newer); |
| 1743 | p.follow_seq = p.replica.last_seq; | ||
| 1744 | p.follow_source = 1; | ||
| 1551 | try std.testing.expect(p.selectionFresh(req.version)); | 1745 | try std.testing.expect(p.selectionFresh(req.version)); |
| 1552 | try selectionTestReply(p, 1, "current text"); | 1746 | try selectionTestReply(p, 1, "current text"); |
| 1553 | const result = p.takeSelection().?; | 1747 | const result = p.takeSelection().?; |
| @@ -1635,6 +1829,7 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1635 | defer fresh.deinit(std.testing.allocator); | 1829 | defer fresh.deinit(std.testing.allocator); |
| 1636 | try std.testing.expect(p.history_pending.?.revision != revision); | 1830 | try std.testing.expect(p.history_pending.?.revision != revision); |
| 1637 | try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk)); | 1831 | try std.testing.expectEqual(Pump.Action.changed, try p.onFrame(.scrollback_chunk, &chunk)); |
| 1832 | try selectionTestFollowMetadata(p, 1); | ||
| 1638 | try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); | 1833 | try std.testing.expectEqual(@as(u32, 17), p.viewOriginLocked()); |
| 1639 | try std.testing.expect(p.viewGridLocked() != p.grid); | 1834 | try std.testing.expect(p.viewGridLocked() != p.grid); |
| 1640 | try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); | 1835 | try std.testing.expectEqual(@as(u16, 3), p.viewGridLocked().cursor.y); |
| @@ -1654,6 +1849,7 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1654 | const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | 1849 | const restored = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; |
| 1655 | defer restored.deinit(std.testing.allocator); | 1850 | defer restored.deinit(std.testing.allocator); |
| 1656 | _ = try p.onFrame(.scrollback_chunk, &chunk); | 1851 | _ = try p.onFrame(.scrollback_chunk, &chunk); |
| 1852 | try selectionTestFollowMetadata(p, 1); | ||
| 1657 | try std.testing.expect(p.selectionFresh(history_version)); | 1853 | try std.testing.expect(p.selectionFresh(history_version)); |
| 1658 | const history_request = try selectionTestQueue(p, 42); | 1854 | const history_request = try selectionTestQueue(p, 42); |
| 1659 | try std.testing.expect(history_request.version.history); | 1855 | try std.testing.expect(history_request.version.history); |
| @@ -1672,8 +1868,9 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1672 | const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | 1868 | const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; |
| 1673 | defer after_live.deinit(std.testing.allocator); | 1869 | defer after_live.deinit(std.testing.allocator); |
| 1674 | _ = try p.onFrame(.scrollback_chunk, &chunk); | 1870 | _ = try p.onFrame(.scrollback_chunk, &chunk); |
| 1871 | try selectionTestFollowMetadata(p, 1); | ||
| 1675 | var newer = testSnapshot(); | 1872 | var newer = testSnapshot(); |
| 1676 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | 1873 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 39, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); |
| 1677 | _ = try p.onFrame(.snapshot, &newer); | 1874 | _ = try p.onFrame(.snapshot, &newer); |
| 1678 | try std.testing.expect(p.history_dirty); | 1875 | try std.testing.expect(p.history_dirty); |
| 1679 | try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked())); | 1876 | try std.testing.expect(!p.selectionFresh(p.selectionVersionLocked())); |
| @@ -1681,9 +1878,14 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1681 | const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | 1878 | const refresh = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; |
| 1682 | defer refresh.deinit(std.testing.allocator); | 1879 | defer refresh.deinit(std.testing.allocator); |
| 1683 | _ = try p.onFrame(.scrollback_chunk, &chunk); | 1880 | _ = try p.onFrame(.scrollback_chunk, &chunk); |
| 1881 | try selectionTestFollowMetadata(p, 1); | ||
| 1684 | try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); | 1882 | try std.testing.expect(p.selectionFresh(p.selectionVersionLocked())); |
| 1685 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true })); | 1883 | _ = try p.onFrame(.term_modes, &proto.encodeTermModes(.{ .bracketed_paste = false, .cursor_keys = true })); |
| 1686 | try std.testing.expect(p.history != null and p.history_dirty); | 1884 | try std.testing.expect(p.history != null and p.history_dirty); |
| 1885 | try p.mail(&wire, true); | ||
| 1886 | const cleared = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1887 | defer cleared.deinit(std.testing.allocator); | ||
| 1888 | try std.testing.expectEqual(proto.MsgType.selection_req, cleared.type); | ||
| 1687 | try p.requestHistory(&wire); | 1889 | try p.requestHistory(&wire); |
| 1688 | const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | 1890 | const before_resize = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; |
| 1689 | defer before_resize.deinit(std.testing.allocator); | 1891 | defer before_resize.deinit(std.testing.allocator); |
| @@ -1695,12 +1897,12 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1695 | try std.testing.expect(p.history_pending != null and p.history == null); | 1897 | try std.testing.expect(p.history_pending != null and p.history == null); |
| 1696 | _ = try p.onFrame(.scrollback_chunk, &chunk); | 1898 | _ = try p.onFrame(.scrollback_chunk, &chunk); |
| 1697 | try std.testing.expect(p.history == null); | 1899 | try std.testing.expect(p.history == null); |
| 1698 | try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); | 1900 | try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq); |
| 1699 | p.scroll_rows = 3; | 1901 | p.scroll_rows = 3; |
| 1700 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | 1902 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; |
| 1701 | try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire)); | 1903 | try std.testing.expectError(error.ConnectionTimedOut, p.requestHistory(&wire)); |
| 1702 | try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 })); | 1904 | try std.testing.expectError(error.BadPayload, p.onFrame(.scrollback_chunk, &.{ 17, 0, 0, 0, 4, 0 })); |
| 1703 | try std.testing.expectEqual(@as(u64, 38), p.replica.last_seq); | 1905 | try std.testing.expectEqual(@as(u64, 39), p.replica.last_seq); |
| 1704 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; | 1906 | p.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = p.history_revision, .until = 0 }; |
| 1705 | try p.attach(&wire, true); | 1907 | try p.attach(&wire, true); |
| 1706 | try std.testing.expect(p.history_pending != null); | 1908 | try std.testing.expect(p.history_pending != null); |
| @@ -1710,6 +1912,77 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1710 | try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0); | 1912 | try std.testing.expect(p.history == null and p.history_pending == null and p.scroll_rows == 0); |
| 1711 | } | 1913 | } |
| 1712 | 1914 | ||
| 1915 | test "selection tracker survives source movement while history source stays paired" { | ||
| 1916 | const p = try selectionTestPump(); | ||
| 1917 | defer p.stop(); | ||
| 1918 | p.admitted = true; | ||
| 1919 | |||
| 1920 | const start = try selectionTestQueue(p, 7); | ||
| 1921 | try std.testing.expect(p.selectionAlive(7, start.version)); | ||
| 1922 | _ = selectionTestBegin(p, start).?; | ||
| 1923 | |||
| 1924 | var position: std.ArrayList(u8) = .empty; | ||
| 1925 | defer position.deinit(std.testing.allocator); | ||
| 1926 | try proto.encodeSelectionReply(&position, std.testing.allocator, .{ | ||
| 1927 | .id = 0, | ||
| 1928 | .gesture = 7, | ||
| 1929 | .seq = p.replica.last_seq, | ||
| 1930 | .source = 1, | ||
| 1931 | .history_rows = p.replica.history_rows, | ||
| 1932 | .status = .ok, | ||
| 1933 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1934 | .active = .{ .row = 0, .col = 1 }, | ||
| 1935 | .text = "", | ||
| 1936 | }); | ||
| 1937 | _ = try p.onFrame(.selection_reply, position.items); | ||
| 1938 | var redraw = testSnapshot(); | ||
| 1939 | std.mem.writeInt(u64, redraw[0..8], p.replica.last_seq + 1, .little); | ||
| 1940 | _ = try p.onFrame(.snapshot, &redraw); | ||
| 1941 | try std.testing.expect(p.followPositionLocked() == null); | ||
| 1942 | try std.testing.expect(p.selectionAlive(7, start.version)); | ||
| 1943 | position.clearRetainingCapacity(); | ||
| 1944 | try proto.encodeSelectionReply(&position, std.testing.allocator, .{ | ||
| 1945 | .id = 0, | ||
| 1946 | .gesture = 7, | ||
| 1947 | .seq = p.replica.last_seq, | ||
| 1948 | .source = 2, | ||
| 1949 | .history_rows = p.replica.history_rows, | ||
| 1950 | .status = .ok, | ||
| 1951 | .anchor = .{ .row = 0, .col = 0 }, | ||
| 1952 | .active = .{ .row = 0, .col = 1 }, | ||
| 1953 | .text = "", | ||
| 1954 | }); | ||
| 1955 | _ = try p.onFrame(.selection_reply, position.items); | ||
| 1956 | try std.testing.expect(p.followPositionLocked() != null); | ||
| 1957 | |||
| 1958 | const copy = SelectionRequest{ .id = 8, .gesture = 7, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket }; | ||
| 1959 | try std.testing.expect(selectionTestBegin(p, copy) != null); | ||
| 1960 | try selectionTestReply(p, 8, "moved source"); | ||
| 1961 | const result = p.takeSelection().?; | ||
| 1962 | defer std.testing.allocator.free(result.text); | ||
| 1963 | try std.testing.expectEqualStrings("moved source", result.text); | ||
| 1964 | |||
| 1965 | const stale_start = SelectionRequest{ .id = 9, .gesture = 9, .anchor = start.anchor, .active = start.active, .version = start.version, .ticket = p.selection_ticket }; | ||
| 1966 | try std.testing.expect(selectionTestBegin(p, stale_start) == null); | ||
| 1967 | |||
| 1968 | const h = try selectionTestPump(); | ||
| 1969 | defer h.stop(); | ||
| 1970 | h.admitted = true; | ||
| 1971 | h.replica.history_rows = 20; | ||
| 1972 | h.scroll_rows = 1; | ||
| 1973 | h.history_pending = .{ .start = 17, .size = .{ .cols = 11, .rows = 3 }, .revision = h.history_revision, .until = std.math.maxInt(i64) }; | ||
| 1974 | const chunk = [_]u8{ 17, 0, 0, 0, 1, 0, 0, 0 }; | ||
| 1975 | try std.testing.expectEqual(Pump.Action.changed, try h.onFrame(.scrollback_chunk, &chunk)); | ||
| 1976 | try std.testing.expectEqual(@as(u64, 0), h.history_version.source); | ||
| 1977 | try selectionTestFollowMetadata(h, 3); | ||
| 1978 | try std.testing.expectEqual(@as(u64, 3), h.history_version.source); | ||
| 1979 | var history_redraw = testSnapshot(); | ||
| 1980 | std.mem.writeInt(u64, history_redraw[0..8], h.replica.last_seq + 1, .little); | ||
| 1981 | _ = try h.onFrame(.snapshot, &history_redraw); | ||
| 1982 | try selectionTestFollowMetadata(h, 4); | ||
| 1983 | try std.testing.expectEqual(@as(u64, 3), h.history_version.source); | ||
| 1984 | } | ||
| 1985 | |||
| 1713 | test "ready terminal mode frames precede queued wheel input through the actual wire" { | 1986 | test "ready terminal mode frames precede queued wheel input through the actual wire" { |
| 1714 | const p = try selectionTestPump(); | 1987 | const p = try selectionTestPump(); |
| 1715 | defer p.stop(); | 1988 | defer p.stop(); |
src/engine/engine.zig
| Old | New | ||
|---|---|---|---|
| @@ -38,10 +38,59 @@ pub const MuxHandler = struct { | |||
| 38 | comptime action: StreamAction.Tag, | 38 | comptime action: StreamAction.Tag, |
| 39 | value: StreamAction.Value(action), | 39 | value: StreamAction.Value(action), |
| 40 | ) void { | 40 | ) void { |
| 41 | const eng = self.engineOf(); | ||
| 42 | if (comptime action == .print_repeat) { | ||
| 43 | // Ghostty implements REP as repeated print calls. Route each | ||
| 44 | // through the same tracking hook, retaining its character state | ||
| 45 | // and width/wrap implementation instead of predicting the moves. | ||
| 46 | if (eng.term.previous_char) |c| for (0..@max(value, 1)) |_| self.vt(.print, .{ .cp = c }); | ||
| 47 | return; | ||
| 48 | } | ||
| 49 | const active_before = eng.term.screens.active_key; | ||
| 50 | const print_x = if (comptime action == .print) eng.term.screens.active.cursor.x else 0; | ||
| 51 | const print_y = if (comptime action == .print) eng.term.screens.active.cursor.y else 0; | ||
| 52 | const print_at_scroll_bottom = if (comptime action == .print) print_y == eng.term.scrolling_region.bottom and | ||
| 53 | print_x >= eng.term.scrolling_region.left and print_x <= eng.term.scrolling_region.right else false; | ||
| 54 | // The source token guards a client coordinate pair while it crosses | ||
| 55 | // the wire to become tracked pins. It is deliberately narrower than | ||
| 56 | // "received bytes": a counter repaint elsewhere must not make a | ||
| 57 | // completed drag refuse to register. These are the stream actions | ||
| 58 | // which can remap a coordinate to another cell identity. Test the | ||
| 59 | // terminal state before forwarding because index/reverse-index and a | ||
| 60 | // pending wrap decide whether this invocation actually scrolls. | ||
| 61 | const source_before = self.movesCoordinateIdentity(action); | ||
| 62 | if (source_before) eng.selection_source +%= 1; | ||
| 63 | // A narrow character at the right margin only arms pending wrap, but | ||
| 64 | // a wide character can wrap and discard the top row immediately. Mark | ||
| 65 | // the possible victims before Ghostty remaps their pins, then retire | ||
| 66 | // them only when the cursor proves that this print really wrapped. | ||
| 67 | if (comptime action == .print) eng.markPossiblePrintDiscard(); | ||
| 68 | eng.prepareTrackedMutation(action, value); | ||
| 69 | // Ghostty's full reset frees the alternate Screen. Its allocator may | ||
| 70 | // reuse the exact address on the next alternate entry, so pointer | ||
| 71 | // equality alone cannot make a stale tracked pin safe to deinit. | ||
| 72 | if (comptime action == .full_reset) | ||
| 73 | eng.alternate_generation +%= 1; | ||
| 41 | if (comptime action == .semantic_prompt) self.onSemanticPrompt(value); | 74 | if (comptime action == .semantic_prompt) self.onSemanticPrompt(value); |
| 42 | if (comptime action == .clipboard_contents) self.onClipboard(value); | 75 | if (comptime action == .clipboard_contents) self.onClipboard(value); |
| 43 | if (comptime action == .bell) self.onBell(); | 76 | if (comptime action == .bell) self.onBell(); |
| 44 | self.inner.vt(action, value); | 77 | self.inner.vt(action, value); |
| 78 | if (comptime action == .print) { | ||
| 79 | const cursor = eng.term.screens.active.cursor; | ||
| 80 | // A narrow glyph at the last column merely arms pending wrap; it | ||
| 81 | // does not yet remap a coordinate. Actual wrapping moves left or | ||
| 82 | // to another row, including a wide glyph that cannot fit. | ||
| 83 | const wrapped = cursor.x < print_x or cursor.y != print_y; | ||
| 84 | if (!source_before and wrapped) | ||
| 85 | eng.selection_source +%= 1; | ||
| 86 | if (wrapped and print_at_scroll_bottom) eng.commitTrackedMutation() else eng.clearTrackedMutation(); | ||
| 87 | } else eng.commitTrackedMutation(); | ||
| 88 | if (comptime action == .full_reset) | ||
| 89 | eng.screen_epoch +%= 1; | ||
| 90 | if (eng.term.screens.active_key != active_before) { | ||
| 91 | eng.screen_epoch +%= 1; | ||
| 92 | eng.selection_source +%= 1; | ||
| 93 | } | ||
| 45 | // After the stock handler, so the mode is on when the first report goes | 94 | // After the stock handler, so the mode is on when the first report goes |
| 46 | // out. A bare vt answers DECRQM 2048 as recognised but never speaks the | 95 | // out. A bare vt answers DECRQM 2048 as recognised but never speaks the |
| 47 | // report, and an app that turns it on then ignores SIGWINCH and waits. | 96 | // report, and an app that turns it on then ignores SIGWINCH and waits. |
| @@ -56,6 +105,36 @@ pub const MuxHandler = struct { | |||
| 56 | return @alignCast(@fieldParentPtr("stream", stream_ptr)); | 105 | return @alignCast(@fieldParentPtr("stream", stream_ptr)); |
| 57 | } | 106 | } |
| 58 | 107 | ||
| 108 | fn movesCoordinateIdentity(self: *MuxHandler, comptime action: StreamAction.Tag) bool { | ||
| 109 | const term = &self.engineOf().term; | ||
| 110 | const cursor = term.screens.active.cursor; | ||
| 111 | const in_horizontal_region = cursor.x >= term.scrolling_region.left and | ||
| 112 | cursor.x <= term.scrolling_region.right; | ||
| 113 | return switch (action) { | ||
| 114 | // These either shift cells/lines directly, or can move retained | ||
| 115 | // rows into or out of scrollback. A no-op is conservatively a | ||
| 116 | // new source, which only rejects a racing registration. | ||
| 117 | .insert_lines, | ||
| 118 | .delete_lines, | ||
| 119 | .insert_blanks, | ||
| 120 | .delete_chars, | ||
| 121 | .scroll_up, | ||
| 122 | .scroll_down, | ||
| 123 | .erase_display_complete, | ||
| 124 | .erase_display_scrollback, | ||
| 125 | .erase_display_scroll_complete, | ||
| 126 | .full_reset, | ||
| 127 | => true, | ||
| 128 | |||
| 129 | // A pending wrap necessarily invokes printWrap. For an otherwise | ||
| 130 | // ordinary print, `vt` compares pre/post cursor coordinates. | ||
| 131 | .print => term.modes.get(.insert) or cursor.pending_wrap, | ||
| 132 | .linefeed, .index, .next_line => in_horizontal_region and cursor.y == term.scrolling_region.bottom, | ||
| 133 | .reverse_index => in_horizontal_region and cursor.y == term.scrolling_region.top, | ||
| 134 | else => false, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 59 | fn onSemanticPrompt( | 138 | fn onSemanticPrompt( |
| 60 | self: *MuxHandler, | 139 | self: *MuxHandler, |
| 61 | value: StreamAction.Value(.semantic_prompt), | 140 | value: StreamAction.Value(.semantic_prompt), |
| @@ -137,6 +216,19 @@ pub const Engine = struct { | |||
| 137 | side_events: std.ArrayList(SideEvent), | 216 | side_events: std.ArrayList(SideEvent), |
| 138 | /// Copied from Options: the interception path reads it per event. | 217 | /// Copied from Options: the interception path reads it per event. |
| 139 | clipboard_max: usize, | 218 | clipboard_max: usize, |
| 219 | /// Full primary scrollback preserves pins only while the screen retains | ||
| 220 | /// scrolled-off rows. With no scrollback, that same scroll discards them. | ||
| 221 | scrollback_enabled: bool, | ||
| 222 | /// Monotonic coordinate-identity token. Clients use this to identify the | ||
| 223 | /// exact source state at which a tracked selection was installed. | ||
| 224 | selection_source: u64, | ||
| 225 | /// Changes when screen identity or geometry is reset, switched, or resized. | ||
| 226 | /// Ordinary output leaves this stable so tracked pins can follow it. | ||
| 227 | screen_epoch: u64, | ||
| 228 | /// Lifetime of Ghostty's lazily allocated alternate Screen. Full reset | ||
| 229 | /// destroys it; a new alternate may reuse its address. | ||
| 230 | alternate_generation: u64, | ||
| 231 | tracked_head: ?*TrackedSelection = null, | ||
| 140 | 232 | ||
| 141 | pub const MarkEvent = struct { | 233 | pub const MarkEvent = struct { |
| 142 | pub const Kind = enum(u8) { prompt_start, command_start, command_end }; | 234 | pub const Kind = enum(u8) { prompt_start, command_start, command_end }; |
| @@ -198,6 +290,96 @@ pub const Engine = struct { | |||
| 198 | } | 290 | } |
| 199 | }; | 291 | }; |
| 200 | 292 | ||
| 293 | /// A selection whose endpoints are pinned in one active Ghostty screen. | ||
| 294 | /// The daemon keeps this per client, rather than using Screen.selection, | ||
| 295 | /// so independent clients cannot overwrite one another's selection. | ||
| 296 | const SelectionPoints = struct { anchor: proto.SelectionPoint, active: proto.SelectionPoint }; | ||
| 297 | |||
| 298 | pub const TrackedSelection = struct { | ||
| 299 | engine: *Engine, | ||
| 300 | screen: *vt.Screen, | ||
| 301 | screen_key: vt.ScreenSet.Key, | ||
| 302 | screen_epoch: u64, | ||
| 303 | alternate_generation: u64, | ||
| 304 | cols: u16, | ||
| 305 | rows: u16, | ||
| 306 | selection: vt.Selection, | ||
| 307 | prev: ?*TrackedSelection = null, | ||
| 308 | next: ?*TrackedSelection = null, | ||
| 309 | valid: bool = true, | ||
| 310 | discard_candidate: bool = false, | ||
| 311 | remap: ?SelectionPoints = null, | ||
| 312 | rotate_rows: u16 = 0, | ||
| 313 | |||
| 314 | pub fn deinit(self: *TrackedSelection) void { | ||
| 315 | self.unlink(); | ||
| 316 | // A full reset may destroy the alternate Screen and its pins. | ||
| 317 | // Its allocator can then recreate it at this exact address; the | ||
| 318 | // explicit generation prevents an ABA untrack in that new Screen. | ||
| 319 | if (self.screen_key == .alternate and | ||
| 320 | self.engine.alternate_generation != self.alternate_generation) | ||
| 321 | return self.engine.alloc.destroy(self); | ||
| 322 | const current = self.engine.term.screens.all.get(self.screen_key) orelse { | ||
| 323 | self.engine.alloc.destroy(self); | ||
| 324 | return; | ||
| 325 | }; | ||
| 326 | if (current == self.screen) self.selection.deinit(self.screen); | ||
| 327 | self.engine.alloc.destroy(self); | ||
| 328 | } | ||
| 329 | |||
| 330 | fn unlink(self: *TrackedSelection) void { | ||
| 331 | if (self.prev) |prev| prev.next = self.next else if (self.engine.tracked_head == self) self.engine.tracked_head = self.next; | ||
| 332 | if (self.next) |next| next.prev = self.prev; | ||
| 333 | self.prev = null; | ||
| 334 | self.next = null; | ||
| 335 | } | ||
| 336 | |||
| 337 | /// Current screen-space endpoints. A garbage pin, screen switch, or | ||
| 338 | /// geometry change retires the durable selection. | ||
| 339 | pub fn points(self: *const TrackedSelection) ?SelectionPoints { | ||
| 340 | if (!self.valid or self.engine.term.screens.active_key != self.screen_key or | ||
| 341 | self.engine.screen_epoch != self.screen_epoch or | ||
| 342 | self.engine.term.cols != self.cols or self.engine.term.rows != self.rows) | ||
| 343 | return null; | ||
| 344 | |||
| 345 | const anchor_pin = self.selection.start(); | ||
| 346 | const active_pin = self.selection.end(); | ||
| 347 | if (anchor_pin.garbage or active_pin.garbage) return null; | ||
| 348 | const anchor = self.screen.pages.pointFromPin(.screen, anchor_pin) orelse return null; | ||
| 349 | const active = self.screen.pages.pointFromPin(.screen, active_pin) orelse return null; | ||
| 350 | const anchor_coord = switch (anchor) { | ||
| 351 | .screen => |coord| coord, | ||
| 352 | else => return null, | ||
| 353 | }; | ||
| 354 | const active_coord = switch (active) { | ||
| 355 | .screen => |coord| coord, | ||
| 356 | else => return null, | ||
| 357 | }; | ||
| 358 | return .{ | ||
| 359 | .anchor = .{ .row = anchor_coord.y, .col = @intCast(anchor_coord.x) }, | ||
| 360 | .active = .{ .row = active_coord.y, .col = @intCast(active_coord.x) }, | ||
| 361 | }; | ||
| 362 | } | ||
| 363 | |||
| 364 | /// Extract from the endpoints' current positions. Null means the | ||
| 365 | /// tracked pins no longer name the original active screen geometry. | ||
| 366 | pub fn extract( | ||
| 367 | self: *const TrackedSelection, | ||
| 368 | alloc: std.mem.Allocator, | ||
| 369 | max_bytes: usize, | ||
| 370 | ) !?SelectionExtract { | ||
| 371 | const p = self.points() orelse return null; | ||
| 372 | return try self.engine.extractSelection( | ||
| 373 | alloc, | ||
| 374 | p.anchor.row, | ||
| 375 | p.anchor.col, | ||
| 376 | p.active.row, | ||
| 377 | p.active.col, | ||
| 378 | max_bytes, | ||
| 379 | ); | ||
| 380 | } | ||
| 381 | }; | ||
| 382 | |||
| 201 | /// Heap-allocates: stream.handler holds a pointer to `term`, so an | 383 | /// Heap-allocates: stream.handler holds a pointer to `term`, so an |
| 202 | /// Engine must never move after init. | 384 | /// Engine must never move after init. |
| 203 | pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { | 385 | pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine { |
| @@ -216,6 +398,11 @@ pub const Engine = struct { | |||
| 216 | .mark_events = .empty, | 398 | .mark_events = .empty, |
| 217 | .side_events = .empty, | 399 | .side_events = .empty, |
| 218 | .clipboard_max = opts.clipboard_max, | 400 | .clipboard_max = opts.clipboard_max, |
| 401 | .scrollback_enabled = opts.max_scrollback != 0, | ||
| 402 | .selection_source = 1, | ||
| 403 | .screen_epoch = 1, | ||
| 404 | .alternate_generation = 1, | ||
| 405 | .tracked_head = null, | ||
| 219 | }; | 406 | }; |
| 220 | errdefer self.term.deinit(alloc); | 407 | errdefer self.term.deinit(alloc); |
| 221 | 408 | ||
| @@ -230,6 +417,7 @@ pub const Engine = struct { | |||
| 230 | } | 417 | } |
| 231 | 418 | ||
| 232 | pub fn deinit(self: *Engine) void { | 419 | pub fn deinit(self: *Engine) void { |
| 420 | while (self.tracked_head) |tracked| tracked.deinit(); | ||
| 233 | self.pty_out.deinit(self.alloc); | 421 | self.pty_out.deinit(self.alloc); |
| 234 | self.clearSideEvents(); | 422 | self.clearSideEvents(); |
| 235 | self.side_events.deinit(self.alloc); | 423 | self.side_events.deinit(self.alloc); |
| @@ -243,6 +431,141 @@ pub const Engine = struct { | |||
| 243 | self.stream.nextSlice(bytes); | 431 | self.stream.nextSlice(bytes); |
| 244 | } | 432 | } |
| 245 | 433 | ||
| 434 | fn prepareTrackedMutation(self: *Engine, comptime action: StreamAction.Tag, value: StreamAction.Value(action)) void { | ||
| 435 | if (self.tracked_head == null) return; | ||
| 436 | const t = &self.term; | ||
| 437 | const c = t.screens.active.cursor; | ||
| 438 | const in_region = c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right; | ||
| 439 | switch (action) { | ||
| 440 | .print => if (t.modes.get(.insert)) self.invalidateActive(), | ||
| 441 | .scroll_up => self.prepareScrollUp(value), | ||
| 442 | .scroll_down => self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, value, .down), | ||
| 443 | .index, .linefeed, .next_line => if (in_region and c.y == t.scrolling_region.bottom) self.prepareScrollUp(1), | ||
| 444 | .reverse_index => if (in_region and c.y == t.scrolling_region.top) self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, 1, .down), | ||
| 445 | .insert_lines, .delete_lines => if (in_region and c.y >= t.scrolling_region.top and c.y <= t.scrolling_region.bottom) { | ||
| 446 | self.shiftSelectionRows(c.y, t.scrolling_region.bottom, value, if (action == .insert_lines) .down else .up); | ||
| 447 | }, | ||
| 448 | // Horizontal edits need a different range policy from vertical moves. | ||
| 449 | .insert_blanks, .delete_chars => self.invalidateActive(), | ||
| 450 | .erase_display_complete, .erase_display_scrollback, .erase_display_scroll_complete, .full_reset => self.invalidateActive(), | ||
| 451 | else => {}, | ||
| 452 | } | ||
| 453 | } | ||
| 454 | fn prepareScrollUp(self: *Engine, count: usize) void { | ||
| 455 | const t = &self.term; | ||
| 456 | if (count == 0) return; | ||
| 457 | if (t.scrolling_region.top == 0 and t.scrolling_region.left == 0 and t.scrolling_region.right == t.cols - 1) { | ||
| 458 | // Ghostty moves retained history itself. A partial bottom also | ||
| 459 | // rotates the untouched rows below it; those external pins need | ||
| 460 | // the same displacement after Ghostty has grown/pruned pages. | ||
| 461 | const n: u16 = @intCast(@min(count, t.scrolling_region.bottom + 1)); | ||
| 462 | const origin = self.historyRows(); | ||
| 463 | const bottom = origin + t.scrolling_region.bottom; | ||
| 464 | var it = self.tracked_head; | ||
| 465 | while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| { | ||
| 466 | const first = @min(points.anchor.row, points.active.row); | ||
| 467 | const last = @max(points.anchor.row, points.active.row); | ||
| 468 | if (first <= bottom and last > bottom) tracked.discard_candidate = true; | ||
| 469 | if (first > bottom) tracked.rotate_rows = n; | ||
| 470 | if (!self.scrollback_enabled or t.screens.active_key == .alternate) | ||
| 471 | tracked.discard_candidate = tracked.discard_candidate or first < n; | ||
| 472 | }; | ||
| 473 | return; | ||
| 474 | } | ||
| 475 | self.shiftSelectionRows(t.scrolling_region.top, t.scrolling_region.bottom, count, .up); | ||
| 476 | } | ||
| 477 | const ShiftDirection = enum { up, down }; | ||
| 478 | fn shiftSelectionRows(self: *Engine, first: u16, last: u16, count: usize, direction: ShiftDirection) void { | ||
| 479 | if (count == 0 or self.tracked_head == null) return; | ||
| 480 | const t = &self.term; | ||
| 481 | // A rectangular margin can split a linear selection. Retire it until | ||
| 482 | // that separate selection policy is supported. | ||
| 483 | if (t.scrolling_region.left != 0 or t.scrolling_region.right != t.cols - 1) return self.invalidateActive(); | ||
| 484 | const origin = self.historyRows(); | ||
| 485 | const top = origin + first; | ||
| 486 | const bottom = origin + last; | ||
| 487 | const n: u32 = @intCast(@min(count, last - first + 1)); | ||
| 488 | var it = self.tracked_head; | ||
| 489 | while (it) |tracked| : (it = tracked.next) if (tracked.points()) |points| { | ||
| 490 | const start = @min(points.anchor.row, points.active.row); | ||
| 491 | const end = @max(points.anchor.row, points.active.row); | ||
| 492 | if (start <= bottom and end >= top and (start < top or end > bottom)) { | ||
| 493 | tracked.discard_candidate = true; | ||
| 494 | continue; | ||
| 495 | } | ||
| 496 | var moved = points; | ||
| 497 | for ([_]*proto.SelectionPoint{ &moved.anchor, &moved.active }) |point| { | ||
| 498 | if (point.row < top or point.row > bottom) continue; | ||
| 499 | switch (direction) { | ||
| 500 | .up => if (point.row < top + n) { | ||
| 501 | tracked.discard_candidate = true; | ||
| 502 | } else { | ||
| 503 | point.row -= n; | ||
| 504 | }, | ||
| 505 | .down => if (point.row + n > bottom) { | ||
| 506 | tracked.discard_candidate = true; | ||
| 507 | } else { | ||
| 508 | point.row += n; | ||
| 509 | }, | ||
| 510 | } | ||
| 511 | } | ||
| 512 | tracked.remap = moved; | ||
| 513 | }; | ||
| 514 | } | ||
| 515 | fn markPossiblePrintDiscard(self: *Engine) void { | ||
| 516 | if (self.tracked_head == null) return; | ||
| 517 | const t = &self.term; | ||
| 518 | const c = t.screens.active.cursor; | ||
| 519 | if (c.y == t.scrolling_region.bottom and c.x >= t.scrolling_region.left and c.x <= t.scrolling_region.right and | ||
| 520 | (c.x == t.scrolling_region.right or c.pending_wrap)) self.prepareScrollUp(1); | ||
| 521 | } | ||
| 522 | fn commitTrackedMutation(self: *Engine) void { | ||
| 523 | var it = self.tracked_head; | ||
| 524 | while (it) |tracked| : (it = tracked.next) { | ||
| 525 | if (tracked.discard_candidate) tracked.valid = false; | ||
| 526 | if (tracked.rotate_rows != 0) { | ||
| 527 | if (tracked.points()) |points| { | ||
| 528 | var moved = points; | ||
| 529 | moved.anchor.row += tracked.rotate_rows; | ||
| 530 | moved.active.row += tracked.rotate_rows; | ||
| 531 | tracked.remap = moved; | ||
| 532 | } else tracked.valid = false; | ||
| 533 | } | ||
| 534 | if (tracked.valid) if (tracked.remap) |points| { | ||
| 535 | // IL/DL copy row contents without remapping arbitrary pins. | ||
| 536 | // Rebind after the action, also covering index's fast path | ||
| 537 | // without duplicating Ghostty's choice of row-copy algorithm. | ||
| 538 | const a = tracked.screen.pages.pin(.{ .screen = .{ .x = points.anchor.col, .y = points.anchor.row } }); | ||
| 539 | const b = tracked.screen.pages.pin(.{ .screen = .{ .x = points.active.col, .y = points.active.row } }); | ||
| 540 | if (a != null and b != null) { | ||
| 541 | tracked.selection.startPtr().* = a.?; | ||
| 542 | tracked.selection.endPtr().* = b.?; | ||
| 543 | } else tracked.valid = false; | ||
| 544 | }; | ||
| 545 | tracked.discard_candidate = false; | ||
| 546 | tracked.remap = null; | ||
| 547 | tracked.rotate_rows = 0; | ||
| 548 | } | ||
| 549 | } | ||
| 550 | fn clearTrackedMutation(self: *Engine) void { | ||
| 551 | var it = self.tracked_head; | ||
| 552 | while (it) |tracked| : (it = tracked.next) { | ||
| 553 | tracked.discard_candidate = false; | ||
| 554 | tracked.remap = null; | ||
| 555 | tracked.rotate_rows = 0; | ||
| 556 | } | ||
| 557 | } | ||
| 558 | fn invalidateActive(self: *Engine) void { | ||
| 559 | var it = self.tracked_head; | ||
| 560 | while (it) |tracked| : (it = tracked.next) { | ||
| 561 | if (tracked.screen == self.term.screens.active) tracked.valid = false; | ||
| 562 | } | ||
| 563 | } | ||
| 564 | |||
| 565 | pub fn selectionSource(self: *const Engine) u64 { | ||
| 566 | return self.selection_source; | ||
| 567 | } | ||
| 568 | |||
| 246 | pub fn ptyOutput(self: *const Engine) []const u8 { | 569 | pub fn ptyOutput(self: *const Engine) []const u8 { |
| 247 | return self.pty_out.items; | 570 | return self.pty_out.items; |
| 248 | } | 571 | } |
| @@ -592,6 +915,46 @@ pub const Engine = struct { | |||
| 592 | g.cursor = .{ .x = cur.x, .y = cur.y }; | 915 | g.cursor = .{ .x = cur.x, .y = cur.y }; |
| 593 | } | 916 | } |
| 594 | 917 | ||
| 918 | /// Pin a selection to the currently active screen. The returned owner | ||
| 919 | /// must be deinitialized by the caller, even when the terminal later | ||
| 920 | /// makes either endpoint unavailable. | ||
| 921 | pub fn trackSelection( | ||
| 922 | self: *Engine, | ||
| 923 | anchor: proto.SelectionPoint, | ||
| 924 | active: proto.SelectionPoint, | ||
| 925 | ) !?*TrackedSelection { | ||
| 926 | if (anchor.col >= self.term.cols or active.col >= self.term.cols) | ||
| 927 | return null; | ||
| 928 | |||
| 929 | const screen = self.term.screens.active; | ||
| 930 | const start = screen.pages.pin(.{ .screen = .{ | ||
| 931 | .x = anchor.col, | ||
| 932 | .y = anchor.row, | ||
| 933 | } }) orelse return null; | ||
| 934 | const end = screen.pages.pin(.{ .screen = .{ | ||
| 935 | .x = active.col, | ||
| 936 | .y = active.row, | ||
| 937 | } }) orelse return null; | ||
| 938 | const selection = vt.Selection.init(start, end, false); | ||
| 939 | const pinned = try selection.track(screen); | ||
| 940 | errdefer pinned.deinit(screen); | ||
| 941 | const tracked = try self.alloc.create(TrackedSelection); | ||
| 942 | tracked.* = .{ | ||
| 943 | .engine = self, | ||
| 944 | .screen = screen, | ||
| 945 | .screen_key = self.term.screens.active_key, | ||
| 946 | .screen_epoch = self.screen_epoch, | ||
| 947 | .alternate_generation = self.alternate_generation, | ||
| 948 | .cols = @intCast(self.term.cols), | ||
| 949 | .rows = @intCast(self.term.rows), | ||
| 950 | .selection = pinned, | ||
| 951 | }; | ||
| 952 | tracked.next = self.tracked_head; | ||
| 953 | if (self.tracked_head) |head| head.prev = tracked; | ||
| 954 | self.tracked_head = tracked; | ||
| 955 | return tracked; | ||
| 956 | } | ||
| 957 | |||
| 595 | /// Screen-space rows, zero being the oldest retained. Formatting writes | 958 | /// Screen-space rows, zero being the oldest retained. Formatting writes |
| 596 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. | 959 | /// into a fixed-size allocation: hostile coordinates cannot blow it up. |
| 597 | pub fn extractSelection( | 960 | pub fn extractSelection( |
| @@ -674,11 +1037,16 @@ pub const Engine = struct { | |||
| 674 | 1037 | ||
| 675 | /// Full reset (RIS). Also DISCARDS queued side_events: drain them first. | 1038 | /// Full reset (RIS). Also DISCARDS queued side_events: drain them first. |
| 676 | pub fn reset(self: *Engine) void { | 1039 | pub fn reset(self: *Engine) void { |
| 1040 | self.selection_source +%= 1; | ||
| 1041 | self.screen_epoch +%= 1; | ||
| 1042 | self.alternate_generation +%= 1; | ||
| 677 | self.term.fullReset(); | 1043 | self.term.fullReset(); |
| 678 | self.clearSideEvents(); | 1044 | self.clearSideEvents(); |
| 679 | } | 1045 | } |
| 680 | 1046 | ||
| 681 | pub fn resize(self: *Engine, cols: u16, rows: u16) !void { | 1047 | pub fn resize(self: *Engine, cols: u16, rows: u16) !void { |
| 1048 | self.selection_source +%= 1; | ||
| 1049 | self.screen_epoch +%= 1; | ||
| 682 | try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); | 1050 | try self.term.resize(self.alloc, @intCast(cols), @intCast(rows)); |
| 683 | if (self.term.modes.get(.in_band_size_reports)) self.reportSize(); | 1051 | if (self.term.modes.get(.in_band_size_reports)) self.reportSize(); |
| 684 | } | 1052 | } |
| @@ -948,6 +1316,308 @@ test "Engine: selection extraction uses only the active alternate screen" { | |||
| 948 | try std.testing.expectEqual(@as(?[]u8, null), unavailable.text); | 1316 | try std.testing.expectEqual(@as(?[]u8, null), unavailable.text); |
| 949 | } | 1317 | } |
| 950 | 1318 | ||
| 1319 | test "Engine: tracked selection follows output and preserves duplicate row identity" { | ||
| 1320 | const alloc = std.testing.allocator; | ||
| 1321 | var e = try Engine.init(alloc, .{ .cols = 16, .rows = 3, .max_scrollback = 16 }); | ||
| 1322 | defer e.deinit(); | ||
| 1323 | |||
| 1324 | e.feed("DUPLICATE\r\nsame\r\nDUPLICATE\r\nother"); | ||
| 1325 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 })).?; | ||
| 1326 | defer tracked.deinit(); | ||
| 1327 | var other = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 })).?; | ||
| 1328 | defer other.deinit(); | ||
| 1329 | try std.testing.expect(tracked.points().?.anchor.row != other.points().?.anchor.row); | ||
| 1330 | var first = (try tracked.extract(alloc, 64)).?; | ||
| 1331 | defer first.deinit(alloc); | ||
| 1332 | try std.testing.expectEqualStrings("DUPLICATE", first.text.?); | ||
| 1333 | |||
| 1334 | // More output advances the viewport and creates another identical row; | ||
| 1335 | // tracked pins keep naming the first row rather than matching by text. | ||
| 1336 | e.feed("\r\nnew-1\r\nnew-2\r\nnew-3"); | ||
| 1337 | var after_output = (try tracked.extract(alloc, 64)).?; | ||
| 1338 | defer after_output.deinit(alloc); | ||
| 1339 | try std.testing.expectEqualStrings("DUPLICATE", after_output.text.?); | ||
| 1340 | try std.testing.expect(after_output.history_rows > first.history_rows); | ||
| 1341 | var other_after = (try other.extract(alloc, 64)).?; | ||
| 1342 | defer other_after.deinit(alloc); | ||
| 1343 | try std.testing.expectEqualStrings("DUPLICATE", other_after.text.?); | ||
| 1344 | } | ||
| 1345 | |||
| 1346 | test "Engine: selection source advances only for coordinate remapping actions" { | ||
| 1347 | const alloc = std.testing.allocator; | ||
| 1348 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 }); | ||
| 1349 | defer e.deinit(); | ||
| 1350 | |||
| 1351 | const initial = e.selectionSource(); | ||
| 1352 | // Side effects and ordinary redraws leave coordinates meaningful. This is | ||
| 1353 | // the live-counter case: a completed drag elsewhere must still register. | ||
| 1354 | e.feed("\x07x\rY\n"); | ||
| 1355 | try std.testing.expectEqual(initial, e.selectionSource()); | ||
| 1356 | |||
| 1357 | // Insert mode shifts the remainder of the row even away from its edge. | ||
| 1358 | e.feed("\x1b[4hZ\x1b[4l"); | ||
| 1359 | const inserted = e.selectionSource(); | ||
| 1360 | try std.testing.expect(inserted != initial); | ||
| 1361 | |||
| 1362 | // Full-row repainting reaches the last column but only arms pending-wrap; | ||
| 1363 | // it must not reject a drag elsewhere, such as a live counter redraw. | ||
| 1364 | e.feed("\x1b[1;1H1234"); | ||
| 1365 | try std.testing.expectEqual(inserted, e.selectionSource()); | ||
| 1366 | |||
| 1367 | // A wide glyph at the right margin cannot fit, so Ghostty immediately | ||
| 1368 | // wraps and remaps coordinates. The post-print cursor proves that path. | ||
| 1369 | e.feed("\x1b[1;4H漢"); | ||
| 1370 | const wrapped = e.selectionSource(); | ||
| 1371 | try std.testing.expect(wrapped != inserted); | ||
| 1372 | |||
| 1373 | // Index at the bottom scrolls; the same LF away from the bottom above did | ||
| 1374 | // not. ED 3 clears scrollback and changes absolute row identities. | ||
| 1375 | e.feed("\x1b[3;1H\n"); | ||
| 1376 | const scrolled = e.selectionSource(); | ||
| 1377 | try std.testing.expect(scrolled != wrapped); | ||
| 1378 | e.feed("\x1b[3J"); | ||
| 1379 | try std.testing.expect(e.selectionSource() != scrolled); | ||
| 1380 | } | ||
| 1381 | |||
| 1382 | test "Engine: tracked selection rejects active screen and geometry changes" { | ||
| 1383 | const alloc = std.testing.allocator; | ||
| 1384 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 }); | ||
| 1385 | defer e.deinit(); | ||
| 1386 | e.feed("tracked"); | ||
| 1387 | |||
| 1388 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1389 | defer tracked.deinit(); | ||
| 1390 | try std.testing.expect(tracked.points() != null); | ||
| 1391 | |||
| 1392 | // Switching away and back in one feed still retires the source identity; | ||
| 1393 | // comparing only the final active screen key would miss this. | ||
| 1394 | e.feed("\x1b[?1049halt\x1b[?1049l"); | ||
| 1395 | try std.testing.expect(tracked.points() == null); | ||
| 1396 | |||
| 1397 | // Ordinary alternate leave retains its Screen and pins, but switching | ||
| 1398 | // away/re-entering still retires this selection's presentation epoch. | ||
| 1399 | e.feed("\x1b[?1049h"); | ||
| 1400 | var alt_tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 })).?; | ||
| 1401 | defer alt_tracked.deinit(); | ||
| 1402 | e.feed("\x1b[?1049l\x1b[?1049h"); | ||
| 1403 | try std.testing.expect(alt_tracked.points() == null); | ||
| 1404 | e.feed("\x1b[?1049l"); | ||
| 1405 | |||
| 1406 | var resized = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1407 | defer resized.deinit(); | ||
| 1408 | try e.resize(9, 3); | ||
| 1409 | try std.testing.expect(resized.points() == null); | ||
| 1410 | try e.resize(8, 3); | ||
| 1411 | try std.testing.expect(resized.points() == null); | ||
| 1412 | |||
| 1413 | var reset = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 })).?; | ||
| 1414 | defer reset.deinit(); | ||
| 1415 | e.reset(); | ||
| 1416 | try std.testing.expect(reset.points() == null); | ||
| 1417 | } | ||
| 1418 | |||
| 1419 | test "Engine: alternate tracked pin deinit rejects a reset ABA lifetime" { | ||
| 1420 | const alloc = std.testing.allocator; | ||
| 1421 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3 }); | ||
| 1422 | defer e.deinit(); | ||
| 1423 | |||
| 1424 | e.feed("\x1b[?1049halt"); | ||
| 1425 | var tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1426 | const lifetime = tracked.alternate_generation; | ||
| 1427 | e.reset(); | ||
| 1428 | try std.testing.expect(e.alternate_generation != lifetime); | ||
| 1429 | // This must be a no-op even if Ghostty's next alternate allocation takes | ||
| 1430 | // the former address. The generation, not allocator behavior, is proof. | ||
| 1431 | tracked.deinit(); | ||
| 1432 | e.feed("\x1b[?1049hnew-alt"); | ||
| 1433 | } | ||
| 1434 | |||
| 1435 | test "Engine: bounded scroll retires discarded endpoints but keeps shifted rows" { | ||
| 1436 | const alloc = std.testing.allocator; | ||
| 1437 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 }); | ||
| 1438 | defer e.deinit(); | ||
| 1439 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD"); | ||
| 1440 | const discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1441 | defer discarded.deinit(); | ||
| 1442 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1443 | defer kept.deinit(); | ||
| 1444 | // Alt screen never receives scrollback: CSI S discards its top row. | ||
| 1445 | e.feed("\x1b[1S"); | ||
| 1446 | try std.testing.expect(discarded.points() == null); | ||
| 1447 | try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row); | ||
| 1448 | } | ||
| 1449 | |||
| 1450 | test "Engine: print wrapping and REP retire an alternate discarded row" { | ||
| 1451 | const alloc = std.testing.allocator; | ||
| 1452 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 3 }); | ||
| 1453 | defer e.deinit(); | ||
| 1454 | |||
| 1455 | e.feed("\x1b[?1049hA\r\nB\r\nC"); | ||
| 1456 | const wide_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1457 | defer wide_discarded.deinit(); | ||
| 1458 | // A wide glyph at the final cell wraps immediately; it does not first set | ||
| 1459 | // pending_wrap, so the candidate must be remembered before Ghostty scrolls. | ||
| 1460 | e.feed("\x1b[3;4H漢"); | ||
| 1461 | try std.testing.expect(wide_discarded.points() == null); | ||
| 1462 | |||
| 1463 | e.reset(); | ||
| 1464 | e.feed("\x1b[?1049hA\r\nB\r\nC"); | ||
| 1465 | const repeated_discarded = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1466 | defer repeated_discarded.deinit(); | ||
| 1467 | // REP at the margin prints twice: the second character consumes pending | ||
| 1468 | // wrap and scrolls the alternate screen. | ||
| 1469 | e.feed("\x1b[3;4H\x1b[2b"); | ||
| 1470 | try std.testing.expect(repeated_discarded.points() == null); | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | test "Engine: a non-bottom wrap retains primary history selection" { | ||
| 1474 | const alloc = std.testing.allocator; | ||
| 1475 | var e = try Engine.init(alloc, .{ .cols = 4, .rows = 4, .max_scrollback = 16 }); | ||
| 1476 | defer e.deinit(); | ||
| 1477 | e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive"); | ||
| 1478 | const history = e.historyRows(); | ||
| 1479 | try std.testing.expect(history > 0); | ||
| 1480 | const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 })).?; | ||
| 1481 | defer tracked.deinit(); | ||
| 1482 | // This is an actual wide-character wrap, but it lands on the next row | ||
| 1483 | // above the bottom of the full scrolling region and cannot discard text. | ||
| 1484 | e.feed("\x1b[2;4H漢"); | ||
| 1485 | try std.testing.expect(tracked.points() != null); | ||
| 1486 | } | ||
| 1487 | |||
| 1488 | test "Engine: full primary scroll without scrollback retires its top row" { | ||
| 1489 | const alloc = std.testing.allocator; | ||
| 1490 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 3, .max_scrollback = 0 }); | ||
| 1491 | defer e.deinit(); | ||
| 1492 | e.feed("top\r\nmid\r\nbottom"); | ||
| 1493 | const tracked = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1494 | defer tracked.deinit(); | ||
| 1495 | e.feed("\x1b[3;1H\n"); | ||
| 1496 | try std.testing.expect(tracked.points() == null); | ||
| 1497 | } | ||
| 1498 | |||
| 1499 | test "Engine: partial primary discard compares absolute rows with history" { | ||
| 1500 | const alloc = std.testing.allocator; | ||
| 1501 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4, .max_scrollback = 16 }); | ||
| 1502 | defer e.deinit(); | ||
| 1503 | |||
| 1504 | e.feed("zero\r\none\r\ntwo\r\nthree\r\nfour\r\nfive"); | ||
| 1505 | const history = e.historyRows(); | ||
| 1506 | try std.testing.expect(history > 0); | ||
| 1507 | const discarded = (try e.trackSelection(.{ .row = history + 1, .col = 0 }, .{ .row = history + 1, .col = 0 })).?; | ||
| 1508 | defer discarded.deinit(); | ||
| 1509 | const kept = (try e.trackSelection(.{ .row = history + 2, .col = 0 }, .{ .row = history + 2, .col = 0 })).?; | ||
| 1510 | defer kept.deinit(); | ||
| 1511 | e.feed("\x1b[2;4r\x1b[1S"); | ||
| 1512 | try std.testing.expect(discarded.points() == null); | ||
| 1513 | try std.testing.expectEqual(history + 1, kept.points().?.anchor.row); | ||
| 1514 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1515 | defer text.deinit(alloc); | ||
| 1516 | try std.testing.expectEqualStrings("f", text.text.?); | ||
| 1517 | } | ||
| 1518 | |||
| 1519 | test "Engine: retained region selection follows IL DL index and reverse scrolling" { | ||
| 1520 | const alloc = std.testing.allocator; | ||
| 1521 | const cases = .{ | ||
| 1522 | .{ "\x1b[2S", @as(u32, 1) }, | ||
| 1523 | .{ "\x1b[1T", @as(u32, 4) }, | ||
| 1524 | .{ "\x1b[3;1H\x1b[1L", @as(u32, 4) }, | ||
| 1525 | .{ "\x1b[2;1H\x1b[1M", @as(u32, 2) }, | ||
| 1526 | .{ "\x1b[6;1H\n", @as(u32, 2) }, | ||
| 1527 | .{ "\x1b[44m\x1b[6;1H\n", @as(u32, 2) }, | ||
| 1528 | .{ "\x1b[2;1H\x1bM", @as(u32, 4) }, | ||
| 1529 | .{ "\x1b[6;8H漢", @as(u32, 2) }, | ||
| 1530 | }; | ||
| 1531 | inline for (cases) |case| { | ||
| 1532 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1533 | defer e.deinit(); | ||
| 1534 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[2;6r"); | ||
| 1535 | const kept = (try e.trackSelection(.{ .row = 3, .col = 0 }, .{ .row = 3, .col = 0 })).?; | ||
| 1536 | defer kept.deinit(); | ||
| 1537 | e.feed(case[0]); | ||
| 1538 | try std.testing.expectEqual(case[1], kept.points().?.anchor.row); | ||
| 1539 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1540 | defer text.deinit(alloc); | ||
| 1541 | try std.testing.expectEqualStrings("D", text.text.?); | ||
| 1542 | } | ||
| 1543 | } | ||
| 1544 | |||
| 1545 | test "Engine: REP uses the normal print tracking path" { | ||
| 1546 | const alloc = std.testing.allocator; | ||
| 1547 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 4 }); | ||
| 1548 | defer e.deinit(); | ||
| 1549 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD"); | ||
| 1550 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1551 | defer kept.deinit(); | ||
| 1552 | const source = e.selectionSource(); | ||
| 1553 | e.feed("\x1b[1;2Hx\x1b[3b"); | ||
| 1554 | try std.testing.expectEqual(source, e.selectionSource()); | ||
| 1555 | try std.testing.expectEqual(@as(u32, 2), kept.points().?.anchor.row); | ||
| 1556 | e.feed("\x1b[4;8H\x1b[2b"); | ||
| 1557 | try std.testing.expectEqual(@as(u32, 1), kept.points().?.anchor.row); | ||
| 1558 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1559 | defer text.deinit(alloc); | ||
| 1560 | try std.testing.expectEqualStrings("C", text.text.?); | ||
| 1561 | } | ||
| 1562 | |||
| 1563 | test "Engine: split region ranges and insert-mode prints retire selection" { | ||
| 1564 | const alloc = std.testing.allocator; | ||
| 1565 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1566 | defer e.deinit(); | ||
| 1567 | e.feed("\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE\r\nF"); | ||
| 1568 | const crossing = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 4, .col = 0 })).?; | ||
| 1569 | defer crossing.deinit(); | ||
| 1570 | e.feed("\x1b[2;4r\x1b[1S"); | ||
| 1571 | try std.testing.expect(crossing.points() == null); | ||
| 1572 | e.feed("\x1b[r\x1b[1;1Hhello"); | ||
| 1573 | const inserted = (try e.trackSelection(.{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 })).?; | ||
| 1574 | defer inserted.deinit(); | ||
| 1575 | e.feed("\x1b[1;1H\x1b[4hZ"); | ||
| 1576 | try std.testing.expect(inserted.points() == null); | ||
| 1577 | } | ||
| 1578 | |||
| 1579 | test "Engine: top-zero partial-bottom scrolling preserves selected occurrence" { | ||
| 1580 | const alloc = std.testing.allocator; | ||
| 1581 | inline for (.{ "\x1b[1S", "\x1b[4;1H\n" }) |action| { | ||
| 1582 | inline for (.{ true, false }) |alternate| { | ||
| 1583 | var e = try Engine.init(alloc, .{ .cols = 8, .rows = 6 }); | ||
| 1584 | defer e.deinit(); | ||
| 1585 | if (alternate) e.feed("\x1b[?1049h"); | ||
| 1586 | e.feed("A\r\nB\r\nC\r\nD\r\nE\r\nF\x1b[1;4r"); | ||
| 1587 | const kept = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 2, .col = 0 })).?; | ||
| 1588 | defer kept.deinit(); | ||
| 1589 | const below = (try e.trackSelection(.{ .row = 4, .col = 0 }, .{ .row = 5, .col = 0 })).?; | ||
| 1590 | defer below.deinit(); | ||
| 1591 | const crossing = (try e.trackSelection(.{ .row = 2, .col = 0 }, .{ .row = 4, .col = 0 })).?; | ||
| 1592 | defer crossing.deinit(); | ||
| 1593 | e.feed(action); | ||
| 1594 | try std.testing.expect(crossing.points() == null); | ||
| 1595 | try std.testing.expectEqual(e.historyRows() + 4, below.points().?.anchor.row); | ||
| 1596 | const outside_text = (try below.extract(alloc, 100)).?; | ||
| 1597 | defer outside_text.deinit(alloc); | ||
| 1598 | try std.testing.expectEqualStrings("E\nF", outside_text.text.?); | ||
| 1599 | try std.testing.expectEqual(e.historyRows() + 1, kept.points().?.anchor.row); | ||
| 1600 | const text = (try kept.extract(alloc, 100)).?; | ||
| 1601 | defer text.deinit(alloc); | ||
| 1602 | try std.testing.expectEqualStrings("C", text.text.?); | ||
| 1603 | } | ||
| 1604 | } | ||
| 1605 | } | ||
| 1606 | |||
| 1607 | test "Engine: history eviction makes tracked pins unavailable" { | ||
| 1608 | const alloc = std.testing.allocator; | ||
| 1609 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 3, .max_scrollback = 3 }); | ||
| 1610 | defer e.deinit(); | ||
| 1611 | |||
| 1612 | e.feed("old\r\none\r\ntwo\r\nthree\r\nfour"); | ||
| 1613 | const oldest = (try e.trackSelection(.{ .row = 0, .col = 0 }, .{ .row = 0, .col = 2 })).?; | ||
| 1614 | defer oldest.deinit(); | ||
| 1615 | // Scrollback is page-backed. Drive past several pages rather than | ||
| 1616 | // assuming max_scrollback is a line count. | ||
| 1617 | for (0..1600) |_| e.feed("discard\r\n"); | ||
| 1618 | try std.testing.expect(oldest.points() == null); | ||
| 1619 | } | ||
| 1620 | |||
| 951 | test "ghostty-vt boots headless and text lands in the grid" { | 1621 | test "ghostty-vt boots headless and text lands in the grid" { |
| 952 | const alloc = std.testing.allocator; | 1622 | const alloc = std.testing.allocator; |
| 953 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); | 1623 | var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 }); |
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -377,111 +377,106 @@ pub const SelectionPoint = struct { | |||
| 377 | col: u16, | 377 | col: u16, |
| 378 | }; | 378 | }; |
| 379 | 379 | ||
| 380 | /// One selection contract for direct extraction and per-client tracking. | ||
| 381 | /// Start guards the coordinate source; copy and clear name an owned gesture. | ||
| 380 | pub const SelectionReq = struct { | 382 | pub const SelectionReq = struct { |
| 383 | action: enum(u8) { extract, start, copy, clear } = .extract, | ||
| 381 | id: u32, | 384 | id: u32, |
| 385 | gesture: u32 = 0, | ||
| 386 | epoch: u64 = 0, | ||
| 387 | source: u64 = 0, | ||
| 382 | anchor: SelectionPoint, | 388 | anchor: SelectionPoint, |
| 383 | active: SelectionPoint, | 389 | active: SelectionPoint, |
| 384 | }; | 390 | }; |
| 385 | |||
| 386 | pub const selection_text_max: usize = 1024 * 1024; | 391 | pub const selection_text_max: usize = 1024 * 1024; |
| 387 | pub const selection_req_len: usize = 16; | 392 | pub const selection_req_len: usize = 37; |
| 388 | pub const selection_reply_prefix_len: usize = 9; | 393 | pub const selection_reply_prefix_len: usize = 41; |
| 389 | 394 | ||
| 390 | pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { | 395 | fn writeSelectionPoint(out: *[6]u8, point: SelectionPoint) void { |
| 391 | var buf: [selection_req_len]u8 = undefined; | 396 | std.mem.writeInt(u32, out[0..4], point.row, .little); |
| 392 | std.mem.writeInt(u32, buf[0..4], req.id, .little); | 397 | std.mem.writeInt(u16, out[4..6], point.col, .little); |
| 393 | std.mem.writeInt(u32, buf[4..8], req.anchor.row, .little); | ||
| 394 | std.mem.writeInt(u16, buf[8..10], req.anchor.col, .little); | ||
| 395 | std.mem.writeInt(u32, buf[10..14], req.active.row, .little); | ||
| 396 | std.mem.writeInt(u16, buf[14..16], req.active.col, .little); | ||
| 397 | return buf; | ||
| 398 | } | 398 | } |
| 399 | 399 | fn readSelectionPoint(bytes: *const [6]u8) SelectionPoint { | |
| 400 | pub fn decodeSelectionReq(payload: []const u8) !SelectionReq { | 400 | return .{ .row = std.mem.readInt(u32, bytes[0..4], .little), .col = std.mem.readInt(u16, bytes[4..6], .little) }; |
| 401 | if (payload.len != selection_req_len) return error.BadPayload; | 401 | } |
| 402 | return .{ | 402 | pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 { |
| 403 | .id = std.mem.readInt(u32, payload[0..4], .little), | 403 | var out: [selection_req_len]u8 = undefined; |
| 404 | .anchor = .{ | 404 | std.mem.writeInt(u32, out[0..4], req.id, .little); |
| 405 | .row = std.mem.readInt(u32, payload[4..8], .little), | 405 | writeSelectionPoint(out[4..10], req.anchor); |
| 406 | .col = std.mem.readInt(u16, payload[8..10], .little), | 406 | writeSelectionPoint(out[10..16], req.active); |
| 407 | }, | 407 | out[16] = @intFromEnum(req.action); |
| 408 | .active = .{ | 408 | std.mem.writeInt(u32, out[17..21], req.gesture, .little); |
| 409 | .row = std.mem.readInt(u32, payload[10..14], .little), | 409 | std.mem.writeInt(u64, out[21..29], req.epoch, .little); |
| 410 | .col = std.mem.readInt(u16, payload[14..16], .little), | 410 | std.mem.writeInt(u64, out[29..37], req.source, .little); |
| 411 | }, | 411 | return out; |
| 412 | } | ||
| 413 | pub fn decodeSelectionReq(bytes: []const u8) !SelectionReq { | ||
| 414 | if (bytes.len != selection_req_len) return error.BadPayload; | ||
| 415 | const req: SelectionReq = .{ | ||
| 416 | .id = std.mem.readInt(u32, bytes[0..4], .little), | ||
| 417 | .anchor = readSelectionPoint(bytes[4..10]), | ||
| 418 | .active = readSelectionPoint(bytes[10..16]), | ||
| 419 | .action = try enumFromByte(@FieldType(SelectionReq, "action"), bytes[16]), | ||
| 420 | .gesture = std.mem.readInt(u32, bytes[17..21], .little), | ||
| 421 | .epoch = std.mem.readInt(u64, bytes[21..29], .little), | ||
| 422 | .source = std.mem.readInt(u64, bytes[29..37], .little), | ||
| 412 | }; | 423 | }; |
| 424 | const valid = switch (req.action) { | ||
| 425 | .extract => req.id != 0 and req.gesture == 0, | ||
| 426 | .start, .copy => req.id != 0 and req.gesture != 0, | ||
| 427 | .clear => req.id == 0 and req.gesture != 0, | ||
| 428 | }; | ||
| 429 | if (!valid) return error.BadPayload; | ||
| 430 | return req; | ||
| 413 | } | 431 | } |
| 414 | 432 | pub const SelectionStatus = enum(u8) { ok = 0, invalid = 1, too_large = 2, unavailable = 3 }; | |
| 415 | pub const SelectionStatus = enum(u8) { | ||
| 416 | ok = 0, | ||
| 417 | invalid = 1, | ||
| 418 | too_large = 2, | ||
| 419 | unavailable = 3, | ||
| 420 | }; | ||
| 421 | |||
| 422 | pub const SelectionReply = struct { | 433 | pub const SelectionReply = struct { |
| 423 | id: u32, | 434 | id: u32 = 0, // zero is a position update, never a clipboard write |
| 424 | status: SelectionStatus, | 435 | gesture: u32 = 0, |
| 425 | /// Retained history rows on the screen the text was extracted from. Absolute | 436 | seq: u64 = 0, |
| 426 | /// rows count from the OLDEST RETAINED row, so an eviction shifts every one | 437 | source: u64 = 0, |
| 427 | /// of them under a request in flight and the reply is `.ok`, valid UTF-8, | ||
| 428 | /// and the wrong text. | ||
| 429 | /// | ||
| 430 | /// A watermark rather than a lease on purpose: the daemon holds no | ||
| 431 | /// per-client selection state, so the requester compares and decides. | ||
| 432 | /// Ordinary output RAISES it without moving row zero; only eviction lowers it. | ||
| 433 | history_rows: u32, | 438 | history_rows: u32, |
| 434 | /// Borrowed from the frame payload and valid only as long as that | 439 | status: SelectionStatus = .unavailable, |
| 435 | /// payload remains alive and unchanged. | 440 | anchor: SelectionPoint = .{ .row = 0, .col = 0 }, |
| 436 | text: []const u8, | 441 | active: SelectionPoint = .{ .row = 0, .col = 0 }, |
| 442 | /// Borrows the frame payload. Only successful copy/extract replies carry text. | ||
| 443 | text: []const u8 = &.{}, | ||
| 437 | }; | 444 | }; |
| 438 | 445 | fn checkSelectionText(reply: SelectionReply) !void { | |
| 439 | /// One rule for both ends of a selection_reply: `.ok` carries valid UTF-8 | 446 | if (reply.status != .ok or reply.id == 0) { |
| 440 | /// within the cap, every refusal carries nothing at all. A writer that | 447 | if (reply.text.len != 0) return error.BadPayload; |
| 441 | /// checked less than the reader would mint a frame it could not read back. | 448 | } else if (reply.text.len > selection_text_max or !std.unicode.utf8ValidateSlice(reply.text)) return error.BadPayload; |
| 442 | fn checkSelectionText(status: SelectionStatus, text_value: []const u8) !void { | 449 | } |
| 443 | switch (status) { | 450 | /// Validate before appending so malformed replies leave the caller's buffer intact. |
| 444 | .ok => { | 451 | pub fn encodeSelectionReply(out: *std.ArrayList(u8), alloc: std.mem.Allocator, reply: SelectionReply) !void { |
| 445 | if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value)) | 452 | try checkSelectionText(reply); |
| 446 | return error.BadPayload; | ||
| 447 | }, | ||
| 448 | .invalid, .too_large, .unavailable => { | ||
| 449 | if (text_value.len != 0) return error.BadPayload; | ||
| 450 | }, | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 454 | /// Validation completes before the first append, so `error.BadPayload` | ||
| 455 | /// leaves a reused `out` unchanged. | ||
| 456 | pub fn encodeSelectionReply( | ||
| 457 | out: *std.ArrayList(u8), | ||
| 458 | alloc: std.mem.Allocator, | ||
| 459 | id: u32, | ||
| 460 | status: SelectionStatus, | ||
| 461 | history_rows: u32, | ||
| 462 | text_value: []const u8, | ||
| 463 | ) !void { | ||
| 464 | try checkSelectionText(status, text_value); | ||
| 465 | |||
| 466 | var prefix: [selection_reply_prefix_len]u8 = undefined; | 453 | var prefix: [selection_reply_prefix_len]u8 = undefined; |
| 467 | std.mem.writeInt(u32, prefix[0..4], id, .little); | 454 | std.mem.writeInt(u32, prefix[0..4], reply.id, .little); |
| 468 | prefix[4] = @intFromEnum(status); | 455 | prefix[4] = @intFromEnum(reply.status); |
| 469 | std.mem.writeInt(u32, prefix[5..9], history_rows, .little); | 456 | std.mem.writeInt(u32, prefix[5..9], reply.history_rows, .little); |
| 457 | std.mem.writeInt(u32, prefix[9..13], reply.gesture, .little); | ||
| 458 | std.mem.writeInt(u64, prefix[13..21], reply.seq, .little); | ||
| 459 | std.mem.writeInt(u64, prefix[21..29], reply.source, .little); | ||
| 460 | writeSelectionPoint(prefix[29..35], reply.anchor); | ||
| 461 | writeSelectionPoint(prefix[35..41], reply.active); | ||
| 470 | try out.appendSlice(alloc, &prefix); | 462 | try out.appendSlice(alloc, &prefix); |
| 471 | try out.appendSlice(alloc, text_value); | 463 | try out.appendSlice(alloc, reply.text); |
| 472 | } | 464 | } |
| 473 | 465 | pub fn decodeSelectionReply(bytes: []const u8) !SelectionReply { | |
| 474 | pub fn decodeSelectionReply(payload: []const u8) !SelectionReply { | 466 | if (bytes.len < selection_reply_prefix_len) return error.BadPayload; |
| 475 | if (payload.len < selection_reply_prefix_len) return error.BadPayload; | 467 | const reply: SelectionReply = .{ |
| 476 | const status = try enumFromByte(SelectionStatus, payload[4]); | 468 | .id = std.mem.readInt(u32, bytes[0..4], .little), |
| 477 | const text_value = payload[selection_reply_prefix_len..]; | 469 | .status = try enumFromByte(SelectionStatus, bytes[4]), |
| 478 | try checkSelectionText(status, text_value); | 470 | .history_rows = std.mem.readInt(u32, bytes[5..9], .little), |
| 479 | return .{ | 471 | .gesture = std.mem.readInt(u32, bytes[9..13], .little), |
| 480 | .id = std.mem.readInt(u32, payload[0..4], .little), | 472 | .seq = std.mem.readInt(u64, bytes[13..21], .little), |
| 481 | .status = status, | 473 | .source = std.mem.readInt(u64, bytes[21..29], .little), |
| 482 | .history_rows = std.mem.readInt(u32, payload[5..9], .little), | 474 | .anchor = readSelectionPoint(bytes[29..35]), |
| 483 | .text = text_value, | 475 | .active = readSelectionPoint(bytes[35..41]), |
| 476 | .text = bytes[selection_reply_prefix_len..], | ||
| 484 | }; | 477 | }; |
| 478 | try checkSelectionText(reply); | ||
| 479 | return reply; | ||
| 485 | } | 480 | } |
| 486 | 481 | ||
| 487 | /// Where the command-boundary signal came from, weakest-last. `marks` is the | 482 | /// Where the command-boundary signal came from, weakest-last. `marks` is the |
| @@ -2672,7 +2667,8 @@ test "selection request has a fixed little-endian wire layout" { | |||
| 2672 | 0x14, 0x13, 0x12, 0x11, | 2667 | 0x14, 0x13, 0x12, 0x11, |
| 2673 | 0x22, 0x21, 0x34, 0x33, | 2668 | 0x22, 0x21, 0x34, 0x33, |
| 2674 | 0x32, 0x31, 0x42, 0x41, | 2669 | 0x32, 0x31, 0x42, 0x41, |
| 2675 | }, &bytes); | 2670 | }, bytes[0..16]); |
| 2671 | try std.testing.expectEqualSlices(u8, &(@as([21]u8, @splat(0))), bytes[16..]); | ||
| 2676 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); | 2672 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); |
| 2677 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15])); | 2673 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15])); |
| 2678 | var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1); | 2674 | var long: [selection_req_len + 1]u8 = .{0} ** (selection_req_len + 1); |
| @@ -2685,11 +2681,11 @@ test "selection reply validates status and text shape" { | |||
| 2685 | var payload: std.ArrayList(u8) = .empty; | 2681 | var payload: std.ArrayList(u8) = .empty; |
| 2686 | defer payload.deinit(alloc); | 2682 | defer payload.deinit(alloc); |
| 2687 | 2683 | ||
| 2688 | try encodeSelectionReply(&payload, alloc, 7, .ok, 0x11223344, "hello"); | 2684 | try encodeSelectionReply(&payload, alloc, .{ .id = 7, .status = .ok, .history_rows = 0x11223344, .text = "hello" }); |
| 2689 | try std.testing.expectEqualSlices( | 2685 | try std.testing.expectEqualSlices( |
| 2690 | u8, | 2686 | u8, |
| 2691 | &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11, 'h', 'e', 'l', 'l', 'o' }, | 2687 | &.{ 7, 0, 0, 0, 0, 0x44, 0x33, 0x22, 0x11 }, |
| 2692 | payload.items, | 2688 | payload.items[0..9], |
| 2693 | ); | 2689 | ); |
| 2694 | const ok = try decodeSelectionReply(payload.items); | 2690 | const ok = try decodeSelectionReply(payload.items); |
| 2695 | try std.testing.expectEqual(@as(u32, 7), ok.id); | 2691 | try std.testing.expectEqual(@as(u32, 7), ok.id); |
| @@ -2698,27 +2694,60 @@ test "selection reply validates status and text shape" { | |||
| 2698 | try std.testing.expectEqualStrings("hello", ok.text); | 2694 | try std.testing.expectEqualStrings("hello", ok.text); |
| 2699 | 2695 | ||
| 2700 | payload.clearRetainingCapacity(); | 2696 | payload.clearRetainingCapacity(); |
| 2701 | try encodeSelectionReply(&payload, alloc, 9, .invalid, 5, ""); | 2697 | try encodeSelectionReply(&payload, alloc, .{ .id = 9, .status = .invalid, .history_rows = 5, .text = "" }); |
| 2702 | try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items); | 2698 | try std.testing.expectEqualSlices(u8, &.{ 9, 0, 0, 0, 1, 5, 0, 0, 0 }, payload.items[0..9]); |
| 2703 | const invalid = try decodeSelectionReply(payload.items); | 2699 | const invalid = try decodeSelectionReply(payload.items); |
| 2704 | try std.testing.expectEqual(@as(u32, 9), invalid.id); | 2700 | try std.testing.expectEqual(@as(u32, 9), invalid.id); |
| 2705 | try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); | 2701 | try std.testing.expectEqual(SelectionStatus.invalid, invalid.status); |
| 2706 | try std.testing.expectEqual(@as(u32, 5), invalid.history_rows); | 2702 | try std.testing.expectEqual(@as(u32, 5), invalid.history_rows); |
| 2707 | try std.testing.expectEqual(@as(usize, 0), invalid.text.len); | 2703 | try std.testing.expectEqual(@as(usize, 0), invalid.text.len); |
| 2708 | 2704 | ||
| 2705 | payload.items[4] = 0xff; | ||
| 2706 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2707 | payload.items[4] = @intFromEnum(SelectionStatus.invalid); | ||
| 2708 | try payload.append(alloc, 'x'); | ||
| 2709 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2710 | payload.items[4] = @intFromEnum(SelectionStatus.ok); | ||
| 2711 | payload.items[selection_reply_prefix_len] = 0xff; | ||
| 2712 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(payload.items)); | ||
| 2713 | |||
| 2709 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 })); | 2714 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff, 0, 0, 0, 0 })); |
| 2710 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); | 2715 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 0, 0, 0, 0, 'x' })); |
| 2711 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff })); | 2716 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0xff })); |
| 2712 | 2717 | ||
| 2713 | // The watermark is part of the prefix, so a reply carrying only the | 2718 | // The watermark is part of the prefix, so a reply carrying only the |
| 2714 | // pre-watermark five bytes is short, not a legacy reply to interpret. | 2719 | // pre-watermark five bytes is short, not a legacy reply to interpret. |
| 2715 | const prefix = [_]u8{ 1, 0, 0, 0, 0, 0, 0, 0, 0 }; | 2720 | const prefix: [selection_reply_prefix_len]u8 = @splat(0); |
| 2716 | for (0..selection_reply_prefix_len) |len| { | 2721 | for (0..selection_reply_prefix_len) |len| { |
| 2717 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); | 2722 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(prefix[0..len])); |
| 2718 | } | 2723 | } |
| 2719 | } | 2724 | } |
| 2720 | 2725 | ||
| 2721 | test "selection status discriminants have exact nine-byte golden replies" { | 2726 | test "selection tracking has one request and reply codec with guarded action shapes" { |
| 2727 | var req: SelectionReq = .{ .action = .start, .id = 7, .gesture = 9, .epoch = 11, .source = 13, .anchor = .{ .row = 70000, .col = 3 }, .active = .{ .row = 70001, .col = 4 } }; | ||
| 2728 | const bytes = encodeSelectionReq(req); | ||
| 2729 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes)); | ||
| 2730 | try std.testing.expectEqualSlices(u8, &.{ 1, 9, 0, 0, 0, 11, 0, 0, 0, 0, 0, 0, 0, 13, 0, 0, 0, 0, 0, 0, 0 }, bytes[16..]); | ||
| 2731 | req.id = 0; | ||
| 2732 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2733 | req.action = .clear; | ||
| 2734 | try std.testing.expectEqualDeep(req, try decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2735 | req.gesture = 0; | ||
| 2736 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&encodeSelectionReq(req))); | ||
| 2737 | var bad = bytes; | ||
| 2738 | bad[16] = 0xff; | ||
| 2739 | try std.testing.expectError(error.BadPayload, decodeSelectionReq(&bad)); | ||
| 2740 | |||
| 2741 | const reply: SelectionReply = .{ .gesture = 9, .seq = 15, .source = 13, .history_rows = 69999, .status = .ok, .anchor = req.anchor, .active = req.active }; | ||
| 2742 | var out: std.ArrayList(u8) = .empty; | ||
| 2743 | defer out.deinit(std.testing.allocator); | ||
| 2744 | try encodeSelectionReply(&out, std.testing.allocator, reply); | ||
| 2745 | try std.testing.expectEqualDeep(reply, try decodeSelectionReply(out.items)); | ||
| 2746 | try out.append(std.testing.allocator, 'x'); | ||
| 2747 | try std.testing.expectError(error.BadPayload, decodeSelectionReply(out.items)); | ||
| 2748 | } | ||
| 2749 | |||
| 2750 | test "selection status discriminants retain exact scalar prefixes" { | ||
| 2722 | const alloc = std.testing.allocator; | 2751 | const alloc = std.testing.allocator; |
| 2723 | const cases = [_]struct { status: SelectionStatus, wire: u8 }{ | 2752 | const cases = [_]struct { status: SelectionStatus, wire: u8 }{ |
| 2724 | .{ .status = .ok, .wire = 0 }, | 2753 | .{ .status = .ok, .wire = 0 }, |
| @@ -2729,11 +2758,11 @@ test "selection status discriminants have exact nine-byte golden replies" { | |||
| 2729 | for (cases) |case| { | 2758 | for (cases) |case| { |
| 2730 | var payload: std.ArrayList(u8) = .empty; | 2759 | var payload: std.ArrayList(u8) = .empty; |
| 2731 | defer payload.deinit(alloc); | 2760 | defer payload.deinit(alloc); |
| 2732 | try encodeSelectionReply(&payload, alloc, 0x01020304, case.status, 0x0a0b0c0d, ""); | 2761 | try encodeSelectionReply(&payload, alloc, .{ .id = 0x01020304, .status = case.status, .history_rows = 0x0a0b0c0d, .text = "" }); |
| 2733 | try std.testing.expectEqualSlices( | 2762 | try std.testing.expectEqualSlices( |
| 2734 | u8, | 2763 | u8, |
| 2735 | &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a }, | 2764 | &.{ 0x04, 0x03, 0x02, 0x01, case.wire, 0x0d, 0x0c, 0x0b, 0x0a }, |
| 2736 | payload.items, | 2765 | payload.items[0..9], |
| 2737 | ); | 2766 | ); |
| 2738 | const reply = try decodeSelectionReply(payload.items); | 2767 | const reply = try decodeSelectionReply(payload.items); |
| 2739 | try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); | 2768 | try std.testing.expectEqual(@as(u32, 0x01020304), reply.id); |
| @@ -2751,7 +2780,7 @@ test "selection text accepts the exact cap and rejects one byte more" { | |||
| 2751 | 2780 | ||
| 2752 | var payload: std.ArrayList(u8) = .empty; | 2781 | var payload: std.ArrayList(u8) = .empty; |
| 2753 | defer payload.deinit(alloc); | 2782 | defer payload.deinit(alloc); |
| 2754 | try encodeSelectionReply(&payload, alloc, 1, .ok, 0, at_cap); | 2783 | try encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = at_cap }); |
| 2755 | try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); | 2784 | try std.testing.expectEqual(selection_reply_prefix_len + selection_text_max, payload.items.len); |
| 2756 | const decoded = try decodeSelectionReply(payload.items); | 2785 | const decoded = try decodeSelectionReply(payload.items); |
| 2757 | try std.testing.expectEqual(selection_text_max, decoded.text.len); | 2786 | try std.testing.expectEqual(selection_text_max, decoded.text.len); |
| @@ -2759,7 +2788,7 @@ test "selection text accepts the exact cap and rejects one byte more" { | |||
| 2759 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); | 2788 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); |
| 2760 | defer alloc.free(over_cap); | 2789 | defer alloc.free(over_cap); |
| 2761 | @memset(over_cap, 'x'); | 2790 | @memset(over_cap, 'x'); |
| 2762 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); | 2791 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap })); |
| 2763 | 2792 | ||
| 2764 | var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); | 2793 | var oversized_payload = try alloc.alloc(u8, selection_reply_prefix_len + selection_text_max + 1); |
| 2765 | defer alloc.free(oversized_payload); | 2794 | defer alloc.free(oversized_payload); |
| @@ -2776,20 +2805,20 @@ test "selection reply validation errors do not modify a reused output buffer" { | |||
| 2776 | defer payload.deinit(alloc); | 2805 | defer payload.deinit(alloc); |
| 2777 | try payload.appendSlice(alloc, "sentinel"); | 2806 | try payload.appendSlice(alloc, "sentinel"); |
| 2778 | 2807 | ||
| 2779 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .invalid, 0, "x")); | 2808 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .invalid, .history_rows = 0, .text = "x" })); |
| 2780 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2809 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2781 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, &.{0xff})); | 2810 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = &.{0xff} })); |
| 2782 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2811 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2783 | 2812 | ||
| 2784 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); | 2813 | const over_cap = try alloc.alloc(u8, selection_text_max + 1); |
| 2785 | defer alloc.free(over_cap); | 2814 | defer alloc.free(over_cap); |
| 2786 | @memset(over_cap, 'x'); | 2815 | @memset(over_cap, 'x'); |
| 2787 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, 1, .ok, 0, over_cap)); | 2816 | try std.testing.expectError(error.BadPayload, encodeSelectionReply(&payload, alloc, .{ .id = 1, .status = .ok, .history_rows = 0, .text = over_cap })); |
| 2788 | try std.testing.expectEqualStrings("sentinel", payload.items); | 2817 | try std.testing.expectEqualStrings("sentinel", payload.items); |
| 2789 | } | 2818 | } |
| 2790 | 2819 | ||
| 2791 | test "decoded selection text borrows the payload" { | 2820 | test "decoded selection text borrows the payload" { |
| 2792 | var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0, 'o', 'n', 'e' }; | 2821 | var payload = [_]u8{ 7, 0, 0, 0, 0, 3, 0, 0, 0 } ++ [_]u8{0} ** 32 ++ [_]u8{ 'o', 'n', 'e' }; |
| 2793 | const reply = try decodeSelectionReply(&payload); | 2822 | const reply = try decodeSelectionReply(&payload); |
| 2794 | try std.testing.expectEqualStrings("one", reply.text); | 2823 | try std.testing.expectEqualStrings("one", reply.text); |
| 2795 | payload[selection_reply_prefix_len] = 'O'; | 2824 | payload[selection_reply_prefix_len] = 'O'; |
| @@ -2799,8 +2828,8 @@ test "decoded selection text borrows the payload" { | |||
| 2799 | test "selection message values and fixed lengths are pinned" { | 2828 | test "selection message values and fixed lengths are pinned" { |
| 2800 | try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); | 2829 | try std.testing.expectEqual(@as(u8, 0x0b), @intFromEnum(MsgType.selection_req)); |
| 2801 | try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); | 2830 | try std.testing.expectEqual(@as(u8, 0x90), @intFromEnum(MsgType.selection_reply)); |
| 2802 | try std.testing.expectEqual(@as(usize, 16), selection_req_len); | 2831 | try std.testing.expectEqual(@as(usize, 37), selection_req_len); |
| 2803 | try std.testing.expectEqual(@as(usize, 9), selection_reply_prefix_len); | 2832 | try std.testing.expectEqual(@as(usize, 41), selection_reply_prefix_len); |
| 2804 | try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); | 2833 | try std.testing.expectEqual(@as(usize, 1024 * 1024), selection_text_max); |
| 2805 | } | 2834 | } |
| 2806 | 2835 | ||
src/gui/interaction.zig
| Old | New | ||
|---|---|---|---|
| @@ -98,6 +98,7 @@ pub const Controller = struct { | |||
| 98 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), | 98 | wheel_remainder: [model.max_panes]struct { key: ?model.Attachment = null, wheel: Wheel = .{} } = @splat(.{}), |
| 99 | selection_key: ?model.Attachment = null, | 99 | selection_key: ?model.Attachment = null, |
| 100 | selection_request: u32 = 0, | 100 | selection_request: u32 = 0, |
| 101 | selection_gesture: u32 = 0, | ||
| 101 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | 102 | selection_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, |
| 102 | consumed_key: ?u32 = null, | 103 | consumed_key: ?u32 = null, |
| 103 | notice: []const u8 = "", | 104 | notice: []const u8 = "", |
| @@ -125,6 +126,7 @@ pub const Controller = struct { | |||
| 125 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); | 126 | if (self.selection_key) |key| if (self.rt.get(key.pane)) |live| live.pump.cancelSelection(); |
| 126 | self.selection_drag.clear(); | 127 | self.selection_drag.clear(); |
| 127 | self.selection_key = null; | 128 | self.selection_key = null; |
| 129 | self.selection_gesture = 0; | ||
| 128 | self.dirty = true; | 130 | self.dirty = true; |
| 129 | } | 131 | } |
| 130 | fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit { | 132 | fn hit(self: *Controller, x: u32, y: u32) ?client.selection.Hit { |
| @@ -146,6 +148,15 @@ pub const Controller = struct { | |||
| 146 | } | 148 | } |
| 147 | pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span { | 149 | pub fn selectedSpan(self: *const Controller, tile: usize, row: u32, cols: u16) ?client.selection.Span { |
| 148 | if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) { | 150 | if (self.selection_key) |key| if (@as(usize, @intCast(key.pane)) == tile and self.rt.accepts(key)) { |
| 151 | if (self.selection_gesture != 0 and !self.selection_drag.buttonHeld()) { | ||
| 152 | const position = self.rt.get(key.pane).?.snapshot_follow orelse return null; | ||
| 153 | if (position.id != self.selection_gesture or position.status != .ok) return null; | ||
| 154 | const range: client.selection.Range = .{ | ||
| 155 | .from = .{ .tile = tile, .row = position.anchor.row, .col = position.anchor.col }, | ||
| 156 | .to = .{ .tile = tile, .row = position.active.row, .col = position.active.col }, | ||
| 157 | }; | ||
| 158 | return range.span(tile, row, cols); | ||
| 159 | } | ||
| 149 | return (self.selection_drag.range() orelse return null).span(tile, row, cols); | 160 | return (self.selection_drag.range() orelse return null).span(tile, row, cols); |
| 150 | }; | 161 | }; |
| 151 | return null; | 162 | return null; |
| @@ -160,7 +171,13 @@ pub const Controller = struct { | |||
| 160 | self.clearSelection(); | 171 | self.clearSelection(); |
| 161 | return true; | 172 | return true; |
| 162 | }; | 173 | }; |
| 163 | if (!self.rt.accepts(key) or !live.pump.selectionFresh(self.selection_version)) { | 174 | const fresh = if (self.selection_drag.buttonHeld()) |
| 175 | live.pump.selectionFresh(self.selection_version) | ||
| 176 | else if (self.selection_gesture != 0) | ||
| 177 | live.pump.selectionAlive(self.selection_gesture, self.selection_version) | ||
| 178 | else | ||
| 179 | live.pump.selectionFresh(self.selection_version); | ||
| 180 | if (!self.rt.accepts(key) or !fresh) { | ||
| 164 | self.clearSelection(); | 181 | self.clearSelection(); |
| 165 | return true; | 182 | return true; |
| 166 | } | 183 | } |
| @@ -172,7 +189,7 @@ pub const Controller = struct { | |||
| 172 | const live = self.rt.get(key.pane) orelse return null; | 189 | const live = self.rt.get(key.pane) orelse return null; |
| 173 | if (!self.rt.accepts(key)) return null; | 190 | if (!self.rt.accepts(key)) return null; |
| 174 | const result = live.pump.takeSelection() orelse return null; | 191 | const result = live.pump.takeSelection() orelse return null; |
| 175 | if (result.id != self.selection_request or !std.meta.eql(result.version, self.selection_version) or result.status != .ok) { | 192 | if (result.id != self.selection_request or result.gesture != self.selection_gesture or result.status != .ok) { |
| 176 | self.rt.alloc.free(result.text); | 193 | self.rt.alloc.free(result.text); |
| 177 | if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable"); | 194 | if (result.status != .ok) self.setNotice(if (result.status == .too_large) "Selection too large" else "Selection unavailable"); |
| 178 | return null; | 195 | return null; |
| @@ -455,6 +472,9 @@ pub const Controller = struct { | |||
| 455 | switch (self.selection_drag.release()) { | 472 | switch (self.selection_drag.release()) { |
| 456 | .click => {}, | 473 | .click => {}, |
| 457 | .selection => |range| { | 474 | .selection => |range| { |
| 475 | // A release always starts a new daemon tracker, even when a | ||
| 476 | // copy was requested while the button was held. | ||
| 477 | self.selection_gesture = 0; | ||
| 458 | try self.queueSelection(live, range); | 478 | try self.queueSelection(live, range); |
| 459 | }, | 479 | }, |
| 460 | .nothing => self.clearSelection(), | 480 | .nothing => self.clearSelection(), |
| @@ -463,14 +483,22 @@ pub const Controller = struct { | |||
| 463 | } | 483 | } |
| 464 | fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void { | 484 | fn queueSelection(self: *Controller, live: *runtime.Live, range: client.selection.Range) !void { |
| 465 | self.selection_request +%= 1; | 485 | self.selection_request +%= 1; |
| 466 | try self.rt.requestSelection(live.key, self.selection_request, range, self.selection_version); | 486 | if (self.selection_request == 0) self.selection_request = 1; |
| 487 | if (self.selection_gesture == 0) self.selection_gesture = self.selection_request; | ||
| 488 | try self.rt.requestSelection(live.key, self.selection_request, self.selection_gesture, range, self.selection_version); | ||
| 467 | } | 489 | } |
| 468 | pub fn copySelection(self: *Controller) !void { | 490 | pub fn copySelection(self: *Controller) !void { |
| 469 | const key = self.selection_key orelse return; | 491 | const key = self.selection_key orelse return; |
| 470 | if (!self.rt.accepts(key)) return self.clearSelection(); | 492 | if (!self.rt.accepts(key)) return self.clearSelection(); |
| 471 | const live = self.rt.get(key.pane) orelse return; | 493 | const live = self.rt.get(key.pane) orelse return; |
| 472 | const range = self.selection_drag.range() orelse return; | 494 | const range = self.selection_drag.range() orelse return; |
| 473 | if (!live.pump.selectionFresh(self.selection_version)) return self.clearSelection(); | 495 | const held = self.selection_drag.buttonHeld(); |
| 496 | const valid = if (held) | ||
| 497 | live.pump.selectionFresh(self.selection_version) | ||
| 498 | else | ||
| 499 | live.pump.selectionAlive(self.selection_gesture, self.selection_version); | ||
| 500 | if (!valid) return self.clearSelection(); | ||
| 501 | if (held) self.selection_gesture = 0; | ||
| 474 | try self.queueSelection(live, range); | 502 | try self.queueSelection(live, range); |
| 475 | } | 503 | } |
| 476 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { | 504 | pub fn pointerMove(self: *Controller, x: i64, y: i64) !void { |
| @@ -834,6 +862,45 @@ test "selection hit keeps absolute history and pane-local pointer cells" { | |||
| 834 | try std.testing.expect(ui.selection_drag.range() == null); | 862 | try std.testing.expect(ui.selection_drag.range() == null); |
| 835 | } | 863 | } |
| 836 | 864 | ||
| 865 | test "held selection copies restart the gesture and release starts another" { | ||
| 866 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | ||
| 867 | defer rt.deinit(); | ||
| 868 | const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 }; | ||
| 869 | const id = try rt.add(.{ .via = "cat" }, "copy-gesture", 800, 600, metrics); | ||
| 870 | var ui: Controller = .{ .rt = &rt, .metrics = metrics, .fb_w = 800, .fb_h = 600 }; | ||
| 871 | defer ui.deinit(); | ||
| 872 | ui.layout = rt.workspace.layout(800, 600, metrics); | ||
| 873 | const live = rt.get(id).?; | ||
| 874 | live.pump.mu.lock(); | ||
| 875 | try live.snapshot.resize(22, 9); | ||
| 876 | live.pump.status.phase = .attached; | ||
| 877 | live.pump.admitted = true; | ||
| 878 | live.pump.replica.state_since_attach = true; | ||
| 879 | live.pump.follow_seq = live.pump.replica.last_seq; | ||
| 880 | live.pump.follow_source = 1; | ||
| 881 | const source_version = live.pump.selectionVersionLocked(); | ||
| 882 | live.pump.mu.unlock(); | ||
| 883 | const p = ui.layout.get(id).?.content; | ||
| 884 | try ui.pointerDown(p.x + 4, p.y + 8, 0, 0); | ||
| 885 | ui.selection_version = source_version; | ||
| 886 | try ui.pointerMove(p.x + 20, p.y + 8); | ||
| 887 | const first_range = ui.selection_drag.range().?; | ||
| 888 | try std.testing.expectEqual(@as(u16, 2), first_range.to.col); | ||
| 889 | try ui.copySelection(); | ||
| 890 | const first = ui.selection_gesture; | ||
| 891 | try std.testing.expect(first != 0); | ||
| 892 | try ui.pointerMove(p.x + 28, p.y + 8); | ||
| 893 | const extended_range = ui.selection_drag.range().?; | ||
| 894 | try std.testing.expectEqual(@as(u16, 3), extended_range.to.col); | ||
| 895 | try std.testing.expect(extended_range.to.col != first_range.to.col); | ||
| 896 | try ui.copySelection(); | ||
| 897 | try std.testing.expect(ui.selection_gesture != first); | ||
| 898 | const second = ui.selection_gesture; | ||
| 899 | try ui.pointerUp(p.x + 36, p.y + 8); | ||
| 900 | try std.testing.expect(ui.selection_gesture != second); | ||
| 901 | try std.testing.expectEqual(@as(u16, 4), ui.selection_drag.range().?.to.col); | ||
| 902 | } | ||
| 903 | |||
| 837 | test "wheel reuses an existing pane remainder before a vacant earlier slot" { | 904 | test "wheel reuses an existing pane remainder before a vacant earlier slot" { |
| 838 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); | 905 | var rt = runtime.Runtime.init(std.testing.allocator, .{}); |
| 839 | defer rt.deinit(); | 906 | defer rt.deinit(); |
src/gui/runtime.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ pub const Live = struct { | |||
| 12 | snapshot: *term.grid.Grid, | 12 | snapshot: *term.grid.Grid, |
| 13 | snapshot_seq: u64 = 0, | 13 | snapshot_seq: u64 = 0, |
| 14 | snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, | 14 | snapshot_version: client.session_pump.SelectionVersion = .{ .seq = 0, .history_rows = 0, .epoch = 0, .revision = 0 }, |
| 15 | snapshot_follow: ?client.session_pump.FollowPosition = null, | ||
| 15 | painted_seq: u64 = 0, | 16 | painted_seq: u64 = 0, |
| 16 | view_origin: u32 = 0, | 17 | view_origin: u32 = 0, |
| 17 | status: client.session_pump.State = .{}, | 18 | status: client.session_pump.State = .{}, |
| @@ -51,6 +52,7 @@ pub const Live = struct { | |||
| 51 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); | 52 | try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); |
| 52 | self.snapshot_seq = self.pump.replica.last_seq; | 53 | self.snapshot_seq = self.pump.replica.last_seq; |
| 53 | self.snapshot_version = self.pump.selectionVersionLocked(); | 54 | self.snapshot_version = self.pump.selectionVersionLocked(); |
| 55 | self.snapshot_follow = self.pump.followPositionLocked(); | ||
| 54 | self.view_origin = self.pump.viewOriginLocked(); | 56 | self.view_origin = self.pump.viewOriginLocked(); |
| 55 | return self.pump.last_apply_us; | 57 | return self.pump.last_apply_us; |
| 56 | } | 58 | } |
| @@ -183,10 +185,10 @@ pub const Runtime = struct { | |||
| 183 | try live.pump.say(.{ .wheel = event }); | 185 | try live.pump.say(.{ .wheel = event }); |
| 184 | } | 186 | } |
| 185 | 187 | ||
| 186 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { | 188 | pub fn requestSelection(self: *Runtime, key: model.Attachment, id: u32, gesture: u32, range: client.selection.Range, version: client.session_pump.SelectionVersion) !void { |
| 187 | const live = self.get(key.pane) orelse return error.MissingPane; | 189 | const live = self.get(key.pane) orelse return error.MissingPane; |
| 188 | if (!self.accepts(key)) return error.StaleAttachment; | 190 | if (!self.accepts(key)) return error.StaleAttachment; |
| 189 | try live.pump.say(.{ .selection = .{ .id = id, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } }); | 191 | try live.pump.say(.{ .selection = .{ .id = id, .gesture = gesture, .anchor = .{ .row = range.from.row, .col = range.from.col }, .active = .{ .row = range.to.row, .col = range.to.col }, .version = version } }); |
| 190 | } | 192 | } |
| 191 | pub fn poll(self: *Runtime, now: i64) bool { | 193 | pub fn poll(self: *Runtime, now: i64) bool { |
| 192 | var changed = false; | 194 | var changed = false; |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -268,6 +268,12 @@ const ClientSlot = struct { | |||
| 268 | /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and | 268 | /// This client volunteered an SSH agent (`.agent_offer`). Opt-in and |
| 269 | /// per-connection: a redial is a new slot and must offer again. | 269 | /// per-connection: a redial is a new slot and must offer again. |
| 270 | agent_offer: bool = false, | 270 | agent_offer: bool = false, |
| 271 | selection: ?struct { id: u32, tracked: *Engine.TrackedSelection } = null, | ||
| 272 | |||
| 273 | fn clearSelection(self: *ClientSlot) void { | ||
| 274 | if (self.selection) |*selection| selection.tracked.deinit(); | ||
| 275 | self.selection = null; | ||
| 276 | } | ||
| 271 | }; | 277 | }; |
| 272 | 278 | ||
| 273 | /// One client's outstanding await: the request as asked, plus the two pieces | 279 | /// One client's outstanding await: the request as asked, plus the two pieces |
| @@ -1287,6 +1293,7 @@ pub const Server = struct { | |||
| 1287 | fn teardownClient(self: *Server, i: usize, close_sink: bool) void { | 1293 | fn teardownClient(self: *Server, i: usize, close_sink: bool) void { |
| 1288 | self.agents.closeOfClient(self, i); | 1294 | self.agents.closeOfClient(self, i); |
| 1289 | if (self.clients[i]) |*slot| { | 1295 | if (self.clients[i]) |*slot| { |
| 1296 | slot.clearSelection(); | ||
| 1290 | slot.pending.deinit(self.alloc); | 1297 | slot.pending.deinit(self.alloc); |
| 1291 | slot.inbound.deinit(self.alloc); | 1298 | slot.inbound.deinit(self.alloc); |
| 1292 | if (close_sink) slot.sink.close(); | 1299 | if (close_sink) slot.sink.close(); |
| @@ -1322,26 +1329,22 @@ pub const Server = struct { | |||
| 1322 | return self.clients[i] != null; | 1329 | return self.clients[i] != null; |
| 1323 | } | 1330 | } |
| 1324 | 1331 | ||
| 1325 | /// The lane is TOTAL: a decodable request leaves with exactly one answer, | 1332 | pub fn queueSelectionReply(self: *Server, i: usize, reply: proto.SelectionReply) void { |
| 1326 | /// so the fallback reserves capacity first. | 1333 | var bytes: std.ArrayList(u8) = .empty; |
| 1327 | pub fn queueSelectionReply( | 1334 | defer bytes.deinit(self.alloc); |
| 1328 | self: *Server, | 1335 | // Reserve the refusal before formatting a potentially large reply. |
| 1329 | i: usize, | 1336 | bytes.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch { |
| 1330 | id: u32, | 1337 | self.dropClient(i); |
| 1331 | status: proto.SelectionStatus, | ||
| 1332 | history_rows: u32, | ||
| 1333 | value: []const u8, | ||
| 1334 | ) void { | ||
| 1335 | var payload: std.ArrayList(u8) = .empty; | ||
| 1336 | defer payload.deinit(self.alloc); | ||
| 1337 | payload.ensureTotalCapacity(self.alloc, proto.selection_reply_prefix_len) catch return; | ||
| 1338 | proto.encodeSelectionReply(&payload, self.alloc, id, status, history_rows, value) catch { | ||
| 1339 | payload.clearRetainingCapacity(); | ||
| 1340 | proto.encodeSelectionReply(&payload, self.alloc, id, .unavailable, 0, "") catch return; | ||
| 1341 | _ = self.queueFrame(i, .selection_reply, payload.items); | ||
| 1342 | return; | 1338 | return; |
| 1343 | }; | 1339 | }; |
| 1344 | _ = self.queueFrame(i, .selection_reply, payload.items); | 1340 | proto.encodeSelectionReply(&bytes, self.alloc, reply) catch { |
| 1341 | bytes.clearRetainingCapacity(); | ||
| 1342 | var refusal = reply; | ||
| 1343 | refusal.status = .unavailable; | ||
| 1344 | refusal.text = &.{}; | ||
| 1345 | proto.encodeSelectionReply(&bytes, self.alloc, refusal) catch unreachable; | ||
| 1346 | }; | ||
| 1347 | _ = self.queueFrame(i, .selection_reply, bytes.items); | ||
| 1345 | } | 1348 | } |
| 1346 | 1349 | ||
| 1347 | /// Wait up to `budget_ms` for clients to accept what they are owed. Only | 1350 | /// Wait up to `budget_ms` for clients to accept what they are owed. Only |
| @@ -1855,7 +1858,7 @@ pub const Server = struct { | |||
| 1855 | .resize => self.onResize(i, frame), | 1858 | .resize => self.onResize(i, frame), |
| 1856 | .input => self.onInput(i, frame), | 1859 | .input => self.onInput(i, frame), |
| 1857 | .fetch_scrollback => self.onFetchScrollback(i, frame), | 1860 | .fetch_scrollback => self.onFetchScrollback(i, frame), |
| 1858 | .selection_req => self.onSelectionReq(i, frame), | 1861 | .selection_req => self.onSelectionReq(i, frame.payload), |
| 1859 | .detach => self.dropClient(i), | 1862 | .detach => self.dropClient(i), |
| 1860 | .status_req => self.onStatusReq(i, frame), | 1863 | .status_req => self.onStatusReq(i, frame), |
| 1861 | .await_req => self.onAwaitReq(i, frame), | 1864 | .await_req => self.onAwaitReq(i, frame), |
| @@ -2018,6 +2021,7 @@ pub const Server = struct { | |||
| 2018 | return; | 2021 | return; |
| 2019 | }; | 2022 | }; |
| 2020 | if (self.clients[i]) |*c| { | 2023 | if (self.clients[i]) |*c| { |
| 2024 | c.clearSelection(); | ||
| 2021 | // Seq series are per-session, so an await's `since_seq` from the | 2025 | // Seq series are per-session, so an await's `since_seq` from the |
| 2022 | // old one would answer instantly or never, arbitrarily. Only on a | 2026 | // old one would answer instantly or never, arbitrarily. Only on a |
| 2023 | // real change: a reconnect re-resolving the same name keeps its await. | 2027 | // real change: a reconnect re-resolving the same name keeps its await. |
| @@ -2080,47 +2084,79 @@ pub const Server = struct { | |||
| 2080 | @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count)); | 2084 | @memcpy(payload[0..6], &proto.encodeScrollbackReq(got.first, got.count)); |
| 2081 | @memcpy(payload[6..], got.bytes); | 2085 | @memcpy(payload[6..], got.bytes); |
| 2082 | _ = self.queueFrame(i, .scrollback_chunk, payload); | 2086 | _ = self.queueFrame(i, .scrollback_chunk, payload); |
| 2087 | self.sendSelectionState(si, i); | ||
| 2083 | } | 2088 | } |
| 2084 | 2089 | ||
| 2085 | fn onSelectionReq(self: *Server, i: usize, frame: proto.Frame) void { | 2090 | fn onSelectionReq(self: *Server, i: usize, payload: []const u8) void { |
| 2086 | // Selection is a client-local observation, not input: it claims no | 2091 | const req = proto.decodeSelectionReq(payload) catch return; |
| 2087 | // size and repaints nobody. Decoded BEFORE the session lookup, because | ||
| 2088 | // a malformed payload carries no id to correlate and only silence is | ||
| 2089 | // honest — while a well-formed one from a session-less slot deserves | ||
| 2090 | // its answer rather than the client's whole timeout. | ||
| 2091 | const req = proto.decodeSelectionReq(frame.payload) catch return; | ||
| 2092 | const si = self.clients[i].?.session orelse { | 2092 | const si = self.clients[i].?.session orelse { |
| 2093 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, ""); | 2093 | self.queueSelectionReply(i, .{ .id = req.id, .gesture = req.gesture, .history_rows = 0 }); |
| 2094 | return; | 2094 | return; |
| 2095 | }; | 2095 | }; |
| 2096 | var result = self.ses(si).eng.extractSelection( | 2096 | const s = self.ses(si); |
| 2097 | self.alloc, | 2097 | if (req.action == .clear) { |
| 2098 | req.anchor.row, | 2098 | if (self.clients[i].?.selection) |selection| { |
| 2099 | req.anchor.col, | 2099 | if (selection.id == req.gesture) self.clients[i].?.clearSelection(); |
| 2100 | req.active.row, | 2100 | } |
| 2101 | req.active.col, | ||
| 2102 | proto.selection_text_max, | ||
| 2103 | ) catch { | ||
| 2104 | // No extraction happened, so there is no screen whose | ||
| 2105 | // history this could honestly quote. Zero is the | ||
| 2106 | // watermark that claims nothing, and a non-ok status is | ||
| 2107 | // never compared against one anyway. | ||
| 2108 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), 0, ""); | ||
| 2109 | return; | 2101 | return; |
| 2110 | }; | 2102 | } |
| 2111 | defer result.deinit(self.alloc); | 2103 | var state = self.selectionState(si, i); |
| 2112 | // Status and text are one invariant, not two independent | 2104 | state.id = req.id; |
| 2113 | // fields. In particular, an impossible `.ok` without the | 2105 | state.gesture = req.gesture; |
| 2114 | // owned text must not become a successful empty selection. | 2106 | if (req.action == .start) { |
| 2115 | const base = result.history_rows; | 2107 | const tracked = if (req.gesture != 0 and req.epoch == s.epoch and req.source == s.eng.selectionSource()) |
| 2116 | switch (result.status) { | 2108 | s.eng.trackSelection(req.anchor, req.active) catch null |
| 2117 | .ok => if (result.text) |value| | ||
| 2118 | self.queueSelectionReply(i, req.id, selectionReplyStatus(.ok), base, value) | ||
| 2119 | else | 2109 | else |
| 2120 | self.queueSelectionReply(i, req.id, selectionReplyStatus(null), base, ""), | 2110 | null; |
| 2121 | .invalid => self.queueSelectionReply(i, req.id, selectionReplyStatus(.invalid), base, ""), | 2111 | if (tracked) |selection| { |
| 2122 | .too_large => self.queueSelectionReply(i, req.id, selectionReplyStatus(.too_large), base, ""), | 2112 | self.clients[i].?.clearSelection(); |
| 2113 | self.clients[i].?.selection = .{ .id = req.gesture, .tracked = selection }; | ||
| 2114 | state = self.selectionState(si, i); | ||
| 2115 | state.id = req.id; | ||
| 2116 | } else { | ||
| 2117 | state.status = .unavailable; | ||
| 2118 | self.queueSelectionReply(i, state); | ||
| 2119 | return; | ||
| 2120 | } | ||
| 2121 | } | ||
| 2122 | const result: ?Engine.SelectionExtract = if (req.action == .extract) | ||
| 2123 | s.eng.extractSelection(self.alloc, req.anchor.row, req.anchor.col, req.active.row, req.active.col, proto.selection_text_max) catch null | ||
| 2124 | else if (self.clients[i].?.selection) |*selection| | ||
| 2125 | if (selection.id == req.gesture and req.epoch == s.epoch) | ||
| 2126 | selection.tracked.extract(self.alloc, proto.selection_text_max) catch null | ||
| 2127 | else | ||
| 2128 | null | ||
| 2129 | else | ||
| 2130 | null; | ||
| 2131 | defer if (result) |value| value.deinit(self.alloc); | ||
| 2132 | if (result) |value| { | ||
| 2133 | state.status = selectionReplyStatus(value.status); | ||
| 2134 | state.text = value.text orelse &.{}; | ||
| 2135 | } else state.status = .unavailable; | ||
| 2136 | self.queueSelectionReply(i, state); | ||
| 2137 | } | ||
| 2138 | |||
| 2139 | fn selectionState(self: *Server, si: usize, i: usize) proto.SelectionReply { | ||
| 2140 | const s = self.ses(si); | ||
| 2141 | var state: proto.SelectionReply = .{ | ||
| 2142 | .seq = s.tracker.seq, | ||
| 2143 | .source = s.eng.selectionSource(), | ||
| 2144 | .history_rows = s.eng.historyRows(), | ||
| 2145 | }; | ||
| 2146 | if (self.clients[i].?.selection) |*selection| { | ||
| 2147 | state.gesture = selection.id; | ||
| 2148 | if (selection.tracked.points()) |points| { | ||
| 2149 | state.status = .ok; | ||
| 2150 | state.anchor = points.anchor; | ||
| 2151 | state.active = points.active; | ||
| 2152 | } else self.clients[i].?.clearSelection(); | ||
| 2123 | } | 2153 | } |
| 2154 | return state; | ||
| 2155 | } | ||
| 2156 | |||
| 2157 | fn sendSelectionState(self: *Server, si: usize, i: usize) void { | ||
| 2158 | if (!self.inSession(i, si)) return; | ||
| 2159 | self.queueSelectionReply(i, self.selectionState(si, i)); | ||
| 2124 | } | 2160 | } |
| 2125 | 2161 | ||
| 2126 | fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void { | 2162 | fn onStatusReq(self: *Server, i: usize, frame: proto.Frame) void { |
| @@ -2573,6 +2609,7 @@ pub const Server = struct { | |||
| 2573 | for (0..max_clients) |i| { | 2609 | for (0..max_clients) |i| { |
| 2574 | if (if (to) |only| i != only else !self.inSession(i, si)) continue; | 2610 | if (if (to) |only| i != only else !self.inSession(i, si)) continue; |
| 2575 | if (!self.queueFrame(i, t, payload)) continue; | 2611 | if (!self.queueFrame(i, t, payload)) continue; |
| 2612 | self.sendSelectionState(si, i); | ||
| 2576 | sent = true; | 2613 | sent = true; |
| 2577 | if (t == .delta) { | 2614 | if (t == .delta) { |
| 2578 | self.stats.deltas += 1; | 2615 | self.stats.deltas += 1; |
| @@ -2612,7 +2649,7 @@ pub const Server = struct { | |||
| 2612 | } | 2649 | } |
| 2613 | const upd = s.tracker.update(self.alloc, s.eng) catch return; | 2650 | const upd = s.tracker.update(self.alloc, s.eng) catch return; |
| 2614 | switch (upd) { | 2651 | switch (upd) { |
| 2615 | .none => {}, | 2652 | .none => for (0..max_clients) |i| self.sendSelectionState(si, i), |
| 2616 | .discontinuity => self.resyncSnapshot(si), | 2653 | .discontinuity => self.resyncSnapshot(si), |
| 2617 | .advanced => { | 2654 | .advanced => { |
| 2618 | // Losing the payload would strand the clients a seq behind | 2655 | // Losing the payload would strand the clients a seq behind |
| @@ -2866,6 +2903,9 @@ pub const Server = struct { | |||
| 2866 | /// unlike sendResync: every attached client was told the mode on attach | 2903 | /// unlike sendResync: every attached client was told the mode on attach |
| 2867 | /// and at each change; a resync that skips it breaks that, silently. | 2904 | /// and at each change; a resync that skips it breaks that, silently. |
| 2868 | pub fn resyncSnapshot(self: *Server, si: usize) void { | 2905 | pub fn resyncSnapshot(self: *Server, si: usize) void { |
| 2906 | for (0..max_clients) |i| { | ||
| 2907 | if (self.inSession(i, si)) self.clients[i].?.clearSelection(); | ||
| 2908 | } | ||
| 2869 | if (!self.rebuildTracker(si)) return; | 2909 | if (!self.rebuildTracker(si)) return; |
| 2870 | if (!self.hasClientsIn(si)) return; | 2910 | if (!self.hasClientsIn(si)) return; |
| 2871 | const payload = self.buildSnapshotPayload(si) catch return; | 2911 | const payload = self.buildSnapshotPayload(si) catch return; |
src/server/server_test_attach.zig
| Old | New | ||
|---|---|---|---|
| @@ -610,6 +610,7 @@ test "Server: scrollback fetch is per-client and independent" { | |||
| 610 | if (frame.type != .selection_reply) return; | 610 | if (frame.type != .selection_reply) return; |
| 611 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | 611 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); |
| 612 | const reply = try proto.decodeSelectionReply(frame.payload); | 612 | const reply = try proto.decodeSelectionReply(frame.payload); |
| 613 | if (reply.id == 0) return; | ||
| 613 | switch (self.n) { | 614 | switch (self.n) { |
| 614 | 0 => { | 615 | 0 => { |
| 615 | try std.testing.expectEqual(@as(u32, 77), reply.id); | 616 | try std.testing.expectEqual(@as(u32, 77), reply.id); |
| @@ -656,7 +657,7 @@ test "Server: scrollback fetch is per-client and independent" { | |||
| 656 | fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void { | 657 | fn on(_: ?*anyopaque, frame: proto.Frame) anyerror!void { |
| 657 | // A fifth reply to four requests would mean the malformed frame | 658 | // A fifth reply to four requests would mean the malformed frame |
| 658 | // generated one of its own. | 659 | // generated one of its own. |
| 659 | if (frame.type == .selection_reply) return error.LateSelectionReply; | 660 | if (frame.type == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id != 0) return error.LateSelectionReply; |
| 660 | } | 661 | } |
| 661 | }; | 662 | }; |
| 662 | var status_reply: ?proto.StatusReply = null; | 663 | var status_reply: ?proto.StatusReply = null; |
src/server/server_test_clipboard.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const awaitFrame = h.awaitFrame; | |||
| 12 | const awaitGridText = h.awaitGridText; | 12 | const awaitGridText = h.awaitGridText; |
| 13 | const connectedPair = h.connectedPair; | 13 | const connectedPair = h.connectedPair; |
| 14 | const writeDyingGapShell = h.writeDyingGapShell; | 14 | const writeDyingGapShell = h.writeDyingGapShell; |
| 15 | const SelectionAction = @FieldType(proto.SelectionReq, "action"); | ||
| 15 | 16 | ||
| 16 | /// Returns the parts of the reply that outlive the frame's payload — which | 17 | /// Returns the parts of the reply that outlive the frame's payload — which |
| 17 | /// is the whole reason this is not `awaitFrame` spelled at each call site: | 18 | /// is the whole reason this is not `awaitFrame` spelled at each call site: |
| @@ -21,10 +22,223 @@ fn awaitSelectionReply( | |||
| 21 | srv: *Server, | 22 | srv: *Server, |
| 22 | peer: std.posix.fd_t, | 23 | peer: std.posix.fd_t, |
| 23 | ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { | 24 | ) !?struct { id: u32, status: proto.SelectionStatus, text_len: usize } { |
| 24 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; | 25 | // Every grid update also carries an id-zero source/position state. A |
| 25 | defer frame.deinit(alloc); | 26 | // direct extraction is correlated by its nonzero id, as ClientCore is. |
| 26 | const reply = try proto.decodeSelectionReply(frame.payload); | 27 | for (0..16) |_| { |
| 27 | return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len }; | 28 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; |
| 29 | defer frame.deinit(alloc); | ||
| 30 | const reply = try proto.decodeSelectionReply(frame.payload); | ||
| 31 | if (reply.id == 0) continue; | ||
| 32 | return .{ .id = reply.id, .status = reply.status, .text_len = reply.text.len }; | ||
| 33 | } | ||
| 34 | return null; | ||
| 35 | } | ||
| 36 | |||
| 37 | const TrackedReply = struct { | ||
| 38 | id: u32, | ||
| 39 | gesture: u32, | ||
| 40 | seq: u64, | ||
| 41 | source: u64, | ||
| 42 | status: proto.SelectionStatus, | ||
| 43 | anchor: proto.SelectionPoint, | ||
| 44 | active: proto.SelectionPoint, | ||
| 45 | text: [128]u8 = undefined, | ||
| 46 | text_len: usize = 0, | ||
| 47 | |||
| 48 | fn textValue(self: *const TrackedReply) []const u8 { | ||
| 49 | return self.text[0..self.text_len]; | ||
| 50 | } | ||
| 51 | }; | ||
| 52 | |||
| 53 | /// Position updates have id zero; a copy/start reply carries its request id. | ||
| 54 | /// Skip the other client-local updates so tests never depend on send order. | ||
| 55 | fn awaitTracked( | ||
| 56 | alloc: std.mem.Allocator, | ||
| 57 | srv: *Server, | ||
| 58 | peer: std.posix.fd_t, | ||
| 59 | gesture: u32, | ||
| 60 | id: u32, | ||
| 61 | ) !?TrackedReply { | ||
| 62 | for (0..16) |_| { | ||
| 63 | const frame = (try awaitFrame(alloc, srv, peer, .selection_reply, 400)) orelse return null; | ||
| 64 | defer frame.deinit(alloc); | ||
| 65 | const reply = try proto.decodeSelectionReply(frame.payload); | ||
| 66 | if (reply.gesture != gesture or reply.id != id) continue; | ||
| 67 | if (reply.text.len > 128) return error.TrackedTextTooLong; | ||
| 68 | var result: TrackedReply = .{ | ||
| 69 | .id = reply.id, | ||
| 70 | .gesture = reply.gesture, | ||
| 71 | .seq = reply.seq, | ||
| 72 | .source = reply.source, | ||
| 73 | .status = reply.status, | ||
| 74 | .anchor = reply.anchor, | ||
| 75 | .active = reply.active, | ||
| 76 | }; | ||
| 77 | @memcpy(result.text[0..reply.text.len], reply.text); | ||
| 78 | result.text_len = reply.text.len; | ||
| 79 | return result; | ||
| 80 | } | ||
| 81 | return null; | ||
| 82 | } | ||
| 83 | |||
| 84 | fn writeTracked( | ||
| 85 | peer: std.posix.fd_t, | ||
| 86 | action: SelectionAction, | ||
| 87 | id: u32, | ||
| 88 | gesture: u32, | ||
| 89 | epoch: u64, | ||
| 90 | source: u64, | ||
| 91 | anchor: proto.SelectionPoint, | ||
| 92 | active: proto.SelectionPoint, | ||
| 93 | ) !void { | ||
| 94 | const bytes = proto.encodeSelectionReq(.{ | ||
| 95 | .action = action, | ||
| 96 | .id = id, | ||
| 97 | .gesture = gesture, | ||
| 98 | .epoch = epoch, | ||
| 99 | .source = source, | ||
| 100 | .anchor = anchor, | ||
| 101 | .active = active, | ||
| 102 | }); | ||
| 103 | try proto.writeFrame(peer, .selection_req, &bytes); | ||
| 104 | } | ||
| 105 | |||
| 106 | fn attachTracked(alloc: std.mem.Allocator, td: *h.TestDaemon) !std.net.Stream { | ||
| 107 | const c = try dial.dialAttach(td.sock_path, 16, 9); | ||
| 108 | errdefer c.close(); | ||
| 109 | (try awaitFrame(alloc, &td.srv, c.handle, .snapshot, 400) orelse return error.NoTrackedSnapshot).deinit(alloc); | ||
| 110 | const state_frame = (try awaitFrame(alloc, &td.srv, c.handle, .selection_reply, 400)) orelse return error.NoTrackedSource; | ||
| 111 | defer state_frame.deinit(alloc); | ||
| 112 | const state = try proto.decodeSelectionReply(state_frame.payload); | ||
| 113 | if (state.id != 0 or state.gesture != 0) return error.BadTrackedSource; | ||
| 114 | return c; | ||
| 115 | } | ||
| 116 | |||
| 117 | test "Server: tracked selection rejects a stale exact source even when the grid did not change" { | ||
| 118 | const alloc = std.testing.allocator; | ||
| 119 | var td = try h.TestDaemon.init(alloc, "follow-stale", .{ .shell = "/bin/cat" }); | ||
| 120 | defer td.deinit(); | ||
| 121 | const c = try attachTracked(alloc, &td); | ||
| 122 | defer c.close(); | ||
| 123 | |||
| 124 | const session = td.srv.sessions.table[0].?; | ||
| 125 | const source = session.eng.selectionSource(); | ||
| 126 | // Scroll a blank screen: row identities move but the rendered grid is | ||
| 127 | // unchanged. The source guard must still reject a delayed coordinate. | ||
| 128 | session.eng.feed("\x1b[1S"); | ||
| 129 | td.srv.sendUpdate(0); | ||
| 130 | try writeTracked(c.handle, .start, 1, 41, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 131 | const reply = (try awaitTracked(alloc, &td.srv, c.handle, 41, 1)) orelse return error.NoStaleTrackedReply; | ||
| 132 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, reply.status); | ||
| 133 | try std.testing.expectEqual(@as(usize, 0), reply.text_len); | ||
| 134 | } | ||
| 135 | |||
| 136 | test "Server: a stale start does not retire the client's existing tracked gesture" { | ||
| 137 | const alloc = std.testing.allocator; | ||
| 138 | var td = try h.TestDaemon.init(alloc, "tracked-stale-replace", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 139 | defer td.deinit(); | ||
| 140 | const c = try attachTracked(alloc, &td); | ||
| 141 | defer c.close(); | ||
| 142 | const session = td.srv.sessions.table[0].?; | ||
| 143 | |||
| 144 | session.eng.feed("KEEP"); | ||
| 145 | td.srv.sendUpdate(0); | ||
| 146 | const source = session.eng.selectionSource(); | ||
| 147 | try writeTracked(c.handle, .start, 1, 71, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 3 }); | ||
| 148 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 71, 1)) orelse return error.NoInitialTrackedStart; | ||
| 149 | |||
| 150 | // This changes the coordinate source after gesture 71 is owned. A late | ||
| 151 | // gesture 72 must be refused without clearing the still-live pins. | ||
| 152 | session.eng.feed("\x1b[1S"); | ||
| 153 | td.srv.sendUpdate(0); | ||
| 154 | try writeTracked(c.handle, .start, 2, 72, session.epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 155 | const stale = (try awaitTracked(alloc, &td.srv, c.handle, 72, 2)) orelse return error.NoStaleReplacementReply; | ||
| 156 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, stale.status); | ||
| 157 | |||
| 158 | try writeTracked(c.handle, .copy, 3, 71, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 159 | const kept = (try awaitTracked(alloc, &td.srv, c.handle, 71, 3)) orelse return error.NoKeptTrackedCopy; | ||
| 160 | try std.testing.expectEqual(proto.SelectionStatus.ok, kept.status); | ||
| 161 | try std.testing.expectEqualStrings("KEEP", kept.textValue()); | ||
| 162 | } | ||
| 163 | |||
| 164 | test "Server: tracked selection tracks duplicate occurrences per client and clear ids cannot cross" { | ||
| 165 | const alloc = std.testing.allocator; | ||
| 166 | var td = try h.TestDaemon.init(alloc, "follow-clients", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 167 | defer td.deinit(); | ||
| 168 | const a = try attachTracked(alloc, &td); | ||
| 169 | defer a.close(); | ||
| 170 | const b = try attachTracked(alloc, &td); | ||
| 171 | defer b.close(); | ||
| 172 | const session = td.srv.sessions.table[0].?; | ||
| 173 | |||
| 174 | session.eng.feed("DUPLICATE\r\nBEFORE\r\nDUPLICATE\r\nAFTER"); | ||
| 175 | td.srv.sendUpdate(0); | ||
| 176 | const epoch = session.epoch; | ||
| 177 | const source = session.eng.selectionSource(); | ||
| 178 | try writeTracked(a.handle, .start, 1, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 8 }); | ||
| 179 | try writeTracked(b.handle, .start, 1, 202, epoch, source, .{ .row = 2, .col = 0 }, .{ .row = 2, .col = 8 }); | ||
| 180 | const started_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 1)) orelse return error.NoTrackedA; | ||
| 181 | const started_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 1)) orelse return error.NoTrackedB; | ||
| 182 | try std.testing.expectEqualStrings("DUPLICATE", started_a.textValue()); | ||
| 183 | try std.testing.expectEqualStrings("DUPLICATE", started_b.textValue()); | ||
| 184 | try std.testing.expect(started_a.anchor.row != started_b.anchor.row); | ||
| 185 | |||
| 186 | // Start at the bottom and append enough to shift both matching strings; | ||
| 187 | // no text comparison can identify which one each client intended. | ||
| 188 | session.eng.feed("\r\nTAIL-1\r\nTAIL-2\r\nTAIL-3\r\nTAIL-4\r\nTAIL-5\r\nTAIL-6"); | ||
| 189 | td.srv.sendUpdate(0); | ||
| 190 | const moved_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 0)) orelse return error.NoMovedTrackedA; | ||
| 191 | const moved_b = (try awaitTracked(alloc, &td.srv, b.handle, 202, 0)) orelse return error.NoMovedTrackedB; | ||
| 192 | try std.testing.expect(moved_a.anchor.row != moved_b.anchor.row); | ||
| 193 | |||
| 194 | // A foreign clear cannot destroy A's pins. Its following copy must still | ||
| 195 | // name A's original duplicate, not B's or a current coordinate. | ||
| 196 | try writeTracked(a.handle, .clear, 0, 999, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 197 | try writeTracked(a.handle, .copy, 2, 101, epoch, source, .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 0 }); | ||
| 198 | const copied_a = (try awaitTracked(alloc, &td.srv, a.handle, 101, 2)) orelse return error.NoTrackedCopy; | ||
| 199 | try std.testing.expectEqual(proto.SelectionStatus.ok, copied_a.status); | ||
| 200 | try std.testing.expectEqualStrings("DUPLICATE", copied_a.textValue()); | ||
| 201 | } | ||
| 202 | |||
| 203 | test "Server: tracked selection is retired by resync, resize and detach" { | ||
| 204 | const alloc = std.testing.allocator; | ||
| 205 | var td = try h.TestDaemon.init(alloc, "follow-retire", .{ .shell = "/bin/cat", .cols = 16, .rows = 9 }); | ||
| 206 | defer td.deinit(); | ||
| 207 | var c = try attachTracked(alloc, &td); | ||
| 208 | var c_open = true; | ||
| 209 | defer if (c_open) c.close(); | ||
| 210 | const session = td.srv.sessions.table[0].?; | ||
| 211 | session.eng.feed("tracked"); | ||
| 212 | td.srv.sendUpdate(0); | ||
| 213 | try writeTracked(c.handle, .start, 1, 77, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 }); | ||
| 214 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 77, 1)) orelse return error.NoTrackedFollow; | ||
| 215 | |||
| 216 | td.srv.resyncSnapshot(0); | ||
| 217 | const resynced = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResyncFollow; | ||
| 218 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, resynced.status); | ||
| 219 | |||
| 220 | // Re-arm before a real client resize; its snapshot and following state | ||
| 221 | // must likewise retire the pins rather than projecting them through reflow. | ||
| 222 | try writeTracked(c.handle, .start, 2, 78, session.epoch, session.eng.selectionSource(), .{ .row = 0, .col = 0 }, .{ .row = 0, .col = 6 }); | ||
| 223 | _ = (try awaitTracked(alloc, &td.srv, c.handle, 78, 2)) orelse return error.NoResizeFollowStart; | ||
| 224 | const size = proto.encodeSize(17, 9); | ||
| 225 | try proto.writeFrame(c.handle, .resize, &size); | ||
| 226 | const resized = (try awaitTracked(alloc, &td.srv, c.handle, 0, 0)) orelse return error.NoResizeFollow; | ||
| 227 | try std.testing.expectEqual(proto.SelectionStatus.unavailable, resized.status); | ||
| 228 | |||
| 229 | // Socket closure takes the sole teardown path. The testing allocator at | ||
| 230 | // TestDaemon.deinit then proves its tracked pins were unregistered. | ||
| 231 | c.close(); | ||
| 232 | c_open = false; | ||
| 233 | var detached = false; | ||
| 234 | for (0..80) |_| { | ||
| 235 | try td.srv.pumpOnce(5); | ||
| 236 | if (!td.srv.hasClientsIn(0)) { | ||
| 237 | detached = true; | ||
| 238 | break; | ||
| 239 | } | ||
| 240 | } | ||
| 241 | try std.testing.expect(detached); | ||
| 28 | } | 242 | } |
| 29 | 243 | ||
| 30 | test "Server: a session-less slot still answers a well-formed selection request" { | 244 | test "Server: a session-less slot still answers a well-formed selection request" { |
| @@ -79,7 +293,7 @@ test "Server: an unencodable selection result still answers with unavailable" { | |||
| 79 | }); | 293 | }); |
| 80 | const real = td.srv.alloc; | 294 | const real = td.srv.alloc; |
| 81 | td.srv.alloc = failing.allocator(); | 295 | td.srv.alloc = failing.allocator(); |
| 82 | td.srv.queueSelectionReply(0, 99, .ok, 7, text); | 296 | td.srv.queueSelectionReply(0, .{ .id = 99, .status = .ok, .history_rows = 7, .text = text }); |
| 83 | td.srv.alloc = real; | 297 | td.srv.alloc = real; |
| 84 | try std.testing.expect(failing.has_induced_failure); | 298 | try std.testing.expect(failing.has_induced_failure); |
| 85 | 299 | ||
src/server/server_test_deliver.zig
| Old | New | ||
|---|---|---|---|
| @@ -61,8 +61,8 @@ test "Server: a stalled client does not block delivery to others" { | |||
| 61 | try std.testing.expect(td.srv.clients[1] != null); | 61 | try std.testing.expect(td.srv.clients[1] != null); |
| 62 | try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len); | 62 | try std.testing.expectEqual(@as(usize, 0), td.srv.clients[1].?.pending.items.len); |
| 63 | 63 | ||
| 64 | // ...and what it received is whole parseable frames, one snapshot per | 64 | // ...and what it received is whole parseable snapshot/state pairs, one |
| 65 | // round, not a stream truncated or interleaved by A's backpressure. | 65 | // per round, not a stream truncated or interleaved by A's backpressure. |
| 66 | var frames: usize = 0; | 66 | var frames: usize = 0; |
| 67 | while (frames < rounds) : (frames += 1) { | 67 | while (frames < rounds) : (frames += 1) { |
| 68 | const f = (try proto.readFrame(alloc, b.peer)) orelse break; | 68 | const f = (try proto.readFrame(alloc, b.peer)) orelse break; |
| @@ -70,6 +70,12 @@ test "Server: a stalled client does not block delivery to others" { | |||
| 70 | try std.testing.expectEqual(proto.MsgType.snapshot, f.type); | 70 | try std.testing.expectEqual(proto.MsgType.snapshot, f.type); |
| 71 | const p = try proto.readSnapshotPrefix(f.payload); | 71 | const p = try proto.readSnapshotPrefix(f.payload); |
| 72 | try std.testing.expectEqual(@as(u16, 80), p.cols); | 72 | try std.testing.expectEqual(@as(u16, 80), p.cols); |
| 73 | const state = (try proto.readFrame(alloc, b.peer)) orelse return error.NoSnapshotSource; | ||
| 74 | defer state.deinit(alloc); | ||
| 75 | try std.testing.expectEqual(proto.MsgType.selection_reply, state.type); | ||
| 76 | const reply = try proto.decodeSelectionReply(state.payload); | ||
| 77 | try std.testing.expectEqual(@as(u32, 0), reply.id); | ||
| 78 | try std.testing.expectEqual(@as(u32, 0), reply.gesture); | ||
| 73 | } | 79 | } |
| 74 | try std.testing.expectEqual(rounds, frames); | 80 | try std.testing.expectEqual(rounds, frames); |
| 75 | } | 81 | } |
src/server/server_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -526,7 +526,12 @@ pub const ReplicaFeed = struct { | |||
| 526 | pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { | 526 | pub fn on(ctx: ?*anyopaque, frame: proto.Frame) anyerror!void { |
| 527 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); | 527 | const self: *@This() = @ptrCast(@alignCast(ctx.?)); |
| 528 | for (self.w.reject) |bad| { | 528 | for (self.w.reject) |bad| { |
| 529 | if (frame.type == bad) return error.RejectedFrameArrived; | 529 | if (frame.type == bad) { |
| 530 | // Unsolicited position metadata accompanies every grid. Only | ||
| 531 | // a correlated copy reply would leak another client's read. | ||
| 532 | if (bad == .selection_reply and (try proto.decodeSelectionReply(frame.payload)).id == 0) continue; | ||
| 533 | return error.RejectedFrameArrived; | ||
| 534 | } | ||
| 530 | } | 535 | } |
| 531 | // applyFrame ignores everything that is not state, so the stream's | 536 | // applyFrame ignores everything that is not state, so the stream's |
| 532 | // replies and marks pass through untouched. | 537 | // replies and marks pass through untouched. |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -4577,6 +4577,7 @@ fn replyBytes( | |||
| 4577 | std.mem.writeInt(u32, buf[0..4], id, .little); | 4577 | std.mem.writeInt(u32, buf[0..4], id, .little); |
| 4578 | buf[4] = @intFromEnum(status); | 4578 | buf[4] = @intFromEnum(status); |
| 4579 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); | 4579 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); |
| 4580 | @memset(buf[9..proto.selection_reply_prefix_len], 0); | ||
| 4580 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); | 4581 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); |
| 4581 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; | 4582 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; |
| 4582 | } | 4583 | } |
src/tui/wall_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -281,6 +281,7 @@ pub fn replyBytes( | |||
| 281 | std.mem.writeInt(u32, buf[0..4], id, .little); | 281 | std.mem.writeInt(u32, buf[0..4], id, .little); |
| 282 | buf[4] = @intFromEnum(status); | 282 | buf[4] = @intFromEnum(status); |
| 283 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); | 283 | std.mem.writeInt(u32, buf[5..9], history_rows, .little); |
| 284 | @memset(buf[9..proto.selection_reply_prefix_len], 0); | ||
| 284 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); | 285 | @memcpy(buf[proto.selection_reply_prefix_len..][0..text.len], text); |
| 285 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; | 286 | return buf[0 .. proto.selection_reply_prefix_len + text.len]; |
| 286 | } | 287 | } |
test/native_selection.py
| Old | New | ||
|---|---|---|---|
| @@ -245,6 +245,15 @@ def active_output(rig, pane): | |||
| 245 | 'active counter cleared the held selection') | 245 | 'active counter cleared the held selection') |
| 246 | rig.key('copy') | 246 | rig.key('copy') |
| 247 | rig.copied('alpha') | 247 | rig.copied('alpha') |
| 248 | # A second copy during the same held gesture must use the newly | ||
| 249 | # extended range. Release then registers its final, shorter range. | ||
| 250 | rig.send('mousemove:' + rig.cell_point(state, pane, 6, 1)) | ||
| 251 | # Wayland motion and the FIFO keyboard hook arrive independently. | ||
| 252 | # Observe the extended highlight before asking the other channel to copy. | ||
| 253 | eventually(lambda: cell_background(rig, rig.state(), pane, 6, 1) != background, | ||
| 254 | 'held drag did not visibly extend before copying') | ||
| 255 | rig.key('copy') | ||
| 256 | rig.copied('alpha c') | ||
| 248 | rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) | 257 | rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) |
| 249 | rig.copied('alpha') | 258 | rig.copied('alpha') |
| 250 | initial = count(rig.state()) | 259 | initial = count(rig.state()) |
test/native_selection_follow.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,239 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Real-PTY acceptance for completed local selections that follow terminal edits. | ||
| 3 | |||
| 4 | The source panes deliberately contain duplicate text. The surrounding numbered | ||
| 5 | rows and framebuffer location make a copied duplicate insufficient evidence: | ||
| 6 | the selected *occurrence* must move with the terminal's own tracked pins. | ||
| 7 | """ | ||
| 8 | import shlex | ||
| 9 | import os | ||
| 10 | import sys | ||
| 11 | import time | ||
| 12 | |||
| 13 | sys.dont_write_bytecode = True | ||
| 14 | from native_lifecycle import start_persistent | ||
| 15 | from native_resize import by_id | ||
| 16 | from native_selection import SelectionRig, cell_background | ||
| 17 | from native_tiling import eventually, require | ||
| 18 | |||
| 19 | |||
| 20 | SHIFT = 1 | ||
| 21 | |||
| 22 | |||
| 23 | def pty_output(rig, pane, data): | ||
| 24 | """Write fixture-owned terminal output without changing GUI focus.""" | ||
| 25 | fd = os.open(rig.tty_paths[pane], os.O_WRONLY | os.O_NOCTTY) | ||
| 26 | try: | ||
| 27 | os.write(fd, data) | ||
| 28 | finally: | ||
| 29 | os.close(fd) | ||
| 30 | |||
| 31 | |||
| 32 | def row_of(state, pane, prefix): | ||
| 33 | for row, line in enumerate(by_id(state)[pane]['painted_text'].splitlines()): | ||
| 34 | if line.startswith(prefix): | ||
| 35 | return row | ||
| 36 | return None | ||
| 37 | |||
| 38 | |||
| 39 | def mouse_cell(rig, state, pane, col, row, kind, mods=0): | ||
| 40 | point = rig.cell_point(state, pane, col, row) | ||
| 41 | x, y = point.split(',') | ||
| 42 | rig.send(f'mouse:{kind},{x},{y},0,{mods}') | ||
| 43 | |||
| 44 | |||
| 45 | def local_shift_select(rig, pane, start, finish): | ||
| 46 | """Use the ordinary SDL mouse hook: an app may be reporting mouse input.""" | ||
| 47 | state = rig.state() | ||
| 48 | mouse_cell(rig, state, pane, *start, 'down', SHIFT) | ||
| 49 | mouse_cell(rig, state, pane, *finish, 'move', SHIFT) | ||
| 50 | mouse_cell(rig, state, pane, *finish, 'up', SHIFT) | ||
| 51 | return state | ||
| 52 | |||
| 53 | |||
| 54 | def shell_follow_program(rig, pane): | ||
| 55 | trigger = rig.root / 'shell-follow-go' | ||
| 56 | source = rig.root / 'shell-follow.py' | ||
| 57 | rows = by_id(rig.state())[pane]['rows'] | ||
| 58 | require(rows >= 9, 'shell follow fixture needs nine terminal rows') | ||
| 59 | source.write_text( | ||
| 60 | 'import os, time\nfrom pathlib import Path\n' | ||
| 61 | f'trigger=Path({str(trigger)!r})\n' | ||
| 62 | f'rows={rows}\n' | ||
| 63 | 'body=["SHELL-%03d" % n for n in range(rows)]\n' | ||
| 64 | 'body[rows-8]="SHELL-DUPLICATE"; body[rows-7]="SHELL-BEFORE-003"\n' | ||
| 65 | 'body[rows-5]="SHELL-DUPLICATE"; body[rows-4]="SHELL-AFTER-006"; body[-1]="SHELL-READY"\n' | ||
| 66 | 'os.write(1, ("\\033[0m\\033[2J\\033[H"+"\\r\\n".join(body)).encode())\n' | ||
| 67 | 'while not trigger.exists(): time.sleep(.01)\n' | ||
| 68 | 'for n in range(3):\n' | ||
| 69 | ' os.write(1, ("\\r\\nSHELL-TAIL-%03d" % n).encode()); time.sleep(.03)\n' | ||
| 70 | 'while True: time.sleep(60)\n') | ||
| 71 | rig.focus(pane) | ||
| 72 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 73 | state = rig.wait_state(lambda s: row_of(s, pane, 'SHELL-READY') is not None) | ||
| 74 | return trigger, state | ||
| 75 | |||
| 76 | |||
| 77 | def shell_primary_scroll(rig, target, neighbour): | ||
| 78 | trigger, state = shell_follow_program(rig, target) | ||
| 79 | after = row_of(state, target, 'SHELL-AFTER-006') | ||
| 80 | start = None if after is None else after - 1 | ||
| 81 | require(start is not None and start + 1 < by_id(state)[target]['rows'], | ||
| 82 | 'shell fixture did not leave a two-row selection in the viewport') | ||
| 83 | background = cell_background(rig, state, target, 2, start) | ||
| 84 | # Select a duplicate plus its distinct successor. Matching the word alone | ||
| 85 | # would let a stale range select a different identical line. | ||
| 86 | local_shift_select(rig, target, (0, start), (16, start + 1)) | ||
| 87 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 88 | eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background, | ||
| 89 | 'completed shell selection was not visibly painted') | ||
| 90 | |||
| 91 | # Unselected output must not move or invalidate this pane's occurrence. | ||
| 92 | pty_output(rig, neighbour, b'\033[0mNEIGHBOUR-OUTPUT\r\n') | ||
| 93 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) | ||
| 94 | rig.key('copy') | ||
| 95 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 96 | |||
| 97 | trigger.touch() | ||
| 98 | moved = rig.wait_state(lambda s: row_of(s, target, 'SHELL-TAIL-002') is not None) | ||
| 99 | after = row_of(moved, target, 'SHELL-AFTER-006') | ||
| 100 | new_row = None if after is None else after - 1 | ||
| 101 | require(new_row == start - 3, | ||
| 102 | f'shell occurrence moved to row {new_row}, expected {start - 3}') | ||
| 103 | eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background, | ||
| 104 | 'shell highlight did not follow its selected occurrence upward') | ||
| 105 | require(cell_background(rig, rig.state(), target, 2, start) == background, | ||
| 106 | 'shell highlight remained at the old viewport row') | ||
| 107 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 108 | rig.copied('COPY-SENTINEL') | ||
| 109 | rig.key('copy') | ||
| 110 | rig.copied('SHELL-DUPLICATE\nSHELL-AFTER-006') | ||
| 111 | rig.ok('completed local selection follows primary-screen append through duplicate rows; neighbouring output is independent') | ||
| 112 | # Return the target pane to its shell before the alternate-screen fixture | ||
| 113 | # starts; otherwise its launch command would be consumed by the old app. | ||
| 114 | rig.key('interrupt') | ||
| 115 | time.sleep(.12) | ||
| 116 | |||
| 117 | |||
| 118 | def alt_reporter(rig, pane): | ||
| 119 | control = rig.root / 'alt-follow-control' | ||
| 120 | received = rig.root / 'alt-follow-received.bin' | ||
| 121 | source = rig.root / 'alt-follow.py' | ||
| 122 | rows = by_id(rig.state())[pane]['rows'] | ||
| 123 | require(rows >= 9, 'alt follow fixture needs nine terminal rows') | ||
| 124 | source.write_text( | ||
| 125 | 'import os, select, sys, termios, tty, time\nfrom pathlib import Path\n' | ||
| 126 | f'control=Path({str(control)!r}); received=Path({str(received)!r})\n' | ||
| 127 | 'fd=0; old=termios.tcgetattr(fd); tty.setraw(fd); seen=0\n' | ||
| 128 | f'rows={rows}\n' | ||
| 129 | 'body=["ALT-%03d" % n for n in range(rows)]\n' | ||
| 130 | 'body[rows-8]="ALT-DUPLICATE"; body[rows-7]="ALT-BEFORE-003"\n' | ||
| 131 | 'body[rows-5]="ALT-DUPLICATE"; body[rows-4]="ALT-AFTER-006"; body[-1]="ALT-READY"\n' | ||
| 132 | 'try:\n' | ||
| 133 | ' os.write(1, ("\\033[?1049h\\033[?25l\\033[2J\\033[H"+"\\r\\n".join(body)+"\\033[?1002h\\033[?1006h").encode())\n' | ||
| 134 | ' while True:\n' | ||
| 135 | ' if control.exists():\n' | ||
| 136 | ' data=control.read_text()[seen:]; seen += len(data)\n' | ||
| 137 | ' for command in data.splitlines():\n' | ||
| 138 | ' if command == "scroll": os.write(1, ("\\033[2;%dr\\033[2S\\033[r" % rows).encode())\n' | ||
| 139 | ' if command == "reset": os.write(1, b"\\033c")\n' | ||
| 140 | ' if select.select([fd], [], [], .02)[0]:\n' | ||
| 141 | ' with received.open("ab") as out: out.write(os.read(fd, 4096))\n' | ||
| 142 | 'finally: termios.tcsetattr(fd, termios.TCSADRAIN, old)\n') | ||
| 143 | rig.focus(pane) | ||
| 144 | rig.shell('python3 ' + shlex.quote(str(source))) | ||
| 145 | state = rig.wait_state(lambda s: row_of(s, pane, 'ALT-READY') is not None) | ||
| 146 | return control, received, state | ||
| 147 | |||
| 148 | |||
| 149 | def alt_region_scroll(rig, target, neighbour): | ||
| 150 | control, received, state = alt_reporter(rig, target) | ||
| 151 | after = row_of(state, target, 'ALT-AFTER-006') | ||
| 152 | start = None if after is None else after - 1 | ||
| 153 | require(start is not None and start + 1 < by_id(state)[target]['rows'], 'alt fixture rows missing') | ||
| 154 | background = cell_background(rig, state, target, 2, start) | ||
| 155 | before_bytes = received.stat().st_size if received.exists() else 0 | ||
| 156 | local_shift_select(rig, target, (0, start), (14, start + 1)) | ||
| 157 | rig.copied('ALT-DUPLICATE\nALT-AFTER-006') | ||
| 158 | time.sleep(.12) | ||
| 159 | require((received.stat().st_size if received.exists() else 0) == before_bytes, | ||
| 160 | 'Shift local selection leaked mouse reports into the reporting app') | ||
| 161 | with control.open('a') as out: | ||
| 162 | out.write('scroll\n') | ||
| 163 | moved = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') == start - 1) | ||
| 164 | after = row_of(moved, target, 'ALT-AFTER-006') | ||
| 165 | new_row = None if after is None else after - 1 | ||
| 166 | eventually(lambda: cell_background(rig, rig.state(), target, 2, new_row) != background, | ||
| 167 | 'alt-screen region scroll did not move the highlight') | ||
| 168 | require(cell_background(rig, rig.state(), target, 2, start) == background, | ||
| 169 | 'alt-screen region scroll left highlight at its old row') | ||
| 170 | # Ctrl+Shift+C must ask the daemon for the tracked occurrence after the | ||
| 171 | # scroll, rather than reusing the old client coordinates. | ||
| 172 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 173 | rig.copied('COPY-SENTINEL') | ||
| 174 | rig.key('copy') | ||
| 175 | rig.copied('ALT-DUPLICATE\nALT-AFTER-006') | ||
| 176 | rig.ok('Shift selection in a mouse-reporting alt screen follows vertical-region scrolling and Ctrl+Shift+C copies it') | ||
| 177 | return control | ||
| 178 | |||
| 179 | |||
| 180 | def invalidation(rig, target, control, neighbour): | ||
| 181 | stable = rig.clipboard() | ||
| 182 | require(stable == 'ALT-DUPLICATE\nALT-AFTER-006', 'missing selection-copy baseline') | ||
| 183 | # A real pane geometry change makes terminal screen coordinates ambiguous. | ||
| 184 | state = rig.state() | ||
| 185 | rig.drag('beside', dx=-2 * state['cell_w']) | ||
| 186 | state = rig.wait_state(lambda s: row_of(s, target, 'ALT-AFTER-006') is not None) | ||
| 187 | rig.unchanged(stable) | ||
| 188 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 189 | rig.copied('COPY-SENTINEL') | ||
| 190 | rig.key('copy') | ||
| 191 | rig.unchanged('COPY-SENTINEL') | ||
| 192 | after = row_of(state, target, 'ALT-AFTER-006') | ||
| 193 | start = None if after is None else after - 1 | ||
| 194 | require(start is not None and start + 1 < by_id(state)[target]['rows'], | ||
| 195 | 'resized alt fixture lost its selection markers') | ||
| 196 | background = cell_background(rig, state, target, 2, start) | ||
| 197 | local_shift_select(rig, target, (0, start), (14, start + 1)) | ||
| 198 | rig.copied(stable) | ||
| 199 | eventually(lambda: cell_background(rig, rig.state(), target, 2, start) != background, | ||
| 200 | 'selection could not be recreated after resize') | ||
| 201 | # RIS resets the terminal's pages and makes Ghostty's tracked pins garbage. | ||
| 202 | with control.open('a') as out: | ||
| 203 | out.write('reset\n') | ||
| 204 | rig.wait_state(lambda s: row_of(s, target, 'ALT-DUPLICATE') is None) | ||
| 205 | rig.unchanged(stable) | ||
| 206 | pty_output(rig, neighbour, b'\033]52;c;Q09QWS1TRU5USU5FTA==\a') | ||
| 207 | rig.copied('COPY-SENTINEL') | ||
| 208 | rig.key('copy') | ||
| 209 | rig.unchanged('COPY-SENTINEL') | ||
| 210 | rig.ok('resize and terminal reset invalidate a followed selection without changing the desktop clipboard') | ||
| 211 | |||
| 212 | |||
| 213 | def exercise(rig): | ||
| 214 | refs = start_persistent(rig) | ||
| 215 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | ||
| 216 | panes = list(refs) | ||
| 217 | target, neighbour = panes[1], panes[2] | ||
| 218 | shell_primary_scroll(rig, target, neighbour) | ||
| 219 | control = alt_region_scroll(rig, target, neighbour) | ||
| 220 | invalidation(rig, target, control, neighbour) | ||
| 221 | rig.assert_cli_untouched() | ||
| 222 | |||
| 223 | |||
| 224 | def main(): | ||
| 225 | require(len(sys.argv) == 3, 'usage: native_selection_follow.py MUX MUXG') | ||
| 226 | rig = SelectionRig(*sys.argv[1:]) | ||
| 227 | try: | ||
| 228 | exercise(rig) | ||
| 229 | rig.quit() | ||
| 230 | print('Native selection-follow acceptance passed; artifacts:', rig.root, flush=True) | ||
| 231 | except BaseException: | ||
| 232 | rig.failure_artifacts() | ||
| 233 | raise | ||
| 234 | finally: | ||
| 235 | rig.close() | ||
| 236 | |||
| 237 | |||
| 238 | if __name__ == '__main__': | ||
| 239 | main() | ||
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -1158,7 +1158,7 @@ class Tile { | |||
| 1158 | const n = this.core.mux_selection_request( | 1158 | const n = this.core.mux_selection_request( |
| 1159 | this.nextSelectionId, a.row, a.col, b.row, b.col, | 1159 | this.nextSelectionId, a.row, a.col, b.row, b.col, |
| 1160 | ); | 1160 | ); |
| 1161 | if (n === 16) { | 1161 | if (n > 0) { |
| 1162 | const selection = this.selection; | 1162 | const selection = this.selection; |
| 1163 | // Establish reply authority before send: a host is allowed to deliver | 1163 | // Establish reply authority before send: a host is allowed to deliver |
| 1164 | // a matching semantic reply synchronously from its send hook. | 1164 | // a matching semantic reply synchronously from its send hook. |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -894,7 +894,7 @@ async function verifySelectionShell(shell, html) { | |||
| 894 | const makeTile = () => { | 894 | const makeTile = () => { |
| 895 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); | 895 | const selected = new h.Tile(4, 'selection fixture', h.document.createElement('div'), ''); |
| 896 | const memory = { buffer: new ArrayBuffer(1024) }; | 896 | const memory = { buffer: new ArrayBuffer(1024) }; |
| 897 | let requestResult = 16; | 897 | let requestResult = 37; |
| 898 | let scrollFeedResult = 0; | 898 | let scrollFeedResult = 0; |
| 899 | let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 }; | 899 | let result = { id: 0, status: 3, historyRows: 30, ptr: 96, len: 0 }; |
| 900 | const requestCalls = []; | 900 | const requestCalls = []; |
| @@ -913,7 +913,7 @@ async function verifySelectionShell(shell, html) { | |||
| 913 | mux_input_cap: () => 1024, | 913 | mux_input_cap: () => 1024, |
| 914 | mux_input_ptr: () => 0, | 914 | mux_input_ptr: () => 0, |
| 915 | mux_output_ptr: () => 256, | 915 | mux_output_ptr: () => 256, |
| 916 | mux_output_len: () => requestResult === 16 ? 16 : 0, | 916 | mux_output_len: () => requestResult === 37 ? 37 : 0, |
| 917 | mux_scroll_start: (pages, rows) => 30 - pages * rows, | 917 | mux_scroll_start: (pages, rows) => 30 - pages * rows, |
| 918 | mux_scroll_feed: () => scrollFeedResult, | 918 | mux_scroll_feed: () => scrollFeedResult, |
| 919 | mux_init: () => 0, | 919 | mux_init: () => 0, |
| @@ -927,14 +927,14 @@ async function verifySelectionShell(shell, html) { | |||
| 927 | mux_selection_len: () => result.len, | 927 | mux_selection_len: () => result.len, |
| 928 | mux_selection_request: (id, ar, ac, br, bc) => { | 928 | mux_selection_request: (id, ar, ac, br, bc) => { |
| 929 | requestCalls.push([id >>> 0, ar, ac, br, bc]); | 929 | requestCalls.push([id >>> 0, ar, ac, br, bc]); |
| 930 | if (requestResult !== 16) return requestResult; | 930 | if (requestResult !== 37) return requestResult; |
| 931 | const view = new DataView(memory.buffer, 256, 16); | 931 | const view = new DataView(memory.buffer, 256, 37); |
| 932 | view.setUint32(0, id, true); | 932 | view.setUint32(0, id, true); |
| 933 | view.setUint32(4, ar, true); | 933 | view.setUint32(4, ar, true); |
| 934 | view.setUint16(8, ac, true); | 934 | view.setUint16(8, ac, true); |
| 935 | view.setUint32(10, br, true); | 935 | view.setUint32(10, br, true); |
| 936 | view.setUint16(14, bc, true); | 936 | view.setUint16(14, bc, true); |
| 937 | return 16; | 937 | return 37; |
| 938 | }, | 938 | }, |
| 939 | }; | 939 | }; |
| 940 | selected.zoomed = true; | 940 | selected.zoomed = true; |
| @@ -1258,9 +1258,9 @@ async function verifySelectionShell(shell, html) { | |||
| 1258 | JSON.stringify([[1, 31, 2, 33, 5]]), | 1258 | JSON.stringify([[1, 31, 2, 33, 5]]), |
| 1259 | ); | 1259 | ); |
| 1260 | check( | 1260 | check( |
| 1261 | 'drag release sends the exact 16-byte request', | 1261 | 'drag release sends the exact 37-byte request', |
| 1262 | Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), | 1262 | Buffer.from(forward.sent[0]?.payload ?? []).toString('hex'), |
| 1263 | '010000001f0000000200210000000500', | 1263 | '010000001f0000000200210000000500000000000000000000000000000000000000000000', |
| 1264 | ); | 1264 | ); |
| 1265 | 1265 | ||
| 1266 | const reverse = makeTile(); | 1266 | const reverse = makeTile(); |
| @@ -3139,11 +3139,11 @@ async function main() { | |||
| 3139 | 3139 | ||
| 3140 | const selectionReply = (id, status, text = '', historyRows = 0) => { | 3140 | const selectionReply = (id, status, text = '', historyRows = 0) => { |
| 3141 | const body = Buffer.from(text, 'utf8'); | 3141 | const body = Buffer.from(text, 'utf8'); |
| 3142 | const reply = Buffer.alloc(9 + body.length); | 3142 | const reply = Buffer.alloc(41 + body.length); |
| 3143 | reply.writeUInt32LE(id, 0); | 3143 | reply.writeUInt32LE(id, 0); |
| 3144 | reply[4] = status; | 3144 | reply[4] = status; |
| 3145 | reply.writeUInt32LE(historyRows, 5); | 3145 | reply.writeUInt32LE(historyRows, 5); |
| 3146 | body.copy(reply, 9); | 3146 | body.copy(reply, 41); |
| 3147 | return reply; | 3147 | return reply; |
| 3148 | }; | 3148 | }; |
| 3149 | const selectionText = () => Buffer.from(mem().subarray( | 3149 | const selectionText = () => Buffer.from(mem().subarray( |
| @@ -3151,12 +3151,12 @@ async function main() { | |||
| 3151 | e.mux_selection_ptr() + e.mux_selection_len(), | 3151 | e.mux_selection_ptr() + e.mux_selection_len(), |
| 3152 | )); | 3152 | )); |
| 3153 | const firstSelectionId = 0x78563412; | 3153 | const firstSelectionId = 0x78563412; |
| 3154 | check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 16); | 3154 | check('selection request len', e.mux_selection_request(firstSelectionId, 0x44332211, 0x6655, 0xaa998877, 0xccbb), 37); |
| 3155 | check('selection request output len', e.mux_output_len(), 16); | 3155 | check('selection request output len', e.mux_output_len(), 37); |
| 3156 | check( | 3156 | check( |
| 3157 | 'selection request golden bytes', | 3157 | 'selection request golden bytes', |
| 3158 | outBytes().toString('hex'), | 3158 | outBytes().toString('hex'), |
| 3159 | '12345678112233445566778899aabbcc', | 3159 | '12345678112233445566778899aabbcc000000000000000000000000000000000000000000', |
| 3160 | ); | 3160 | ); |
| 3161 | check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); | 3161 | check('selection invalid anchor col', e.mux_selection_request(30, 1, 65536, 2, 3), -3); |
| 3162 | check('selection invalid anchor col clears output', e.mux_output_len(), 0); | 3162 | check('selection invalid anchor col clears output', e.mux_output_len(), 0); |
| @@ -3169,13 +3169,13 @@ async function main() { | |||
| 3169 | ); | 3169 | ); |
| 3170 | 3170 | ||
| 3171 | const supersededSelectionId = 0x01020304; | 3171 | const supersededSelectionId = 0x01020304; |
| 3172 | check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 16); | 3172 | check('selection superseded request', e.mux_selection_request(supersededSelectionId, 5, 6, 7, 8), 37); |
| 3173 | const latestSelectionId = 0x10203040; | 3173 | const latestSelectionId = 0x10203040; |
| 3174 | check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 16); | 3174 | check('selection latest request replaces pending', e.mux_selection_request(latestSelectionId, 9, 10, 11, 12), 37); |
| 3175 | check( | 3175 | check( |
| 3176 | 'selection request survives enlarged input staging capacity', | 3176 | 'selection request survives enlarged input staging capacity', |
| 3177 | outBytes().toString('hex'), | 3177 | outBytes().toString('hex'), |
| 3178 | '40302010090000000a000b0000000c00', | 3178 | '40302010090000000a000b0000000c00000000000000000000000000000000000000000000', |
| 3179 | ); | 3179 | ); |
| 3180 | check( | 3180 | check( |
| 3181 | 'selection stale reply ignored', | 3181 | 'selection stale reply ignored', |
| @@ -3206,7 +3206,7 @@ async function main() { | |||
| 3206 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); | 3206 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); |
| 3207 | 3207 | ||
| 3208 | const highSelectionId = 0xfedcba98; | 3208 | const highSelectionId = 0xfedcba98; |
| 3209 | check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 16); | 3209 | check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 37); |
| 3210 | check( | 3210 | check( |
| 3211 | 'high-bit selection reply action', | 3211 | 'high-bit selection reply action', |
| 3212 | e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), | 3212 | e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), |
| @@ -3218,7 +3218,7 @@ async function main() { | |||
| 3218 | 3218 | ||
| 3219 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { | 3219 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { |
| 3220 | const id = 100 + status; | 3220 | const id = 100 + status; |
| 3221 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | 3221 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37); |
| 3222 | check( | 3222 | check( |
| 3223 | `selection ${name} action`, | 3223 | `selection ${name} action`, |
| 3224 | e.mux_client_frame(0x90, stage(selectionReply(id, status))), | 3224 | e.mux_client_frame(0x90, stage(selectionReply(id, status))), |
| @@ -3230,7 +3230,7 @@ async function main() { | |||
| 3230 | } | 3230 | } |
| 3231 | 3231 | ||
| 3232 | const populateSelection = (id) => { | 3232 | const populateSelection = (id) => { |
| 3233 | check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | 3233 | check(`selection ${id} request`, e.mux_selection_request(id, 0, 0, 0, 0), 37); |
| 3234 | check( | 3234 | check( |
| 3235 | `selection ${id} reply`, | 3235 | `selection ${id} reply`, |
| 3236 | e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), | 3236 | e.mux_client_frame(0x90, stage(selectionReply(id, 0, 'x'))), |
| @@ -3251,7 +3251,7 @@ async function main() { | |||
| 3251 | check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); | 3251 | check('wide type after selection', e.mux_client_frame(0x100, 0), clientAction.ignored); |
| 3252 | check('wide type clears selection getter', e.mux_selection_len(), 0); | 3252 | check('wide type clears selection getter', e.mux_selection_len(), 0); |
| 3253 | populateSelection(205); | 3253 | populateSelection(205); |
| 3254 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); | 3254 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 37); |
| 3255 | check('valid new request clears selection getter', e.mux_selection_len(), 0); | 3255 | check('valid new request clears selection getter', e.mux_selection_len(), 0); |
| 3256 | 3256 | ||
| 3257 | populateSelection(207); | 3257 | populateSelection(207); |
| @@ -3281,7 +3281,7 @@ async function main() { | |||
| 3281 | check('invalid selection request clears exposed id', e.mux_selection_id(), 0); | 3281 | check('invalid selection request clears exposed id', e.mux_selection_id(), 0); |
| 3282 | check('invalid selection request clears exposed status', e.mux_selection_status(), 3); | 3282 | check('invalid selection request clears exposed status', e.mux_selection_status(), 3); |
| 3283 | check('invalid selection request clears exposed len', e.mux_selection_len(), 0); | 3283 | check('invalid selection request clears exposed len', e.mux_selection_len(), 0); |
| 3284 | check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 16); | 3284 | check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 37); |
| 3285 | check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); | 3285 | check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); |
| 3286 | check( | 3286 | check( |
| 3287 | 'matching reply after invalid request is accepted', | 3287 | 'matching reply after invalid request is accepted', |
| @@ -3456,7 +3456,7 @@ async function main() { | |||
| 3456 | // The staging cap is pinned to the largest selection reply: its nine-byte | 3456 | // The staging cap is pinned to the largest selection reply: its nine-byte |
| 3457 | // correlation/status/history prefix plus the protocol's one-MiB text | 3457 | // correlation/status/history prefix plus the protocol's one-MiB text |
| 3458 | // maximum. | 3458 | // maximum. |
| 3459 | check('input cap', e.mux_input_cap(), 1024 * 1024 + 9); | 3459 | check('input cap', e.mux_input_cap(), 1024 * 1024 + 41); |
| 3460 | 3460 | ||
| 3461 | // --- the shell's ACTUAL call list, read out of mux.js --- | 3461 | // --- the shell's ACTUAL call list, read out of mux.js --- |
| 3462 | // Everything above pins exports this file happens to name. This pins | 3462 | // Everything above pins exports this file happens to name. This pins |
| @@ -3711,7 +3711,7 @@ async function main() { | |||
| 3711 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); | 3711 | check('clipboard before lifecycle reset', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); |
| 3712 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); | 3712 | check('clipboard populated before lifecycle reset', e.mux_clipboard_len(), 4); |
| 3713 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); | 3713 | check('modes populated before lifecycle reset', e.mux_bracketed_paste(), 1); |
| 3714 | check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 16); | 3714 | check('selection before lifecycle reset request', e.mux_selection_request(301, 0, 0, 0, 0), 37); |
| 3715 | check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); | 3715 | check('selection before lifecycle reset reply', e.mux_client_frame(0x90, stage(selectionReply(301, 0, 'reset'))), clientAction.selection); |
| 3716 | check('selection populated before lifecycle reset', e.mux_selection_len(), 5); | 3716 | check('selection populated before lifecycle reset', e.mux_selection_len(), 5); |
| 3717 | e.mux_deinit(); | 3717 | e.mux_deinit(); |