677ea109
refactor: one pending wake, one picker close, one painted row
a73x 2026-09-01 13:19
Commit message
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. |