a73x

7897f7d8

fix: retain native selection through live terminal redraws

a73x   2026-09-06 10:25

Commit message
fix: retain native selection through live terminal redraws

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