7897f7d8
fix: retain native selection through live terminal redraws
a73x 2026-09-06 10:25
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -1363,3 +1363,43 @@ a fully green CI run; the later e2e groups did not run in this attempt. Next | |||
| 1363 | trigger for the agent-test owner: investigate if this exit-wait timeout recurs. | 1363 | trigger for the agent-test owner: investigate if this exit-wait timeout recurs. |
| 1364 | 1364 | ||
| 1365 | The remaining agent and throughput gates passed in the follow-up run. | 1365 | The remaining agent and throughput gates passed in the follow-up run. |
| 1366 | |||
| 1367 | |||
| 1368 | ### Active-output selection feedback — 2026-09-06 | ||
| 1369 | |||
| 1370 | The user isolated a misleading fullscreen symptom: Claude's counter paused in | ||
| 1371 | the smaller window, and selection failed as soon as it resumed. Root reproduced | ||
| 1372 | this with a real tmux counter and real Wayland pointer/clipboard. Geometry | ||
| 1373 | inspection found no fullscreen-specific hit defect. | ||
| 1374 | |||
| 1375 | The user's Ghostty reference led to a simpler policy: keep the live selected | ||
| 1376 | range through redraws and copy current text, even after that text is overwritten. | ||
| 1377 | Terra verified the pinned Ghostty selection/page ownership and reviewed the | ||
| 1378 | client validity predicate. Root implemented the pump-only change; the new source | ||
| 1379 | provenance flag preserves strict cached-history freshness after returning live. | ||
| 1380 | No per-cell tracking, duplicate extraction, new module, daemon change or wire | ||
| 1381 | extension was needed. Existing epoch, dimensions, modes, input, attachment and | ||
| 1382 | history-watermark guards remain. This supersedes prior retro statements that | ||
| 1383 | any selected-pane output must cancel copying. | ||
| 1384 | |||
| 1385 | Acceptance now includes a counter that demonstrably advances during held and | ||
| 1386 | released selection/copy, real tmux, windowed/fullscreen Wayland, and copying the | ||
| 1387 | current text after an overwrite. Evidence is `dist/wheel-scrolling/active-selection-*`. | ||
| 1388 | The first counter run reproduces the bug. A later full-fixture run exposed shell | ||
| 1389 | job-completion text overwriting the next specimen; the fixture now waits for its | ||
| 1390 | writer before repainting. User requested hands-on review, with no new recording. | ||
| 1391 | |||
| 1392 | Follow-up owner/trigger: the next history-identity slice must define scrolling, | ||
| 1393 | reflow and capped-history eviction identity before promising Ghostty's tracked | ||
| 1394 | pin behavior across the remote protocol. No atomic source snapshot is claimed. | ||
| 1395 | |||
| 1396 | Validation: client/native units and full native integration pass. Actual NVIDIA | ||
| 1397 | Wayland selection passes eleven checkpoints through real tmux, and wheel/history | ||
| 1398 | passes all ten checkpoints including scale transitions. Final focused review has | ||
| 1399 | no remaining finding. The owned isolated compositor is stopped; existing demo | ||
| 1400 | servers awaiting acceptance are preserved. No new recording, macOS verification | ||
| 1401 | or performance claim is made for this behavioral fix. | ||
| 1402 | |||
| 1403 | The final full `make ci` run passed, including all e2e, agent and throughput | ||
| 1404 | gates. The prior nested-agent timeout did not recur. Hands-on acceptance is | ||
| 1405 | still pending; source commit and tests do not imply user approval. | ||
docs/demos/native-wheel-scrolling.html
| Old | New | ||
|---|---|---|---|
| @@ -20,7 +20,7 @@ video{width:100%;display:block;background:#101114;border-radius:10px;margin:22px | |||
| 20 | <p class="muted">27-second continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> | 20 | <p class="muted">27-second continuous recording · NVIDIA RTX 3080 · Wayland at 200% scale · <a href="demo.mp4">Open video</a></p> |
| 21 | <div class="cards"><div class="card"><strong>Shell history</strong>Three rows per notch. Scroll down to live output, or type to return immediately.</div><div class="card"><strong>Application input</strong>Alternate-screen arrows honor cursor-key mode; mouse-aware apps receive the encoding they request.</div><div class="card"><strong>History selection</strong>Drag to copy the displayed history rows. The highlight follows its text when you scroll, including away and back.</div></div> | 21 | <div class="cards"><div class="card"><strong>Shell history</strong>Three rows per notch. Scroll down to live output, or type to return immediately.</div><div class="card"><strong>Application input</strong>Alternate-screen arrows honor cursor-key mode; mouse-aware apps receive the encoding they request.</div><div class="card"><strong>History selection</strong>Drag to copy the displayed history rows. The highlight follows its text when you scroll, including away and back.</div></div> |
| 22 | <h2>What the recording shows</h2> | 22 | <h2>What the recording shows</h2> |
| 23 | <p class="muted">The recording below predates the selection-preservation fix. That fix is ready for hands-on review; no replacement recording was requested.</p> | 23 | <p class="muted">The recording below predates the selection-preservation fix. The highlight also now survives live counter updates and redraws. These fixes are ready for hands-on review; no replacement recording was requested.</p> |
| 24 | <p>Three panes on two daemons. Scroll an unfocused shell pane, select a history row, read its text with a separate desktop clipboard client, return to live output, then scroll a real less process. Pointer motion, selection and wheel events go through the Wayland compositor. Setup commands use the ordinary SDL keyboard event path.</p> | 24 | <p>Three panes on two daemons. Scroll an unfocused shell pane, select a history row, read its text with a separate desktop clipboard client, return to live output, then scroll a real less process. Pointer motion, selection and wheel events go through the Wayland compositor. Setup commands use the ordinary SDL keyboard event path.</p> |
| 25 | <h2>Controls</h2> | 25 | <h2>Controls</h2> |
| 26 | <ul><li>Wheel over terminal content to scroll that pane without moving keyboard focus.</li><li>At a shell prompt, wheel up browses history and wheel down returns toward live output. Typing into that pane returns it to live.</li><li>Drag across displayed text and release to copy; Ctrl+Shift+C also copies the current selection.</li><li>Menus, headers, dividers and command mode consume wheel events. Horizontal wheel behavior remains outside this slice.</li></ul> | 26 | <ul><li>Wheel over terminal content to scroll that pane without moving keyboard focus.</li><li>At a shell prompt, wheel up browses history and wheel down returns toward live output. Typing into that pane returns it to live.</li><li>Drag across displayed text and release to copy; Ctrl+Shift+C also copies the current selection.</li><li>Menus, headers, dividers and command mode consume wheel events. Horizontal wheel behavior remains outside this slice.</li></ul> |
docs/superpowers/plans/2026-09-06-native-text-selection.md
| Old | New | ||
|---|---|---|---|
| @@ -54,9 +54,10 @@ the desktop clipboard's cap; the existing wire's 1 MiB bound still applies. | |||
| 54 | replacement/reconnect or detach cannot allow a delayed reply to overwrite the | 54 | replacement/reconnect or detach cannot allow a delayed reply to overwrite the |
| 55 | clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior | 55 | clipboard. Empty/invalid/unavailable/refused/oversized results leave its prior |
| 56 | contents intact; errors that need action get a bounded notice. | 56 | contents intact; errors that need action get a bounded notice. |
| 57 | - Coordinate validity while output changes is explicit and tested. Favor | 57 | - Live redraws retain the selected range, including when selected text is |
| 58 | cancellation when the displayed coordinates no longer identify the selected | 58 | overwritten; copying extracts its current text, following Ghostty. Geometry, |
| 59 | text, rather than copying newly arrived content under an old highlight. | 59 | screen/connection changes and history-watermark changes still invalidate it. |
| 60 | Cached-history selections retain exact source freshness. | ||
| 60 | - The painter highlights without changing replica cells. Core policy tests run | 61 | - The painter highlights without changing replica cells. Core policy tests run |
| 61 | without GUI libraries. Real clipboard reads and actual framebuffer samples | 62 | without GUI libraries. Real clipboard reads and actual framebuffer samples |
| 62 | are the independent integration oracles. | 63 | are the independent integration oracles. |
| @@ -226,3 +227,37 @@ The following application mouse/clipboard slice must demonstrate: | |||
| 226 | Owner: the planner and implementer of the application mouse/clipboard slice under | 227 | Owner: the planner and implementer of the application mouse/clipboard slice under |
| 227 | parity issue `8b16e26b`. Include the Shift override in that slice's demo and tests | 228 | parity issue `8b16e26b`. Include the Shift override in that slice's demo and tests |
| 228 | before declaring application mouse parity complete. GUI paste remains deferred. | 229 | before declaring application mouse parity complete. GUI paste remains deferred. |
| 230 | |||
| 231 | |||
| 232 | ### Active-output feedback | ||
| 233 | |||
| 234 | A Claude counter running inside tmux made selection unusable. The apparent | ||
| 235 | fullscreen dependency was actually the counter pausing in the smaller window. | ||
| 236 | A real tmux counter reproduced the cancellation, with ordinary redraws advancing | ||
| 237 | the replica sequence during a held drag. | ||
| 238 | |||
| 239 | Following the user's Ghostty reference, live selection now retains its range | ||
| 240 | through redraws and copies current daemon text. `SelectionVersion.history` | ||
| 241 | records whether the selection originated in a cached history view; those | ||
| 242 | selections still require the exact source sequence, including after returning | ||
| 243 | live. This supersedes the initial policy of cancelling on every output frame. | ||
| 244 | The pump remains the sole validity owner at poll, release, send, reply and take. | ||
| 245 | No renderer, mouse-coordinate, daemon or protocol changes are needed. | ||
| 246 | |||
| 247 | Ghostty reference (our pinned revision): | ||
| 248 | [tracked selections](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Selection.zig) | ||
| 249 | and [screen-owned selection](https://github.com/ghostty-org/ghostty/blob/853183e911b70ff7b61057f52fc7b47ea4934238/src/terminal/Screen.zig). | ||
| 250 | Its page pins belong to the daemon's terminal engine; the client has flattened | ||
| 251 | rows. Existing daemon extraction already uses Ghostty. We follow the redraw | ||
| 252 | behavior without adding another terminal or per-cell watcher to the client. | ||
| 253 | |||
| 254 | The current protocol cannot pin an atomic source snapshot for a remote copy, | ||
| 255 | or identify scrollback eviction when the history count remains at its cap. | ||
| 256 | Preserving selection across history eviction/reflow still needs an explicit | ||
| 257 | source-identity design; it is not claimed by this fix. Hands-on review replaces | ||
| 258 | a new recording at the user's request. | ||
| 259 | |||
| 260 | Validation: full `make ci`, client/native units, full native integration and | ||
| 261 | actual NVIDIA Wayland selection/wheel checks passed. The counter runs inside | ||
| 262 | real tmux for the Wayland selection check; eleven selection and ten wheel | ||
| 263 | checkpoints pass. Evidence: `dist/wheel-scrolling/active-selection-*.log`. | ||
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
| Old | New | ||
|---|---|---|---|
| @@ -73,8 +73,9 @@ Application mouse encodings are checked against the | |||
| 73 | [xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking). | 73 | [xterm mouse protocol](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking). |
| 74 | Selection stays attached to its source text when the viewport moves, including | 74 | Selection stays attached to its source text when the viewport moves, including |
| 75 | scrolling offscreen and back or returning through live view. A held drag keeps | 75 | scrolling offscreen and back or returning through live view. A held drag keeps |
| 76 | its original text anchor and extends using the current pane coordinates. Source | 76 | its original text anchor and extends using the current pane coordinates. Live redraws preserve the selected range; geometry, screen/connection and |
| 77 | changes still invalidate selection. These are part of wheel acceptance, not later polish. | 77 | history-watermark changes still invalidate it. Cached history keeps exact |
| 78 | source freshness. These are part of wheel acceptance, not later polish. | ||
| 78 | 79 | ||
| 79 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through | 80 | `frame.zig` owns SDL events and logical-to-framebuffer conversion through |
| 80 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; | 81 | `physicalPoint`. `interaction.Controller` owns pane hit-testing and modal policy; |
| @@ -257,3 +258,8 @@ wheel/copy regressions passed. The repository check gate passed; full CI stopped | |||
| 257 | on an existing nested-agent exit timeout, which passed on targeted rerun without | 258 | on an existing nested-agent exit timeout, which passed on targeted rerun without |
| 258 | changes. Full-run CI success is therefore not claimed for this feedback commit. | 259 | changes. Full-run CI success is therefore not claimed for this feedback commit. |
| 259 | Evidence: `dist/wheel-scrolling/selection-preserve-*.log`; details in `RETRO.md`. | 260 | Evidence: `dist/wheel-scrolling/selection-preserve-*.log`; details in `RETRO.md`. |
| 261 | |||
| 262 | |||
| 263 | Active-output follow-up: live selection now survives counter/redraw frames, | ||
| 264 | following Ghostty; a cached-history origin remains strict even after returning | ||
| 265 | live. See the text-selection plan's active-output entry for semantics and limits. | ||
src/client/session_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,8 +9,10 @@ const proto = term.protocol; | |||
| 9 | const Wire = @import("buffered_wire.zig").Wire; | 9 | const Wire = @import("buffered_wire.zig").Wire; |
| 10 | 10 | ||
| 11 | /// Identity of the displayed terminal state, copied with its grid under mu. | 11 | /// Identity of the displayed terminal state, copied with its grid under mu. |
| 12 | /// Live selection keeps its range through redraws and copies current text. | ||
| 13 | /// History views retain exact source freshness because their cells are cached. | ||
| 12 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. | 14 | /// Revision invalidates coordinates across reconnects, resizes and mode frames. |
| 13 | pub const SelectionVersion = struct { seq: u64, history_rows: u32, epoch: u64, revision: u64 }; | 15 | pub const SelectionVersion = struct { seq: u64, history_rows: u32, epoch: u64, revision: u64, history: bool = false }; |
| 14 | pub const SelectionRequest = struct { | 16 | pub const SelectionRequest = struct { |
| 15 | id: u32, | 17 | id: u32, |
| 16 | anchor: proto.SelectionPoint, | 18 | anchor: proto.SelectionPoint, |
| @@ -203,9 +205,11 @@ pub const Pump = struct { | |||
| 203 | } | 205 | } |
| 204 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { | 206 | fn selectionFreshLocked(self: *const Pump, version: SelectionVersion) bool { |
| 205 | return !self.closing.load(.acquire) and self.status.phase == .attached and | 207 | return !self.closing.load(.acquire) and self.status.phase == .attached and |
| 206 | self.replica.state_since_attach and version.seq == self.replica.last_seq and | 208 | self.replica.state_since_attach and version.epoch == self.replica.session_epoch and |
| 207 | version.history_rows == self.replica.history_rows and version.revision == self.selection_revision and | 209 | version.history_rows == self.replica.history_rows and version.revision == self.selection_revision and |
| 208 | std.meta.eql(version, self.selectionVersionLocked()); | 210 | (!version.history or version.seq == self.replica.last_seq) and |
| 211 | (self.history == null or (self.history_version.seq == self.replica.last_seq and | ||
| 212 | self.history_version.revision == self.selection_revision)); | ||
| 209 | } | 213 | } |
| 210 | fn returnLiveLocked(self: *Pump) void { | 214 | fn returnLiveLocked(self: *Pump) void { |
| 211 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; | 215 | if (self.scroll_rows == 0 and self.history == null and !self.history_dirty) return; |
| @@ -252,7 +256,7 @@ pub const Pump = struct { | |||
| 252 | if (self.history) |old| old.deinit(); | 256 | if (self.history) |old| old.deinit(); |
| 253 | self.history = view; | 257 | self.history = view; |
| 254 | self.history_start = origin; | 258 | self.history_start = origin; |
| 255 | self.history_version = .{ .seq = self.replica.last_seq, .history_rows = self.replica.history_rows, .epoch = self.replica.session_epoch, .revision = self.selection_revision }; | 259 | 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 }; |
| 256 | return .changed; | 260 | return .changed; |
| 257 | } | 261 | } |
| 258 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { | 262 | pub fn selectionFresh(self: *Pump, version: SelectionVersion) bool { |
| @@ -605,12 +609,21 @@ pub const Pump = struct { | |||
| 605 | .snapshot, .delta => { | 609 | .snapshot, .delta => { |
| 606 | const begin = std.time.nanoTimestamp(); | 610 | const begin = std.time.nanoTimestamp(); |
| 607 | const old_epoch = self.replica.session_epoch; | 611 | const old_epoch = self.replica.session_epoch; |
| 612 | const old_history_rows = self.replica.history_rows; | ||
| 613 | const old_cols = self.grid.cols; | ||
| 614 | const old_rows = self.grid.rows; | ||
| 608 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { | 615 | const applied = self.replica.apply(kind, payload) catch |err| switch (err) { |
| 609 | error.BadPayload => return .skip, | 616 | error.BadPayload => return .skip, |
| 610 | else => return err, | 617 | else => return err, |
| 611 | }; | 618 | }; |
| 612 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); | 619 | self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); |
| 613 | self.invalidateSelectionLocked(); | 620 | if (applied == .resync or self.history != null or |
| 621 | self.replica.session_epoch != old_epoch or self.replica.history_rows != old_history_rows or | ||
| 622 | self.grid.cols != old_cols or self.grid.rows != old_rows) | ||
| 623 | { | ||
| 624 | self.selection_revision +%= 1; | ||
| 625 | self.invalidateSelectionLocked(); | ||
| 626 | } | ||
| 614 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); | 627 | if (self.replica.session_epoch != old_epoch) self.returnLiveLocked(); |
| 615 | if (self.scroll_rows != 0) { | 628 | if (self.scroll_rows != 0) { |
| 616 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); | 629 | self.scroll_rows = @min(self.scroll_rows, self.replica.history_rows); |
| @@ -1353,17 +1366,17 @@ fn selectionTestReply(p: *Pump, id: u32, text: []const u8) !void { | |||
| 1353 | @memset(payload.items, 0xaa); // The published result must own its text. | 1366 | @memset(payload.items, 0xaa); // The published result must own its text. |
| 1354 | } | 1367 | } |
| 1355 | 1368 | ||
| 1356 | test "selection cancels queued work and rejects stale displayed versions before sending" { | 1369 | test "selection cancels queued work but retains live requests through redraw" { |
| 1357 | const p = try selectionTestPump(); | 1370 | const p = try selectionTestPump(); |
| 1358 | defer p.stop(); | 1371 | defer p.stop(); |
| 1359 | const cancelled = try selectionTestQueue(p, 1); | 1372 | const cancelled = try selectionTestQueue(p, 1); |
| 1360 | p.cancelSelection(); | 1373 | p.cancelSelection(); |
| 1361 | try std.testing.expect(selectionTestBegin(p, cancelled) == null); | 1374 | try std.testing.expect(selectionTestBegin(p, cancelled) == null); |
| 1362 | const stale = try selectionTestQueue(p, 2); | 1375 | const before_redraw = try selectionTestQueue(p, 2); |
| 1363 | var newer = testSnapshot(); | 1376 | var newer = testSnapshot(); |
| 1364 | std.mem.writeInt(u64, newer[0..8], 38, .little); | 1377 | std.mem.writeInt(u64, newer[0..8], 38, .little); |
| 1365 | _ = try p.onFrame(.snapshot, &newer); | 1378 | _ = try p.onFrame(.snapshot, &newer); |
| 1366 | try std.testing.expect(selectionTestBegin(p, stale) == null); | 1379 | try std.testing.expect(selectionTestBegin(p, before_redraw) != null); |
| 1367 | const current = try selectionTestQueue(p, 3); | 1380 | const current = try selectionTestQueue(p, 3); |
| 1368 | const bytes = selectionTestBegin(p, current).?; | 1381 | const bytes = selectionTestBegin(p, current).?; |
| 1369 | const wire = try proto.decodeSelectionReq(&bytes); | 1382 | const wire = try proto.decodeSelectionReq(&bytes); |
| @@ -1371,7 +1384,7 @@ test "selection cancels queued work and rejects stale displayed versions before | |||
| 1371 | try std.testing.expectEqualDeep(current.active, wire.active); | 1384 | try std.testing.expectEqualDeep(current.active, wire.active); |
| 1372 | } | 1385 | } |
| 1373 | 1386 | ||
| 1374 | test "selection results own UTF-8, newest request wins, and output cancels a decoded result" { | 1387 | test "selection results own UTF-8, newest request wins, and redraw preserves a decoded result" { |
| 1375 | const p = try selectionTestPump(); | 1388 | const p = try selectionTestPump(); |
| 1376 | defer p.stop(); | 1389 | defer p.stop(); |
| 1377 | _ = selectionTestBegin(p, try selectionTestQueue(p, 1)).?; | 1390 | _ = selectionTestBegin(p, try selectionTestQueue(p, 1)).?; |
| @@ -1387,8 +1400,40 @@ test "selection results own UTF-8, newest request wins, and output cancels a dec | |||
| 1387 | try std.testing.expectEqualStrings(text, copied.text); | 1400 | try std.testing.expectEqualStrings(text, copied.text); |
| 1388 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; | 1401 | _ = selectionTestBegin(p, try selectionTestQueue(p, 3)).?; |
| 1389 | try selectionTestReply(p, 3, "café"); | 1402 | try selectionTestReply(p, 3, "café"); |
| 1390 | _ = try p.onFrame(.snapshot, &testSnapshot()); | 1403 | var redraw = testSnapshot(); |
| 1391 | try std.testing.expect(p.takeSelection() == null); | 1404 | std.mem.writeInt(u64, redraw[0..8], 38, .little); |
| 1405 | _ = try p.onFrame(.snapshot, &redraw); | ||
| 1406 | const retained = p.takeSelection().?; | ||
| 1407 | defer std.testing.allocator.free(retained.text); | ||
| 1408 | try std.testing.expectEqualStrings("café", retained.text); | ||
| 1409 | } | ||
| 1410 | |||
| 1411 | test "live selection survives pending redraw; history and geometry remain guarded" { | ||
| 1412 | const p = try selectionTestPump(); | ||
| 1413 | defer p.stop(); | ||
| 1414 | const req = try selectionTestQueue(p, 1); | ||
| 1415 | _ = selectionTestBegin(p, req).?; | ||
| 1416 | var newer = testSnapshot(); | ||
| 1417 | std.mem.writeInt(u64, newer[0..8], 38, .little); | ||
| 1418 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1419 | try std.testing.expect(p.selectionFresh(req.version)); | ||
| 1420 | try selectionTestReply(p, 1, "current text"); | ||
| 1421 | const result = p.takeSelection().?; | ||
| 1422 | defer std.testing.allocator.free(result.text); | ||
| 1423 | try std.testing.expectEqualStrings("current text", result.text); | ||
| 1424 | try std.testing.expectEqualDeep(req.version, result.version); | ||
| 1425 | |||
| 1426 | const before_history = try selectionTestQueue(p, 2); | ||
| 1427 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 39, .history_rows = 1, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1428 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1429 | try std.testing.expect(!p.selectionFresh(before_history.version)); | ||
| 1430 | try std.testing.expect(selectionTestBegin(p, before_history) == null); | ||
| 1431 | const before_resize = try selectionTestQueue(p, 3); | ||
| 1432 | // Empty fixture rows also decode at 12 cols. | ||
| 1433 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 40, .history_rows = 1, .cols = 12, .rows = 3, .epoch = 93 }); | ||
| 1434 | _ = try p.onFrame(.snapshot, &newer); | ||
| 1435 | try std.testing.expect(!p.selectionFresh(before_resize.version)); | ||
| 1436 | try std.testing.expect(selectionTestBegin(p, before_resize) == null); | ||
| 1392 | } | 1437 | } |
| 1393 | 1438 | ||
| 1394 | test "selection mode changes, cancellation and timeout discard later replies" { | 1439 | test "selection mode changes, cancellation and timeout discard later replies" { |
| @@ -1478,6 +1523,23 @@ test "wheel history tombstones, refresh, resize and timeout preserve the live re | |||
| 1478 | defer restored.deinit(std.testing.allocator); | 1523 | defer restored.deinit(std.testing.allocator); |
| 1479 | _ = try p.onFrame(.scrollback_chunk, &chunk); | 1524 | _ = try p.onFrame(.scrollback_chunk, &chunk); |
| 1480 | try std.testing.expect(p.selectionFresh(history_version)); | 1525 | try std.testing.expect(p.selectionFresh(history_version)); |
| 1526 | const history_request = try selectionTestQueue(p, 42); | ||
| 1527 | try std.testing.expect(history_request.version.history); | ||
| 1528 | _ = selectionTestBegin(p, history_request).?; | ||
| 1529 | p.mu.lock(); | ||
| 1530 | p.returnLiveLocked(); | ||
| 1531 | p.mu.unlock(); | ||
| 1532 | var live_redraw = testSnapshot(); | ||
| 1533 | proto.writeSnapshotPrefix(live_redraw[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | ||
| 1534 | _ = try p.onFrame(.snapshot, &live_redraw); | ||
| 1535 | try std.testing.expect(!p.selectionFresh(history_request.version)); | ||
| 1536 | try selectionTestReply(p, 42, "stale history"); | ||
| 1537 | try std.testing.expect(p.takeSelection() == null); | ||
| 1538 | try p.routeWheel(&wire, up); | ||
| 1539 | try p.requestHistory(&wire); | ||
| 1540 | const after_live = (try proto.readFrame(std.testing.allocator, outgoing[0])).?; | ||
| 1541 | defer after_live.deinit(std.testing.allocator); | ||
| 1542 | _ = try p.onFrame(.scrollback_chunk, &chunk); | ||
| 1481 | var newer = testSnapshot(); | 1543 | var newer = testSnapshot(); |
| 1482 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); | 1544 | proto.writeSnapshotPrefix(newer[0..proto.snapshot_prefix_len], .{ .seq = 38, .history_rows = 20, .cols = 11, .rows = 3, .epoch = 93 }); |
| 1483 | _ = try p.onFrame(.snapshot, &newer); | 1545 | _ = try p.onFrame(.snapshot, &newer); |
test/native_selection.py
| Old | New | ||
|---|---|---|---|
| @@ -108,12 +108,12 @@ def cell_background(rig, state, pane_id, col, row): | |||
| 108 | rect['y'] + row * state['cell_h'] + 1) | 108 | rect['y'] + row * state['cell_h'] + 1) |
| 109 | 109 | ||
| 110 | 110 | ||
| 111 | def arm_output(rig, pane_id, label): | 111 | def arm_output(rig, pane_id, label, row=7): |
| 112 | """Shell-owned output released by a file, with no input during the drag.""" | 112 | """Shell-owned output released by a file, with no input during the drag.""" |
| 113 | trigger = rig.root / label | 113 | trigger = rig.root / label |
| 114 | rig.focus(pane_id) | 114 | rig.focus(pane_id) |
| 115 | rig.shell('(while ! test -e ' + shlex.quote(str(trigger)) + | 115 | rig.shell('(while ! test -e ' + shlex.quote(str(trigger)) + |
| 116 | "; do sleep .02; done; printf '\\033[7;1H" + label + "') &") | 116 | "; do sleep .02; done; printf '\\033[" + str(row) + ";1H" + label + "') &") |
| 117 | return trigger | 117 | return trigger |
| 118 | 118 | ||
| 119 | 119 | ||
| @@ -197,10 +197,86 @@ def scale_selection(rig, pane): | |||
| 197 | rig.ok('real held drags cancel at 100/150/200% transitions; new drags copy and PTYs agree') | 197 | rig.ok('real held drags cancel at 100/150/200% transitions; new drags copy and PTYs agree') |
| 198 | 198 | ||
| 199 | 199 | ||
| 200 | |||
| 201 | def active_output(rig, pane): | ||
| 202 | """A real PTY keeps repainting while selection and clipboard are observed.""" | ||
| 203 | rig.focus(pane) | ||
| 204 | stop = rig.root / 'counter-stop' | ||
| 205 | program = rig.root / 'selection-counter.py' | ||
| 206 | program.write_text( | ||
| 207 | 'import os, time\nfrom pathlib import Path\n' | ||
| 208 | f'stop=Path({str(stop)!r})\n' | ||
| 209 | 'os.write(1,b"\\033[?25l\\033[2J\\033[HCOUNTER-READY")\n' | ||
| 210 | 'n=0\n' | ||
| 211 | 'while not stop.exists():\n' | ||
| 212 | ' os.write(1,("\\033[2;1Halpha café omega\\033[2;21HCOUNT-%08d" % n).encode())\n' | ||
| 213 | ' n+=1; time.sleep(.025)\n' | ||
| 214 | 'print("\\r\\nCOUNTER-DONE",flush=True)\n') | ||
| 215 | tmux_socket = rig.root / 'counter-tmux.sock' | ||
| 216 | nested = os.environ.get('MUXG_TEST_TMUX') == '1' | ||
| 217 | command = 'python3 ' + shlex.quote(str(program)) | ||
| 218 | if nested: | ||
| 219 | command = ('tmux -S ' + shlex.quote(str(tmux_socket)) + | ||
| 220 | ' -f /dev/null new-session -s counter ' + shlex.quote(command)) | ||
| 221 | rig.shell(command) | ||
| 222 | def count(state): | ||
| 223 | text = by_id(state)[pane]['painted_text'] | ||
| 224 | return int(text.split('COUNT-', 1)[1][:8]) if 'COUNT-' in text else -1 | ||
| 225 | rig.wait_state(lambda state: count(state) >= 0) | ||
| 226 | wayland = rig.env['SDL_VIDEO_DRIVER'] == 'wayland' | ||
| 227 | windowed = rig.state() | ||
| 228 | original_size = (windowed['logical_width'], windowed['logical_height']) | ||
| 229 | try: | ||
| 230 | for fullscreen in ((False, True) if wayland else (False,)): | ||
| 231 | if wayland: | ||
| 232 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] fullscreen ' + | ||
| 233 | ('enable' if fullscreen else 'disable')], | ||
| 234 | env=rig.env, capture_output=True, check=True, timeout=3) | ||
| 235 | rig.wait_state(lambda state: ((state['logical_width'], state['logical_height']) != | ||
| 236 | original_size) == fullscreen) | ||
| 237 | # Let resize output settle while proving the application still runs. | ||
| 238 | initial = count(rig.state()) | ||
| 239 | state = rig.wait_state(lambda state: count(state) >= initial + 8) | ||
| 240 | background = cell_background(rig, state, pane, 1, 1) | ||
| 241 | rig.select(pane, (0, 1), (4, 1), release=False) | ||
| 242 | initial = count(rig.state()) | ||
| 243 | rig.wait_state(lambda state: count(state) >= initial + 12) | ||
| 244 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 245 | 'active counter cleared the held selection') | ||
| 246 | rig.key('copy') | ||
| 247 | rig.copied('alpha') | ||
| 248 | rig.send('mouseup:' + rig.cell_point(state, pane, 4, 1)) | ||
| 249 | rig.copied('alpha') | ||
| 250 | initial = count(rig.state()) | ||
| 251 | rig.wait_state(lambda state: count(state) >= initial + 12) | ||
| 252 | eventually(lambda: cell_background(rig, state, pane, 1, 1) != background, | ||
| 253 | 'active counter cleared the released highlight') | ||
| 254 | rig.key('copy') | ||
| 255 | rig.copied('alpha') | ||
| 256 | rig.ok('live PTY counter keeps advancing during held/released selection and copy' + | ||
| 257 | (' through tmux' if nested else '') + | ||
| 258 | (' in windowed and fullscreen Wayland' if wayland else '')) | ||
| 259 | finally: | ||
| 260 | stop.touch() | ||
| 261 | if nested: | ||
| 262 | subprocess.run(['tmux', '-S', str(tmux_socket), 'kill-server'], | ||
| 263 | env=rig.env, capture_output=True, timeout=3) | ||
| 264 | if wayland: | ||
| 265 | subprocess.run(['swaymsg', f'[pid={rig.gui.pid}] fullscreen disable'], | ||
| 266 | env=rig.env, capture_output=True, timeout=3) | ||
| 267 | if wayland: | ||
| 268 | rig.wait_state(lambda state: (state['logical_width'], state['logical_height']) == original_size) | ||
| 269 | if nested: | ||
| 270 | rig.wait_state(lambda state: count(state) < 0) | ||
| 271 | else: | ||
| 272 | rig.wait_state(lambda state: 'COUNTER-DONE' in by_id(state)[pane]['painted_text']) | ||
| 273 | |||
| 274 | |||
| 200 | def exercise(rig): | 275 | def exercise(rig): |
| 201 | refs = start_persistent(rig) | 276 | refs = start_persistent(rig) |
| 202 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) | 277 | rig.drag('stacked', dy=rig.state()['cell_h'] * 3) |
| 203 | panes = list(refs) | 278 | panes = list(refs) |
| 279 | active_output(rig, panes[1]) | ||
| 204 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} | 280 | wraps = {pane: specimen(rig, pane, 'PANE-' + str(pane)) for pane in panes} |
| 205 | target, neighbour = panes[1], panes[2] | 281 | target, neighbour = panes[1], panes[2] |
| 206 | copy_shortcut(rig, target) | 282 | copy_shortcut(rig, target) |
| @@ -264,7 +340,7 @@ def exercise(rig): | |||
| 264 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) | 340 | rig.wait_state(lambda s: 'NEIGHBOUR-OUTPUT' in by_id(s)[neighbour]['painted_text']) |
| 265 | rig.send('mouseup:' + rig.cell_point(state, target, 9, 1)) | 341 | rig.send('mouseup:' + rig.cell_point(state, target, 9, 1)) |
| 266 | rig.copied('café') | 342 | rig.copied('café') |
| 267 | trigger = arm_output(rig, target, 'SELECTED-OUTPUT') | 343 | trigger = arm_output(rig, target, 'SELECTED-OUTPUT', row=2) |
| 268 | specimen(rig, target, 'PANE-' + str(target)) | 344 | specimen(rig, target, 'PANE-' + str(target)) |
| 269 | background = cell_background(rig, rig.state(), target, 1, 1) | 345 | background = cell_background(rig, rig.state(), target, 1, 1) |
| 270 | state = rig.select(target, (0, 1), (4, 1), release=False) | 346 | state = rig.select(target, (0, 1), (4, 1), release=False) |
| @@ -273,8 +349,10 @@ def exercise(rig): | |||
| 273 | trigger.touch() | 349 | trigger.touch() |
| 274 | rig.wait_state(lambda s: 'SELECTED-OUTPUT' in by_id(s)[target]['painted_text']) | 350 | rig.wait_state(lambda s: 'SELECTED-OUTPUT' in by_id(s)[target]['painted_text']) |
| 275 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) | 351 | rig.send('mouseup:' + rig.cell_point(state, target, 4, 1)) |
| 276 | rig.unchanged('café') | 352 | rig.copied('SELEC') |
| 277 | rig.ok('independent pane output preserves selection; selected-pane changes cancel it') | 353 | rig.ok('redraw preserves selection; overwriting selected text copies the current range') |
| 354 | rig.shell('wait') # Reap the fixture writer before painting the next specimen. | ||
| 355 | specimen(rig, target, 'PANE-' + str(target)) | ||
| 278 | rig.select(target, (0, 1), (4, 1)) | 356 | rig.select(target, (0, 1), (4, 1)) |
| 279 | rig.copied('alpha') | 357 | rig.copied('alpha') |
| 280 | 358 | ||