a73x

42e13926

fix: picker repaint clears the rows a shorter box no longer covers

a73x   2026-09-04 15:08

Commit message
fix: picker repaint clears the rows a shorter box no longer covers

The popup centres a box of `body.n + 2` rows and writes exactly that box.
Tiles do not paint while `Shared.picker_open` is set, so nothing else was
going to touch the screen, and nothing remembered where the last box had
been. A change to a SMALLER box therefore left the rows the new one does
not cover still on the terminal until the close: four host rows, then
Enter into a host with one session, and three of the four stayed with the
three-row session box painted over the middle of them. An `x` that
shortens the host list did the same.

`paintPicker` now remembers the box it wrote and, before writing the next
one, erases the rows of that box the new one does not cover. The clear is
span-bounded ECH over the OLD box's own columns, the rule every tile clear
obeys, so it cannot reach a neighbouring tile's cells or a rail; it is
preceded by an SGR reset because ECH erases with the current rendition and
every box row wears reverse video.

The geometry rides WITH the hash, in one `Shared.PickerFrame` rather than
beside it as a second field, because the two facts are invalidated
together. Three places say the frame is no longer on the screen — the
close, a relayout under the box, the ssh prompt box painting over it — and
one that zeroed the hash while leaving the geometry behind would have the
next paint ECH over a rect that now belongs to a tile. One assignment
clears both, so there is no way to forget half of it.

The stamp is taken over the box bytes alone, so an unchanged box is still
refused whether or not the write before it owed a clear, and a refused
frame is by definition one whose geometry matches and so owes no clear.

The tests replay two paints into a real engine and judge the grid: four
hosts then one session, asserting the uncovered rows are blank across the
box's columns and untouched outside them, plus a byte-level pin that a box
which GROWS emits no ECH at all and a shrinking one clears at columns
17..144 and not on the rows it is about to paint itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

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;