42e13926
fix: picker repaint clears the rows a shorter box no longer covers
a73x 2026-09-04 15:08
Commit message
src/tui/wall_layout.zig
| Old | New | ||
|---|---|---|---|
| @@ -134,8 +134,9 @@ pub fn relayout(w: Wall, sel: usize) void { | |||
| 134 | } | 134 | } |
| 135 | if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; | 135 | if (w.shared.is_tty) proto.writeAllFd(w.shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {}; |
| 136 | // The screen the popup was on has just been cleared, so the next paint | 136 | // The screen the popup was on has just been cleared, so the next paint |
| 137 | // owes it however unchanged its rows are. | 137 | // owes it however unchanged its rows are — and owes no clear for the |
| 138 | if (w.shared.picker_open.load(.acquire)) w.shared.picker_stamp = 0; | 138 | // box that was there, whose rows this clear has already taken. |
| 139 | if (w.shared.picker_open.load(.acquire)) w.shared.picker_frame = .{}; | ||
| 139 | if (live == 0) { | 140 | if (live == 0) { |
| 140 | wv.paintEmptyWallLocked(w.shared); | 141 | wv.paintEmptyWallLocked(w.shared); |
| 141 | return; | 142 | return; |
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -30,9 +30,17 @@ pub const picker_row_max: usize = 128; | |||
| 30 | const picker_min_cols: u16 = 24; | 30 | const picker_min_cols: u16 = 24; |
| 31 | const picker_min_rows: u16 = 4; | 31 | const picker_min_rows: u16 = 4; |
| 32 | 32 | ||
| 33 | /// Room for the clears a shrunken box owes the rows it no longer covers: | ||
| 34 | /// one cursor address and one ECH per row of the box that was there, whose | ||
| 35 | /// height is capped the same way this one's is, plus the SGR reset the run | ||
| 36 | /// opens with. | ||
| 37 | const picker_clear_max: usize = (wv.max_tiles + 2) * 24 + 4; | ||
| 38 | |||
| 33 | /// One whole popup, written in one go: every row plus its cursor address | 39 | /// One whole popup, written in one go: every row plus its cursor address |
| 34 | /// and its two SGRs, at the table's cap of rows. | 40 | /// and its two SGRs, at the table's cap of rows, and the clears in front of |
| 35 | const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8; | 41 | /// it — a resize can put the old box and the new one on disjoint rows, so |
| 42 | /// the two heights are budgeted separately rather than shared. | ||
| 43 | const picker_frame_max: usize = (wv.max_tiles + 2) * (picker_row_max + 32) + 8 + picker_clear_max; | ||
| 36 | 44 | ||
| 37 | /// The picker's rendered rows. Rendered once per paint and read twice — | 45 | /// The picker's rendered rows. Rendered once per paint and read twice — |
| 38 | /// by the writer and by the tests — so the box on the screen is the box | 46 | /// by the writer and by the tests — so the box on the screen is the box |
| @@ -294,8 +302,9 @@ pub fn paintAsk(shared: *Shared, prompt: []const u8, prefix: *const interact.Pre | |||
| 294 | pickerLine(wr, top + 1 + r, left, w, body.row(r)); | 302 | pickerLine(wr, top + 1 + r, left, w, body.row(r)); |
| 295 | } | 303 | } |
| 296 | // The picker's screen is gone under this box, so its next paint owes a | 304 | // The picker's screen is gone under this box, so its next paint owes a |
| 297 | // frame however unchanged its rows are. | 305 | // frame however unchanged its rows are — and must not ECH over rows |
| 298 | shared.picker_stamp = 0; | 306 | // this box now holds, which is why the geometry goes with the hash. |
| 307 | shared.picker_frame = .{}; | ||
| 299 | proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {}; | 308 | proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {}; |
| 300 | } | 309 | } |
| 301 | 310 | ||
| @@ -752,6 +761,41 @@ pub fn paintPicker(w: Wall, sel: usize, view: PickerView, line: ?[]const u8) voi | |||
| 752 | var out: [picker_frame_max]u8 = undefined; | 761 | var out: [picker_frame_max]u8 = undefined; |
| 753 | var fbs = std.io.fixedBufferStream(&out); | 762 | var fbs = std.io.fixedBufferStream(&out); |
| 754 | const wr = fbs.writer(); | 763 | const wr = fbs.writer(); |
| 764 | // The rows of the box that is on the screen NOW which this one does not | ||
| 765 | // cover, erased before the new box is written. Nothing else would: tiles | ||
| 766 | // do not paint while the popup is up, so a box that shrank — a host list | ||
| 767 | // whose Enter descends into a one-session list, an `x` that shortens the | ||
| 768 | // hosts — used to leave its outer rows standing until the close, and the | ||
| 769 | // user read four host rows with one session row painted over the middle | ||
| 770 | // of them. | ||
| 771 | const prev = shared.picker_frame; | ||
| 772 | // A box whose columns do not hold every column the old one wrote leaves | ||
| 773 | // stale cells on a row it otherwise covers. Only a width change does | ||
| 774 | // that, and a WINCH clears the whole screen through `relayout`, which | ||
| 775 | // forgets the frame before the next paint runs, so no clear is owed at | ||
| 776 | // all on that path. The check is here so a future paint that changes the | ||
| 777 | // box's width without clearing the screen cannot leave a stale column. | ||
| 778 | const spans = left <= prev.left and left + box_w >= prev.left + prev.cols; | ||
| 779 | if (prev.stamp != 0) { | ||
| 780 | var cleared = false; | ||
| 781 | var pr: u16 = prev.top; | ||
| 782 | while (pr < prev.top + prev.rows) : (pr += 1) { | ||
| 783 | if (spans and pr >= top and pr < top + height) continue; | ||
| 784 | if (!cleared) { | ||
| 785 | // Default rendition first: ECH erases with the CURRENT one, | ||
| 786 | // and every row of the box it is erasing wears reverse video. | ||
| 787 | wr.writeAll("\x1b[0m") catch return; | ||
| 788 | cleared = true; | ||
| 789 | } | ||
| 790 | clearSpan(wr, pr, prev.left, prev.cols); | ||
| 791 | } | ||
| 792 | } | ||
| 793 | // Where the box itself starts. The stamp below is taken over THIS much | ||
| 794 | // and no more, so the hash stays a function of the box alone: a repaint | ||
| 795 | // whose rows are unchanged is refused whether or not the write before it | ||
| 796 | // owed a clear, and a refused frame is one whose geometry matches, which | ||
| 797 | // is one that owes no clear. | ||
| 798 | const box_at = fbs.getWritten().len; | ||
| 755 | // The cursor is hidden for as long as the popup owns the screen: a | 799 | // The cursor is hidden for as long as the popup owns the screen: a |
| 756 | // caret left blinking in a tile says the keys are going there. | 800 | // caret left blinking in a tile says the keys are going there. |
| 757 | wr.writeAll("\x1b[?25l") catch return; | 801 | wr.writeAll("\x1b[?25l") catch return; |
| @@ -762,15 +806,21 @@ pub fn paintPicker(w: Wall, sel: usize, view: PickerView, line: ?[]const u8) voi | |||
| 762 | pickerLine(wr, top + 1 + r, left, box_w, body.row(first + r)); | 806 | pickerLine(wr, top + 1 + r, left, box_w, body.row(first + r)); |
| 763 | pickerLine(wr, top + height - 1, left, box_w, foot); | 807 | pickerLine(wr, top + height - 1, left, box_w, foot); |
| 764 | } | 808 | } |
| 765 | const frame = fbs.getWritten(); | 809 | const written = fbs.getWritten(); |
| 766 | // Nothing changed, nothing written. The pollers report once a second | 810 | // Nothing changed, nothing written. The pollers report once a second |
| 767 | // per host and every one of them repaints this box; rewriting an | 811 | // per host and every one of them repaints this box; rewriting an |
| 768 | // identical screen at that rate is a terminal that never goes quiet. | 812 | // identical screen at that rate is a terminal that never goes quiet. |
| 769 | const stamp = std.hash.Wyhash.hash(0, frame); | 813 | const stamp = std.hash.Wyhash.hash(0, written[box_at..]); |
| 770 | if (stamp == shared.picker_stamp) return; | 814 | if (stamp == shared.picker_frame.stamp) return; |
| 771 | shared.picker_stamp = stamp; | 815 | shared.picker_frame = .{ |
| 816 | .stamp = stamp, | ||
| 817 | .top = top, | ||
| 818 | .left = left, | ||
| 819 | .rows = height, | ||
| 820 | .cols = box_w, | ||
| 821 | }; | ||
| 772 | shared.notice_len = 0; | 822 | shared.notice_len = 0; |
| 773 | proto.writeAllFd(shared.out_fd, frame) catch {}; | 823 | proto.writeAllFd(shared.out_fd, written) catch {}; |
| 774 | } | 824 | } |
| 775 | 825 | ||
| 776 | /// What the popup owes the screen on a pass with no keystroke: whether to | 826 | /// What the popup owes the screen on a pass with no keystroke: whether to |
| @@ -802,6 +852,15 @@ fn pickerLine(wr: anytype, row: u16, left: u16, w: u16, text: []const u8) void { | |||
| 802 | wr.writeAll("\x1b[0m") catch return; | 852 | wr.writeAll("\x1b[0m") catch return; |
| 803 | } | 853 | } |
| 804 | 854 | ||
| 855 | /// One row of a box that is no longer there. Span-bounded ECH over the | ||
| 856 | /// columns that box held and not one more, the same rule every tile clear | ||
| 857 | /// obeys: the rest of the row can hold a neighbouring tile's cells or a | ||
| 858 | /// rail, and a line-wide erase would take them with it. | ||
| 859 | fn clearSpan(wr: anytype, row: u16, left: u16, w: u16) void { | ||
| 860 | if (w == 0) return; | ||
| 861 | wr.print("\x1b[{d};{d}H\x1b[{d}X", .{ row + 1, left + 1, w }) catch return; | ||
| 862 | } | ||
| 863 | |||
| 805 | pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 { | 864 | pub fn peekNoticeLocked(shared: *const Shared, out: []u8) []const u8 { |
| 806 | const n = @min(shared.notice_len, out.len); | 865 | const n = @min(shared.notice_len, out.len); |
| 807 | @memcpy(out[0..n], shared.notice[0..n]); | 866 | @memcpy(out[0..n], shared.notice[0..n]); |
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -46,7 +46,7 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 46 | if (t.removed.load(.acquire)) return false; | 46 | if (t.removed.load(.acquire)) return false; |
| 47 | t.shared.paint_mu.lock(); | 47 | t.shared.paint_mu.lock(); |
| 48 | // Under the lock, not before: a pump that loses the race paints over the | 48 | // Under the lock, not before: a pump that loses the race paints over the |
| 49 | // popup, and `picker_stamp` suppresses the repaint that would repair it. | 49 | // popup, and `picker_frame` suppresses the repaint that would repair it. |
| 50 | if (wv.popupOpen(t.shared)) { | 50 | if (wv.popupOpen(t.shared)) { |
| 51 | t.shared.paint_mu.unlock(); | 51 | t.shared.paint_mu.unlock(); |
| 52 | return false; | 52 | return false; |
src/tui/wall_test_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -192,6 +192,118 @@ test "paintPicker: replayed into an engine, the popup covers its box and NOT one | |||
| 192 | try std.testing.expect(std.mem.indexOf(u8, sel_row, " 2> ") != null); | 192 | try std.testing.expect(std.mem.indexOf(u8, sel_row, " 2> ") != null); |
| 193 | } | 193 | } |
| 194 | 194 | ||
| 195 | test "paintPicker: replayed into an engine, a shorter box erases the rows the taller one left" { | ||
| 196 | const alloc = std.testing.allocator; | ||
| 197 | const cols: u16 = 160; | ||
| 198 | const rows: u16 = 20; | ||
| 199 | var screen = try WallScreen.init(alloc, cols, rows); | ||
| 200 | defer screen.deinit(); | ||
| 201 | // Off-origin in both axes, the sibling test's reason. FOUR hosts and | ||
| 202 | // then ONE session: the box goes from six rows to three, so the outer | ||
| 203 | // rows of the tall box are the ones nothing would repaint — tiles do | ||
| 204 | // not draw while the popup is up, and the close is what used to be the | ||
| 205 | // first thing to repair them. | ||
| 206 | const w: u16 = @intCast(wall_picker.picker_row_max); | ||
| 207 | const left: u16 = (cols - w) / 2; | ||
| 208 | const tall: u16 = 6; | ||
| 209 | const short: u16 = 3; | ||
| 210 | const tall_top: u16 = (rows - tall) / 2; | ||
| 211 | const short_top: u16 = (rows - short) / 2; | ||
| 212 | |||
| 213 | var bg: [cols]u8 = undefined; | ||
| 214 | @memset(&bg, '.'); | ||
| 215 | var r: u16 = 0; | ||
| 216 | while (r < rows) : (r += 1) { | ||
| 217 | var cup: [16]u8 = undefined; | ||
| 218 | screen.eng.feed(std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{r + 1}) catch unreachable); | ||
| 219 | screen.eng.feed(&bg); | ||
| 220 | } | ||
| 221 | screen.eng.feed("\x1b[H"); | ||
| 222 | |||
| 223 | var shared = Shared{ .out_fd = screen.w, .size = .{ .cols = cols, .rows = rows }, .is_tty = true }; | ||
| 224 | defer shared.tree.deinit(); | ||
| 225 | var table = [_]Host{ | ||
| 226 | fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"), | ||
| 227 | fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"), | ||
| 228 | fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"), | ||
| 229 | fixture.testHost(&shared, "--sock /tmp/d.sock", "/tmp/d.sock"), | ||
| 230 | }; | ||
| 231 | for (&table) |*h| { | ||
| 232 | fixture.setList(h, ""); | ||
| 233 | h.applied = true; | ||
| 234 | } | ||
| 235 | // The selected host's own list, so the descent is a real one: host 1, | ||
| 236 | // not host 0, and one session on it. | ||
| 237 | fixture.setList(&table[1], "only\n"); | ||
| 238 | |||
| 239 | wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{}, null); | ||
| 240 | screen.drain(); | ||
| 241 | wall_picker.paintPicker(fixture.hostWall(&shared, &table), 1, .{ .level = .sessions, .row = 0 }, null); | ||
| 242 | screen.drain(); | ||
| 243 | |||
| 244 | const dump = try screen.eng.dumpPlain(alloc); | ||
| 245 | defer alloc.free(dump); | ||
| 246 | var blank: [w]u8 = undefined; | ||
| 247 | @memset(&blank, ' '); | ||
| 248 | var i: u16 = 0; | ||
| 249 | while (i < rows) : (i += 1) { | ||
| 250 | const l = WallScreen.line(dump, i) orelse return error.NoSuchRow; | ||
| 251 | try std.testing.expectEqual(@as(usize, cols), l.len); | ||
| 252 | // Whatever happened inside the box, the wall either side of it is | ||
| 253 | // untouched: the clear is span-bounded ECH over the OLD box's own | ||
| 254 | // columns, never a line-wide erase that would reach a neighbouring | ||
| 255 | // tile's cells or a rail. | ||
| 256 | try std.testing.expectEqualStrings(bg[0..left], l[0..left]); | ||
| 257 | try std.testing.expectEqualStrings(bg[0..left], l[left + w ..]); | ||
| 258 | if (i >= short_top and i < short_top + short) { | ||
| 259 | try std.testing.expect(!std.mem.eql(u8, &blank, l[left..][0..w])); | ||
| 260 | } else if (i >= tall_top and i < tall_top + tall) { | ||
| 261 | try std.testing.expectEqualStrings(&blank, l[left..][0..w]); | ||
| 262 | } else { | ||
| 263 | try std.testing.expectEqualStrings(&bg, l); | ||
| 264 | } | ||
| 265 | } | ||
| 266 | } | ||
| 267 | |||
| 268 | test "paintPicker: a box that grows clears nothing, and a shrinking one clears at its own columns" { | ||
| 269 | const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 270 | defer std.posix.close(pipe[0]); | ||
| 271 | defer std.posix.close(pipe[1]); | ||
| 272 | var shared = Shared{ .out_fd = pipe[1], .size = .{ .cols = 160, .rows = 20 }, .is_tty = true }; | ||
| 273 | defer shared.tree.deinit(); | ||
| 274 | var table = [_]Host{ | ||
| 275 | fixture.testHost(&shared, "--sock /tmp/a.sock", "/tmp/a.sock"), | ||
| 276 | fixture.testHost(&shared, "--sock /tmp/b.sock", "/tmp/b.sock"), | ||
| 277 | fixture.testHost(&shared, "--sock /tmp/c.sock", "/tmp/c.sock"), | ||
| 278 | fixture.testHost(&shared, "--sock /tmp/d.sock", "/tmp/d.sock"), | ||
| 279 | }; | ||
| 280 | for (&table) |*h| { | ||
| 281 | fixture.setList(h, ""); | ||
| 282 | h.applied = true; | ||
| 283 | } | ||
| 284 | fixture.setList(&table[1], "only\n"); | ||
| 285 | var buf: [8192]u8 = undefined; | ||
| 286 | // The three-row session box first, then the six-row host box over it. | ||
| 287 | _ = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 0 }, &buf); | ||
| 288 | const grown = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{}, &buf); | ||
| 289 | try std.testing.expect(grown.len > 0); | ||
| 290 | // A box that covers every row of the one before it owes no clear, and | ||
| 291 | // an ECH here would erase a row this same frame is painting. | ||
| 292 | try std.testing.expect(std.mem.indexOf(u8, grown, "X") == null); | ||
| 293 | |||
| 294 | // ...and back down. Six rows at top (20-6)/2 = 7 and three at (20-3)/2 | ||
| 295 | // = 8, so rows 7, 11 and 12 (CUP rows 8, 12, 13) are the uncovered | ||
| 296 | // ones, and the box's left edge is (160-128)/2 = 16 (CUP column 17). | ||
| 297 | const shrunk = fixture.pickerFrameAt(&shared, pipe[0], &table, 1, .{ .level = .sessions, .row = 0 }, &buf); | ||
| 298 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[8;17H\x1b[128X") != null); | ||
| 299 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[12;17H\x1b[128X") != null); | ||
| 300 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[13;17H\x1b[128X") != null); | ||
| 301 | // The rows the new box writes itself are not cleared first. | ||
| 302 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[9;17H\x1b[128X") == null); | ||
| 303 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[10;17H\x1b[128X") == null); | ||
| 304 | try std.testing.expect(std.mem.indexOf(u8, shrunk, "\x1b[11;17H\x1b[128X") == null); | ||
| 305 | } | ||
| 306 | |||
| 195 | test "paintPicker: an unchanged frame is not written again" { | 307 | test "paintPicker: an unchanged frame is not written again" { |
| 196 | const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); | 308 | const pipe = try std.posix.pipe2(.{ .NONBLOCK = true }); |
| 197 | defer std.posix.close(pipe[0]); | 309 | defer std.posix.close(pipe[0]); |
| @@ -215,7 +327,7 @@ test "paintPicker: an unchanged frame is not written again" { | |||
| 215 | try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0); | 327 | try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0); |
| 216 | // ...and a cleared stamp is what a relayout leaves behind, so the box | 328 | // ...and a cleared stamp is what a relayout leaves behind, so the box |
| 217 | // goes back onto a screen that was wiped under it. | 329 | // goes back onto a screen that was wiped under it. |
| 218 | shared.picker_stamp = 0; | 330 | shared.picker_frame = .{}; |
| 219 | try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0); | 331 | try std.testing.expect(fixture.pickerFrame(&shared, pipe[0], &table, 1, &buf).len > 0); |
| 220 | } | 332 | } |
| 221 | 333 | ||
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -171,9 +171,10 @@ pub const Shared = struct { | |||
| 171 | /// the user is choosing from. Their replicas stay hot the whole time, | 171 | /// the user is choosing from. Their replicas stay hot the whole time, |
| 172 | /// and the close bumps `repaint_gen` so every rect comes back. | 172 | /// and the close bumps `repaint_gen` so every rect comes back. |
| 173 | picker_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 173 | picker_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| 174 | /// The popup's last frame, so an unchanged one is not rewritten. | 174 | /// The popup's last frame: its hash, so an unchanged one is not |
| 175 | /// Keyboard-thread only, like the picker itself. | 175 | /// rewritten, and the box it wrote, so a SHORTER one clears the rows it |
| 176 | picker_stamp: u64 = 0, | 176 | /// no longer covers. Keyboard-thread only, like the picker itself. |
| 177 | picker_frame: PickerFrame = .{}, | ||
| 177 | /// The picker's end two-step, per host and name: a second `x` on the SAME | 178 | /// The picker's end two-step, per host and name: a second `x` on the SAME |
| 178 | /// row inside the window forces, any other row is a first press. | 179 | /// row inside the window forces, any other row is a first press. |
| 179 | /// Keyboard-thread only, like the picker itself. | 180 | /// Keyboard-thread only, like the picker itself. |
| @@ -203,6 +204,22 @@ pub const Shared = struct { | |||
| 203 | } | 204 | } |
| 204 | }; | 205 | }; |
| 205 | 206 | ||
| 207 | /// What the popup left on the screen, as the next paint needs it: the hash | ||
| 208 | /// of the bytes, and the box those bytes covered. ONE value rather than two | ||
| 209 | /// fields, because the two facts are invalidated together — every place that | ||
| 210 | /// says the frame is no longer on the screen (a close, a relayout under it, | ||
| 211 | /// the ssh prompt box painting over it) clears this whole struct, and a site | ||
| 212 | /// that zeroed only the hash would leave the next paint erasing a rect that | ||
| 213 | /// now belongs to a tile. `stamp` 0 is "nothing of the box is on the | ||
| 214 | /// screen", and the rect means nothing then. | ||
| 215 | pub const PickerFrame = struct { | ||
| 216 | stamp: u64 = 0, | ||
| 217 | top: u16 = 0, | ||
| 218 | left: u16 = 0, | ||
| 219 | rows: u16 = 0, | ||
| 220 | cols: u16 = 0, | ||
| 221 | }; | ||
| 222 | |||
| 206 | /// The picker's `x`, which is a two-step the client only REMEMBERS: the | 223 | /// The picker's `x`, which is a two-step the client only REMEMBERS: the |
| 207 | /// daemon refuses the first press with a count, and the second inside the | 224 | /// daemon refuses the first press with a count, and the second inside the |
| 208 | /// window says the user meant it. Keyed by host AND name because the same | 225 | /// window says the user meant it. Keyed by host AND name because the same |
| @@ -281,8 +298,9 @@ fn closePicker( | |||
| 281 | shown.* = false; | 298 | shown.* = false; |
| 282 | w.shared.picker_open.store(false, .release); | 299 | w.shared.picker_open.store(false, .release); |
| 283 | // The screen under the box is about to be redrawn, so the next open owes | 300 | // The screen under the box is about to be redrawn, so the next open owes |
| 284 | // a paint however identical its rows are. | 301 | // a paint however identical its rows are, and owes no clear for a box |
| 285 | w.shared.picker_stamp = 0; | 302 | // the redraw has already taken off the screen. |
| 303 | w.shared.picker_frame = .{}; | ||
| 286 | if (birth_at) |at| | 304 | if (birth_at) |at| |
| 287 | focusAnswer(w, true, at) | 305 | focusAnswer(w, true, at) |
| 288 | else | 306 | else |
| @@ -700,7 +718,7 @@ pub fn tileBanner(t: *Tile, text: []const u8) void { | |||
| 700 | defer t.shared.paint_mu.unlock(); | 718 | defer t.shared.paint_mu.unlock(); |
| 701 | // Under the lock, for `tilePaintBegin`'s reason: read before it, a | 719 | // Under the lock, for `tilePaintBegin`'s reason: read before it, a |
| 702 | // banner that lost the race for `paint_mu` lands on a box row, and | 720 | // banner that lost the race for `paint_mu` lands on a box row, and |
| 703 | // `picker_stamp` suppresses the repaint that would repair it. | 721 | // `picker_frame` suppresses the repaint that would repair it. |
| 704 | if (popupOpen(t.shared)) return; | 722 | if (popupOpen(t.shared)) return; |
| 705 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.labelRows(), t.rect.left); | 723 | paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.labelRows(), t.rect.left); |
| 706 | } | 724 | } |
| @@ -2224,7 +2242,7 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 2224 | picker_row = @min(picker_row, wall_picker.sessionCount(w.hosts, picker_sel) -| 1); | 2242 | picker_row = @min(picker_row, wall_picker.sessionCount(w.hosts, picker_sel) -| 1); |
| 2225 | var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; | 2243 | var foot_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined; |
| 2226 | const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or | 2244 | const repair = wall_picker.pickerRepaint(&foot_buf, &input.prefix, picker_opened or host_news or |
| 2227 | winch or fds[1].revents != 0 or shared.picker_stamp == 0); | 2245 | winch or fds[1].revents != 0 or shared.picker_frame.stamp == 0); |
| 2228 | if (repair.due) | 2246 | if (repair.due) |
| 2229 | wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, repair.line); | 2247 | wall_picker.paintPicker(w, picker_sel, .{ .level = input.prefix.pick_level, .row = picker_row }, repair.line); |
| 2230 | if (fds[0].revents == 0) continue; | 2248 | if (fds[0].revents == 0) continue; |