a73x

677ea109

refactor: one pending wake, one picker close, one painted row

a73x   2026-09-01 13:19

Commit message
refactor: one pending wake, one picker close, one painted row

wakePending states the pump_done-before-alive ordering once for
bindTile and armRevive; closePicker is the give-back closeAsk already
had; stepWhere walks the ring for stepPresent and stepLive; paint's
row prefix and sync-close tail each live in one helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

src/tui/paint.zig
Old New
@@ -54,6 +54,40 @@ pub fn clampCursor(cur: Engine.CursorPos, vp: Viewport) Engine.CursorPos {
54 }; 54 };
55 } 55 }
56 56
57 /// The prefix every painted row carries: CUP to that row's own left edge in
58 /// the tile's rect, then the clear that bounds what follows. `clear` is
59 /// span-bounded ECH for a tile sharing the screen, and empty for one that
60 /// already cleared the whole screen — a line-wide clear would reach a
61 /// neighbour's cells or a rail.
62 fn appendRowAt(
63 paint: *std.ArrayList(u8),
64 alloc: std.mem.Allocator,
65 row: u16,
66 vp: Viewport,
67 clear: []const u8,
68 ) !void {
69 var cup: [24]u8 = undefined;
70 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row) + vp.top + 1, vp.left + 1, clear }));
71 }
72
73 /// The tail of a live paint: park the cursor where the grid puts it —
74 /// clamped, since latest-wins lets the grid outgrow the rect — close the
75 /// synchronized update, and write the buffer in ONE call, so no half-drawn
76 /// screen is ever on the terminal.
77 fn finishPaint(
78 paint: *std.ArrayList(u8),
79 alloc: std.mem.Allocator,
80 cur: Engine.CursorPos,
81 vp: Viewport,
82 out_fd: std.posix.fd_t,
83 ) !void {
84 const at = clampCursor(cur, vp);
85 var cbuf: [16]u8 = undefined;
86 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ at.y + vp.top + 1, at.x + vp.left + 1 }));
87 try paint.appendSlice(alloc, sync_end);
88 try proto.writeAllFd(out_fd, paint.items);
89 }
90
57 /// The replica may exceed the tty under latest-wins; rows clip at the 91 /// The replica may exceed the tty under latest-wins; rows clip at the
58 /// right edge (DECAWM off). `rows` null is every row of the viewport. 92 /// right edge (DECAWM off). `rows` null is every row of the viewport.
59 pub fn renderClipped( 93 pub fn renderClipped(
@@ -85,18 +119,13 @@ pub fn renderClipped(
85 for (0..n) |k| { 119 for (0..n) |k| {
86 const y: u16 = if (rows) |r| r[k] else @intCast(k); 120 const y: u16 = if (rows) |r| r[k] else @intCast(k);
87 if (y >= limit) continue; 121 if (y >= limit) continue;
88 var cup: [24]u8 = undefined; 122 try appendRowAt(&paint, alloc, y, vp, clear);
89 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, y) + vp.top + 1, vp.left + 1, clear }));
90 const row = try dumpRow(alloc, replica, y, hl, view); 123 const row = try dumpRow(alloc, replica, y, hl, view);
91 defer alloc.free(row); 124 defer alloc.free(row);
92 try paint.appendSlice(alloc, row); 125 try paint.appendSlice(alloc, row);
93 } 126 }
94 127
95 const cur = clampCursor(replica.cursorPos(), vp); 128 try finishPaint(&paint, alloc, replica.cursorPos(), vp, out_fd);
96 var cbuf: [16]u8 = undefined;
97 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + vp.top + 1, cur.x + vp.left + 1 }));
98 try paint.appendSlice(alloc, sync_end);
99 try proto.writeAllFd(out_fd, paint.items);
100 } 129 }
101 130
102 /// What a MOVING selection needs: a drag changes one or two rows, and a 131 /// What a MOVING selection needs: a drag changes one or two rows, and a
@@ -145,8 +174,7 @@ pub fn paintDeltaClipped(
145 var it = proto.deltaRowIterator(payload); 174 var it = proto.deltaRowIterator(payload);
146 while (try it.next()) |row| { 175 while (try it.next()) |row| {
147 if (row.row >= vp.rows) continue; 176 if (row.row >= vp.rows) continue;
148 var cup: [24]u8 = undefined; 177 try appendRowAt(&paint, alloc, row.row, vp, ech);
149 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row.row) + vp.top + 1, vp.left + 1, ech }));
150 // A row under the selection is redrawn from the replica, which has been 178 // A row under the selection is redrawn from the replica, which has been
151 // fed this very frame; every other row keeps the daemon's bytes verbatim, 179 // fed this very frame; every other row keeps the daemon's bytes verbatim,
152 // so a held selection costs one dumped row per covered row. Verbatim is 180 // so a held selection costs one dumped row per covered row. Verbatim is
@@ -166,11 +194,7 @@ pub fn paintDeltaClipped(
166 } 194 }
167 } 195 }
168 196
169 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp); 197 try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd);
170 var cbuf: [16]u8 = undefined;
171 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + vp.top + 1, cur.x + vp.left + 1 }));
172 try paint.appendSlice(alloc, sync_end);
173 try proto.writeAllFd(out_fd, paint.items);
174 } 198 }
175 199
176 /// An inverse status marker parked in the top-right corner: `[scroll]` when 200 /// An inverse status marker parked in the top-right corner: `[scroll]` when
@@ -222,8 +246,7 @@ fn appendClippedHistory(
222 const view: Engine.RowView = .{ .col_off = vp.left, .cols = vp.cols }; 246 const view: Engine.RowView = .{ .col_off = vp.left, .cols = vp.cols };
223 var n: u16 = 0; 247 var n: u16 = 0;
224 while (n < @min(rows, vp.rows)) : (n += 1) { 248 while (n < @min(rows, vp.rows)) : (n += 1) {
225 var cup: [24]u8 = undefined; 249 try appendRowAt(paint, alloc, n, vp, ech);
226 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, n) + vp.top + 1, vp.left + 1, ech }));
227 const seg = try scratch.dumpVtRowClipped(alloc, n, view); 250 const seg = try scratch.dumpVtRowClipped(alloc, n, view);
228 defer alloc.free(seg); 251 defer alloc.free(seg);
229 try paint.appendSlice(alloc, seg); 252 try paint.appendSlice(alloc, seg);
@@ -267,8 +290,7 @@ pub fn renderScrollback(
267 const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len; 290 const nl = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
268 var seg = rest[0..nl]; 291 var seg = rest[0..nl];
269 if (seg.len > 0 and seg[seg.len - 1] == '\r') seg = seg[0 .. seg.len - 1]; 292 if (seg.len > 0 and seg[seg.len - 1] == '\r') seg = seg[0 .. seg.len - 1];
270 var cup: [24]u8 = undefined; 293 try appendRowAt(&paint, alloc, n, vp, ech);
271 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, n) + vp.top + 1, vp.left + 1, ech }));
272 try paint.appendSlice(alloc, seg); 294 try paint.appendSlice(alloc, seg);
273 if (nl == rest.len) break; 295 if (nl == rest.len) break;
274 rest = rest[nl + 1 ..]; 296 rest = rest[nl + 1 ..];
src/tui/wallview.zig
Old New
@@ -213,6 +213,32 @@ pub fn closeAsk(
213 wall_picker.paintPicker(shared, w.hosts, picker_sel, back.line); 213 wall_picker.paintPicker(shared, w.hosts, picker_sel, back.line);
214 } 214 }
215 215
216 /// The host picker gives the terminal back — the key that closed it, or the
217 /// auto-open deciding the wall no longer needs it. `shown` is the keyboard
218 /// loop's own mirror of `prefix.picking`, cleared here so the two cannot
219 /// disagree about whose screen it is. `birth_at` is the tile a picker Enter
220 /// just made: the focus goes there, re-cutting on the way.
221 pub fn closePicker(
222 w: Wall,
223 prefix: *interact.PrefixFilter,
224 shown: *bool,
225 birth_at: ?usize,
226 ) void {
227 // ONE function: the close owes a `relayout`, which CLEARS the screen the
228 // popup was painted over and bumps `repaint_gen` — the only thing that
229 // redraws a tile whose session said nothing while the box was up.
230 prefix.picking = false;
231 shown.* = false;
232 w.shared.picker_open.store(false, .release);
233 // The screen under the box is about to be redrawn, so the next open owes
234 // a paint however identical its rows are.
235 w.shared.picker_stamp = 0;
236 if (birth_at) |at|
237 focusAnswer(w, true, at)
238 else
239 wall_layout.relayout(w, w.shared.sel);
240 }
241
216 /// Whether a popup owns the terminal. 242 /// Whether a popup owns the terminal.
217 pub fn popupOpen(shared: *const Shared) bool { 243 pub fn popupOpen(shared: *const Shared) bool {
218 // Every tile paint asks: a rect redrawn under either box would erase 244 // Every tile paint asks: a rect redrawn under either box would erase
@@ -412,13 +438,23 @@ const WallInput = struct {
412 /// left. `sel` itself is the answer when it is the only one present, which 438 /// left. `sel` itself is the answer when it is the only one present, which
413 /// is what makes a one-tile wall's `j` a no-op rather than a null. 439 /// is what makes a one-tile wall's `j` a no-op rather than a null.
414 pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize { 440 pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize {
441 return stepWhere(present, null, sel, forward);
442 }
443
444 /// One lap of the ring from `sel` in `forward`'s direction, answering the
445 /// first index that qualifies. `tiles` narrows the answer to a tile whose
446 /// pump is still running; null takes any present tile. Exactly one lap, so
447 /// `sel` itself is the last index tried and never skipped.
448 fn stepWhere(present: []const bool, tiles: ?[]const Tile, sel: usize, forward: bool) ?usize {
415 const n = present.len; 449 const n = present.len;
416 if (n == 0 or sel >= n) return null; 450 if (n == 0 or sel >= n) return null;
417 var i = sel; 451 var i = sel;
418 var seen: usize = 0; 452 var seen: usize = 0;
419 while (seen < n) : (seen += 1) { 453 while (seen < n) : (seen += 1) {
420 i = if (forward) (i + 1) % n else (i + n - 1) % n; 454 i = if (forward) (i + 1) % n else (i + n - 1) % n;
421 if (present[i]) return i; 455 if (!present[i]) continue;
456 if (tiles) |ts| if (!ts[i].alive.load(.acquire)) continue;
457 return i;
422 } 458 }
423 return null; 459 return null;
424 } 460 }
@@ -480,15 +516,7 @@ pub fn walkTiles(present: []const bool, sel: usize, forward: bool) ?usize {
480 /// narrators still has somewhere to put the cursor. `tiles` must outlive 516 /// narrators still has somewhere to put the cursor. `tiles` must outlive
481 /// the call; only `present` is used for the fallback. 517 /// the call; only `present` is used for the fallback.
482 fn stepLive(tiles: []const Tile, present: []const bool, sel: usize, forward: bool) ?usize { 518 fn stepLive(tiles: []const Tile, present: []const bool, sel: usize, forward: bool) ?usize {
483 const n = present.len; 519 return stepWhere(present, tiles, sel, forward) orelse stepPresent(present, sel, forward);
484 if (n == 0 or sel >= n) return null;
485 var i = sel;
486 var seen: usize = 0;
487 while (seen < n) : (seen += 1) {
488 i = if (forward) (i + 1) % n else (i + n - 1) % n;
489 if (present[i] and tiles[i].alive.load(.acquire)) return i;
490 }
491 return stepPresent(present, sel, forward);
492 } 520 }
493 521
494 /// The state is remembered on the tile: the keyboard repaints this bar 522 /// The state is remembered on the tile: the keyboard repaints this bar
@@ -954,23 +982,33 @@ const Birth = struct {
954 borrowed: bool = false, 982 borrowed: bool = false,
955 }; 983 };
956 984
957 /// A pending pane the host's own list confirmed: wake it in place. The 985 /// The pending→live transition, in the one order that is safe: a saved pane
958 /// rect was cut at seed time, so binding moves nothing and re-cuts 986 /// stops being a drawing and becomes a session a pump is dialling. `creates`
959 /// nothing — which is the whole point of seeding. 987 /// asks the daemon to MAKE the session (a revive); false attaches to one the
960 pub fn bindTile(w: Wall, i: usize) void { 988 /// host already listed (a bind). The caller spawns the pump after.
961 const t = &w.tiles[i]; 989 pub fn wakePending(t: *Tile, creates: bool) void {
962 w.shared.paint_mu.lock(); 990 t.shared.paint_mu.lock();
963 t.pending = false; 991 t.pending = false;
992 if (creates) t.creates = true;
964 t.state = .connecting; 993 t.state = .connecting;
965 w.shared.paint_mu.unlock(); 994 t.shared.paint_mu.unlock();
966 t.end_seen = false; 995 t.end_seen = false;
967 // The saved focus may be sitting on this pane; the claim is what makes 996 // The focus may already be sitting on this pane — the saved focus a bind
968 // it reach the terminal, since no `setFocus` moves onto it. 997 // lands under, or the pane whose Enter asked for the revive — and no
969 if (i == w.shared.sel) t.claim_pending.store(true, .release); 998 // `setFocus` moves onto it, so the claim is what reaches the terminal.
999 if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
970 // False must land before the thread exists: the pump only ever stores 1000 // False must land before the thread exists: the pump only ever stores
971 // true, and `freeSlot` reads it as "no thread still holds this slot". 1001 // true, and `freeSlot` reads it as "no thread still holds this slot".
972 t.pump_done.store(false, .release); 1002 t.pump_done.store(false, .release);
973 t.alive.store(true, .release); 1003 t.alive.store(true, .release);
1004 }
1005
1006 /// A pending pane the host's own list confirmed: wake it in place. The
1007 /// rect was cut at seed time, so binding moves nothing and re-cuts
1008 /// nothing — which is the whole point of seeding.
1009 pub fn bindTile(w: Wall, i: usize) void {
1010 const t = &w.tiles[i];
1011 wakePending(t, false);
974 spawnPump(t); 1012 spawnPump(t);
975 } 1013 }
976 1014
@@ -978,17 +1016,7 @@ pub fn bindTile(w: Wall, i: usize) void {
978 /// transitions without racing a pump. `reviveTile` is the only production 1016 /// transitions without racing a pump. `reviveTile` is the only production
979 /// caller. 1017 /// caller.
980 pub fn armRevive(t: *Tile) void { 1018 pub fn armRevive(t: *Tile) void {
981 t.shared.paint_mu.lock(); 1019 wakePending(t, true);
982 t.pending = false;
983 t.creates = true;
984 t.state = .connecting;
985 t.shared.paint_mu.unlock();
986 t.end_seen = false;
987 // The user is looking at this pane — Enter was pressed IN it — so the
988 // claim is armed the way bindTile arms the saved focus.
989 if (t.idx == t.shared.sel) t.claim_pending.store(true, .release);
990 t.pump_done.store(false, .release);
991 t.alive.store(true, .release);
992 } 1020 }
993 1021
994 /// A gone pane, revived on the user's Enter: the same tile re-arms as a 1022 /// A gone pane, revived on the user's Enter: the same tile re-arms as a
@@ -1899,13 +1927,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1899 input.prefix.picking = true; 1927 input.prefix.picking = true;
1900 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel); 1928 picker_sel = wall_picker.pickerNearest(w.hosts, picker_sel);
1901 }, 1929 },
1902 .close => { 1930 .close => closePicker(w, &input.prefix, &picker_shown, null),
1903 input.prefix.picking = false;
1904 picker_shown = false;
1905 shared.picker_open.store(false, .release);
1906 shared.picker_stamp = 0;
1907 wall_layout.relayout(w, shared.sel);
1908 },
1909 .leave => {}, 1931 .leave => {},
1910 } 1932 }
1911 // On news, not on the tick: the rows follow the pollers while the 1933 // On news, not on the tick: the rows follow the pollers while the
@@ -2020,19 +2042,11 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
2020 const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true); 2042 const keyed = wall_picker.pickerRepaint(&line_buf, &input.prefix, true);
2021 wall_picker.paintPicker(&shared, w.hosts, picker_sel, keyed.line); 2043 wall_picker.paintPicker(&shared, w.hosts, picker_sel, keyed.line);
2022 } else { 2044 } else {
2023 // The close gives the terminal back: `relayout` clears it 2045 // A close the USER asked for: the auto-open no longer owns
2024 // and bumps `repaint_gen`, which is the only thing that 2046 // this popup, so the next tile to arrive cannot force-close
2025 // redraws a tile whose session said nothing meanwhile. 2047 // a box the user opened themselves.
2026 shared.picker_open.store(false, .release);
2027 // The screen under the box is about to be redrawn, so the next
2028 // open owes a paint however identical its rows are.
2029 shared.picker_stamp = 0;
2030 picker_shown = false;
2031 picker_auto.taken(); 2048 picker_auto.taken();
2032 if (birth_at) |at| 2049 closePicker(w, &input.prefix, &picker_shown, birth_at);
2033 focusAnswer(w, true, at)
2034 else
2035 wall_layout.relayout(w, shared.sel);
2036 // The focused pump may be holding a claim the popup refused; 2050 // The focused pump may be holding a claim the popup refused;
2037 // it re-arms and retries on its next pass, and this is what 2051 // it re-arms and retries on its next pass, and this is what
2038 // makes that pass happen now rather than within a poll. 2052 // makes that pass happen now rather than within a poll.