a73x

11682d56

feat: painters address a column offset and erase only their span

a73x   2026-08-24 18:06

Commit message
feat: painters address a column offset and erase only their span

Every renderer in paint.zig gains col_off and view_cols beside row_off.
CUP addresses (row_off + r, col_off + 1) and whole-line erases (\x1b[2K)
become erase-N-characters (\x1b[{view_cols}X) so a pane's repaint cannot
blank a beside-neighbour. The owns_screen full-clear path (\x1b[H\x1b[2J)
is untouched. bannerText right-aligns inside the pane's own width.

Core gains col_off beside row_off; the wall pump sets it in a later task,
plain client and muxa leave it 0. screenCursor adds col_off to x.

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

src/interact.zig
Old New
@@ -804,6 +804,7 @@ fn paintOverlay(
804 base: Engine.CursorPos, 804 base: Engine.CursorPos,
805 tty: proto.Size, 805 tty: proto.Size,
806 row_off: u16, 806 row_off: u16,
807 col_off: u16,
807 out_fd: std.posix.fd_t, 808 out_fd: std.posix.fd_t,
808 ) void { 809 ) void {
809 if (!overlay.visible() or overlay.pendingCount() == 0) return; 810 if (!overlay.visible() or overlay.pendingCount() == 0) return;
@@ -820,7 +821,7 @@ fn paintOverlay(
820 // daemon's own row content replaces it. 821 // daemon's own row content replaces it.
821 const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{ 822 const s = std.fmt.bufPrint(&b, "\x1b[{d};{d}H\x1b[4m{c}\x1b[0m", .{
822 cell.row + row_off + 1, 823 cell.row + row_off + 1,
823 cell.col + 1, 824 cell.col + col_off + 1,
824 cell.ch, 825 cell.ch,
825 }) catch continue; 826 }) catch continue;
826 paint.appendSlice(alloc, s) catch return; 827 paint.appendSlice(alloc, s) catch return;
@@ -836,7 +837,7 @@ fn paintOverlay(
836 var cbuf: [32]u8 = undefined; 837 var cbuf: [32]u8 = undefined;
837 const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{ 838 const tail = std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H" ++ paint_mod.sync_end, .{
838 cur.y + row_off + 1, 839 cur.y + row_off + 1,
839 cur.x + 1, 840 cur.x + col_off + 1,
840 }) catch return; 841 }) catch return;
841 paint.appendSlice(alloc, tail) catch return; 842 paint.appendSlice(alloc, tail) catch return;
842 proto.writeAllFd(out_fd, paint.items) catch {}; 843 proto.writeAllFd(out_fd, paint.items) catch {};
@@ -851,6 +852,7 @@ fn offerKeystroke(
851 chunk: []const u8, 852 chunk: []const u8,
852 tty: proto.Size, 853 tty: proto.Size,
853 row_off: u16, 854 row_off: u16,
855 col_off: u16,
854 out_fd: std.posix.fd_t, 856 out_fd: std.posix.fd_t,
855 ) void { 857 ) void {
856 if (chunk.len != 1) { 858 if (chunk.len != 1) {
@@ -873,7 +875,7 @@ fn offerKeystroke(
873 .now_ms = std.time.milliTimestamp(), 875 .now_ms = std.time.milliTimestamp(),
874 }); 876 });
875 switch (out) { 877 switch (out) {
876 .display => paintOverlay(alloc, overlay, base, tty, row_off, out_fd), 878 .display => paintOverlay(alloc, overlay, base, tty, row_off, col_off, out_fd),
877 // Queued but unearned, or refused outright: either way nothing is 879 // Queued but unearned, or refused outright: either way nothing is
878 // drawn, which is the entire safety property. 880 // drawn, which is the entire safety property.
879 .hidden, .suppressed => {}, 881 .hidden, .suppressed => {},
@@ -1143,6 +1145,10 @@ pub const Core = struct {
1143 /// inside its own rect, not the screen's origin; the plain client and 1145 /// inside its own rect, not the screen's origin; the plain client and
1144 /// muxa leave it 0 and paint from row 1 as before. 1146 /// muxa leave it 0 and paint from row 1 as before.
1145 row_off: u16 = 0, 1147 row_off: u16 = 0,
1148 /// The terminal column this tile's rect starts at. A beside-neighbour
1149 /// would be blanked by a whole-line clear, so every painter addresses
1150 /// its own column origin. The plain client and muxa leave it 0.
1151 col_off: u16 = 0,
1146 /// Whether this Core may clear the whole screen. A plain client or a 1152 /// Whether this Core may clear the whole screen. A plain client or a
1147 /// one-tile wall owns every row; a tile among neighbours does not. Set 1153 /// one-tile wall owns every row; a tile among neighbours does not. Set
1148 /// by the driver that knows the layout, not inferred from `row_off`. 1154 /// by the driver that knows the layout, not inferred from `row_off`.
@@ -1371,8 +1377,8 @@ pub const Core = struct {
1371 if (!self.beginPaint()) return; 1377 if (!self.beginPaint()) return;
1372 defer self.endPaint(); 1378 defer self.endPaint();
1373 const hl = self.highlight(); 1379 const hl = self.highlight();
1374 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.owns_screen, self.out_fd); 1380 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.col_off, self.size.cols, self.owns_screen, self.out_fd);
1375 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); 1381 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.col_off, self.out_fd);
1376 } 1382 }
1377 1383
1378 /// The terminal cursor the painters end on — the same 1384 /// The terminal cursor the painters end on — the same
@@ -1380,7 +1386,7 @@ pub const Core = struct {
1380 /// replica cursor is the approximation. 1386 /// replica cursor is the approximation.
1381 pub fn screenCursor(self: *Core) Engine.CursorPos { 1387 pub fn screenCursor(self: *Core) Engine.CursorPos {
1382 const c = paint_mod.clampCursor(self.rep.eng.cursorPos(), self.size); 1388 const c = paint_mod.clampCursor(self.rep.eng.cursorPos(), self.size);
1383 return .{ .x = c.x, .y = c.y + self.row_off }; 1389 return .{ .x = c.x + self.col_off, .y = c.y + self.row_off };
1384 } 1390 }
1385 1391
1386 /// Repaint the rows a drag report changed, and only those. 1392 /// Repaint the rows a drag report changed, and only those.
@@ -1433,9 +1439,11 @@ pub const Core = struct {
1433 hl.sink(), 1439 hl.sink(),
1434 rows.items, 1440 rows.items,
1435 self.row_off, 1441 self.row_off,
1442 self.col_off,
1443 self.size.cols,
1436 self.out_fd, 1444 self.out_fd,
1437 ); 1445 );
1438 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); 1446 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.col_off, self.out_fd);
1439 } 1447 }
1440 1448
1441 /// The whole screen from the replica, with nothing put back on top — 1449 /// The whole screen from the replica, with nothing put back on top —
@@ -1444,7 +1452,7 @@ pub const Core = struct {
1444 if (!self.beginPaint()) return; 1452 if (!self.beginPaint()) return;
1445 defer self.endPaint(); 1453 defer self.endPaint();
1446 const hl = self.highlight(); 1454 const hl = self.highlight();
1447 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.owns_screen, self.out_fd); 1455 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.col_off, self.size.cols, self.owns_screen, self.out_fd);
1448 } 1456 }
1449 1457
1450 /// A one-line marker in the corner, painted over by the next full 1458 /// A one-line marker in the corner, painted over by the next full
@@ -1452,7 +1460,7 @@ pub const Core = struct {
1452 pub fn banner(self: *Core, text: []const u8) void { 1460 pub fn banner(self: *Core, text: []const u8) void {
1453 if (!self.is_tty or !self.beginPaint()) return; 1461 if (!self.is_tty or !self.beginPaint()) return;
1454 defer self.endPaint(); 1462 defer self.endPaint();
1455 paint_mod.paintBanner(self.out_fd, self.size, text, self.row_off); 1463 paint_mod.paintBanner(self.out_fd, self.size.cols, text, self.row_off, self.col_off);
1456 } 1464 }
1457 1465
1458 /// The only thing that can retire a prediction the application 1466 /// The only thing that can retire a prediction the application
@@ -1516,16 +1524,16 @@ pub const Core = struct {
1516 // between them would put the two halves of this frame 1524 // between them would put the two halves of this frame
1517 // on two different screens), and the sink is not reentrant. 1525 // on two different screens), and the sink is not reentrant.
1518 const hl = self.highlight(); 1526 const hl = self.highlight();
1519 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.owns_screen, self.out_fd); 1527 try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.row_off, self.col_off, self.size.cols, self.owns_screen, self.out_fd);
1520 self.repaint_after_resync = false; 1528 self.repaint_after_resync = false;
1521 } else { 1529 } else {
1522 const hl = self.highlight(); 1530 const hl = self.highlight();
1523 try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.eng, self.size, hl.sink(), self.row_off, self.out_fd); 1531 try paint_mod.paintDeltaClipped(self.alloc, payload, self.rep.eng, self.size, hl.sink(), self.row_off, self.col_off, self.size.cols, self.out_fd);
1524 } 1532 }
1525 // Last, and after either paint: the rows the daemon just sent 1533 // Last, and after either paint: the rows the daemon just sent
1526 // have overwritten anything drawn on them, including predictions 1534 // have overwritten anything drawn on them, including predictions
1527 // that are still outstanding. 1535 // that are still outstanding.
1528 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.out_fd); 1536 paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.row_off, self.col_off, self.out_fd);
1529 } 1537 }
1530 } 1538 }
1531 1539
@@ -1553,7 +1561,7 @@ pub const Core = struct {
1553 if (self.scroll_rows == 0 or payload.len < 6) return .skip; 1561 if (self.scroll_rows == 0 or payload.len < 6) return .skip;
1554 if (!self.beginPaint()) return .carry_on; 1562 if (!self.beginPaint()) return .carry_on;
1555 defer self.endPaint(); 1563 defer self.endPaint();
1556 try paint_mod.renderScrollback(self.alloc, payload[6..], self.size, self.row_off, self.owns_screen, self.out_fd); 1564 try paint_mod.renderScrollback(self.alloc, payload[6..], self.row_off, self.col_off, self.size.cols, self.owns_screen, self.out_fd);
1557 return .carry_on; 1565 return .carry_on;
1558 } 1566 }
1559 1567
@@ -1874,7 +1882,7 @@ pub const Core = struct {
1874 // whichever session holds the terminal now. 1882 // whichever session holds the terminal now.
1875 if (self.beginPaint()) { 1883 if (self.beginPaint()) {
1876 defer self.endPaint(); 1884 defer self.endPaint();
1877 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.row_off, self.out_fd); 1885 offerKeystroke(self.alloc, &self.overlay, self.rep.eng, keys, self.size, self.row_off, self.col_off, self.out_fd);
1878 } 1886 }
1879 transport.writeFrame(.input, keys) catch return .lost; 1887 transport.writeFrame(.input, keys) catch return .lost;
1880 // These keystrokes are lost with the transport, by the same 1888 // These keystrokes are lost with the transport, by the same
@@ -2496,8 +2504,8 @@ test "prediction: prev_ch is read at the predicted cursor, not the replica's" {
2496 ov.noteSeq(1); 2504 ov.noteSeq(1);
2497 2505
2498 const tty = proto.Size{ .cols = 80, .rows = 24 }; 2506 const tty = proto.Size{ .cols = 80, .rows = 24 };
2499 offerKeystroke(alloc, &ov, replica, "d", tty, 0, null_fd); 2507 offerKeystroke(alloc, &ov, replica, "d", tty, 0, 0, null_fd);
2500 offerKeystroke(alloc, &ov, replica, "e", tty, 0, null_fd); 2508 offerKeystroke(alloc, &ov, replica, "e", tty, 0, 0, null_fd);
2501 2509
2502 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); 2510 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
2503 try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch); 2511 try std.testing.expectEqual(@as(u8, 'X'), ov.pendingAt(0).prev_ch);
@@ -2542,7 +2550,7 @@ test "prediction: a burst advances the predicted cursor one cell per keystroke"
2542 ov.setMode(.{ .icanon = true, .echo = true }); 2550 ov.setMode(.{ .icanon = true, .echo = true });
2543 2551
2544 const tty = proto.Size{ .cols = 80, .rows = 24 }; 2552 const tty = proto.Size{ .cols = 80, .rows = 24 };
2545 for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, 0, null_fd); 2553 for ("hello") |ch| offerKeystroke(alloc, &ov, replica, &.{ch}, tty, 0, 0, null_fd);
2546 2554
2547 // The replica's own cursor has not moved — the daemon has answered 2555 // The replica's own cursor has not moved — the daemon has answered
2548 // nothing — so every one of these came from the overlay. 2556 // nothing — so every one of these came from the overlay.
@@ -2573,7 +2581,7 @@ test "prediction paints underlined, and parks the cursor past what it drew" {
2573 defer ov.deinit(); 2581 defer ov.deinit();
2574 ov.setMode(.{ .icanon = true, .echo = true }); 2582 ov.setMode(.{ .icanon = true, .echo = true });
2575 2583
2576 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 0, p[1]); 2584 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 0, 0, p[1]);
2577 std.posix.close(p[1]); 2585 std.posix.close(p[1]);
2578 2586
2579 var out: std.ArrayList(u8) = .empty; 2587 var out: std.ArrayList(u8) = .empty;
@@ -2609,7 +2617,7 @@ test "a keystroke's prediction paints at the tile's row offset" {
2609 defer ov.deinit(); 2617 defer ov.deinit();
2610 ov.setMode(.{ .icanon = true, .echo = true }); 2618 ov.setMode(.{ .icanon = true, .echo = true });
2611 2619
2612 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 5, p[1]); 2620 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 5, 0, p[1]);
2613 std.posix.close(p[1]); 2621 std.posix.close(p[1]);
2614 2622
2615 var rbuf: [4096]u8 = undefined; 2623 var rbuf: [4096]u8 = undefined;
@@ -2630,7 +2638,7 @@ test "prediction: nothing is drawn for a context that has not earned it" {
2630 defer ov.deinit(); 2638 defer ov.deinit();
2631 ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned 2639 ov.setMode(.{ .icanon = false, .echo = false }); // raw: display is earned
2632 2640
2633 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 0, p[1]); 2641 offerKeystroke(alloc, &ov, replica, "z", .{ .cols = 80, .rows = 24 }, 0, 0, p[1]);
2634 std.posix.close(p[1]); 2642 std.posix.close(p[1]);
2635 2643
2636 // Queued, so it can be judged and earn the next one its visibility... 2644 // Queued, so it can be judged and earn the next one its visibility...
@@ -2658,12 +2666,12 @@ test "prediction: a chunk that is not one printable byte is never speculated abo
2658 2666
2659 // An arrow key: three bytes, and predicting its lead byte would paint an 2667 // An arrow key: three bytes, and predicting its lead byte would paint an
2660 // escape character on the screen. 2668 // escape character on the screen.
2661 offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, 0, null_fd); 2669 offerKeystroke(alloc, &ov, replica, "\x1b[A", tty, 0, 0, null_fd);
2662 // A multi-byte character, whose display width we do not know. 2670 // A multi-byte character, whose display width we do not know.
2663 offerKeystroke(alloc, &ov, replica, "é", tty, 0, null_fd); 2671 offerKeystroke(alloc, &ov, replica, "é", tty, 0, 0, null_fd);
2664 // And a lone control byte, which goes down the single-byte path and is 2672 // And a lone control byte, which goes down the single-byte path and is
2665 // refused there. 2673 // refused there.
2666 offerKeystroke(alloc, &ov, replica, "\r", tty, 0, null_fd); 2674 offerKeystroke(alloc, &ov, replica, "\r", tty, 0, 0, null_fd);
2667 2675
2668 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); 2676 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
2669 try std.testing.expectEqual(@as(u64, 0), ov.counters.made); 2677 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
@@ -2674,7 +2682,7 @@ test "prediction: a chunk that is not one printable byte is never speculated abo
2674 // guard is the only thing refusing it — and it is refused, because a 2682 // guard is the only thing refusing it — and it is refused, because a
2675 // paste can carry newlines and bracketed-paste markers that are not one 2683 // paste can carry newlines and bracketed-paste markers that are not one
2676 // cell's worth of change each. 2684 // cell's worth of change each.
2677 offerKeystroke(alloc, &ov, replica, "abc", tty, 0, null_fd); 2685 offerKeystroke(alloc, &ov, replica, "abc", tty, 0, 0, null_fd);
2678 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount()); 2686 try std.testing.expectEqual(@as(usize, 0), ov.pendingCount());
2679 try std.testing.expectEqual(@as(u64, 0), ov.counters.made); 2687 try std.testing.expectEqual(@as(u64, 0), ov.counters.made);
2680 // Counted like every other refusal. The decision is the client's — the 2688 // Counted like every other refusal. The decision is the client's — the
@@ -2697,8 +2705,8 @@ test "prediction: a repaint never reveals what was never shown" {
2697 defer ov.deinit(); 2705 defer ov.deinit();
2698 ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident 2706 ov.setMode(.{ .icanon = false, .echo = false }); // raw: unconfident
2699 const tty = proto.Size{ .cols = 80, .rows = 24 }; 2707 const tty = proto.Size{ .cols = 80, .rows = 24 };
2700 offerKeystroke(alloc, &ov, replica, "a", tty, 0, null_fd); 2708 offerKeystroke(alloc, &ov, replica, "a", tty, 0, 0, null_fd);
2701 offerKeystroke(alloc, &ov, replica, "b", tty, 0, null_fd); 2709 offerKeystroke(alloc, &ov, replica, "b", tty, 0, 0, null_fd);
2702 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount()); 2710 try std.testing.expectEqual(@as(usize, 2), ov.pendingCount());
2703 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); 2711 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
2704 2712
@@ -2707,7 +2715,7 @@ test "prediction: a repaint never reveals what was never shown" {
2707 // repaint is a second, quieter chance to show a prediction that was 2715 // repaint is a second, quieter chance to show a prediction that was
2708 // never displayed in the first place — so it asks the same question the 2716 // never displayed in the first place — so it asks the same question the
2709 // keystroke path did, and gets the same answer. 2717 // keystroke path did, and gets the same answer.
2710 paintOverlay(alloc, &ov, replica.cursorPos(), tty, 0, p[1]); 2718 paintOverlay(alloc, &ov, replica.cursorPos(), tty, 0, 0, p[1]);
2711 std.posix.close(p[1]); 2719 std.posix.close(p[1]);
2712 2720
2713 var rbuf: [64]u8 = undefined; 2721 var rbuf: [64]u8 = undefined;
@@ -2735,7 +2743,7 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
2735 const later = std.time.milliTimestamp() + 100; 2743 const later = std.time.milliTimestamp() + 100;
2736 2744
2737 // One confirm banked, one short of promotion. 2745 // One confirm banked, one short of promotion.
2738 offerKeystroke(alloc, &ov, replica, "a", tty, 0, null_fd); 2746 offerKeystroke(alloc, &ov, replica, "a", tty, 0, 0, null_fd);
2739 replica.feed("a"); 2747 replica.feed("a");
2740 try std.testing.expectEqual( 2748 try std.testing.expectEqual(
2741 predict.Verdict.confirmed, 2749 predict.Verdict.confirmed,
@@ -2744,8 +2752,8 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
2744 2752
2745 // Two more typed while still invisible, and the promoting confirmation 2753 // Two more typed while still invisible, and the promoting confirmation
2746 // lands while the second of them is outstanding. 2754 // lands while the second of them is outstanding.
2747 offerKeystroke(alloc, &ov, replica, "b", tty, 0, null_fd); 2755 offerKeystroke(alloc, &ov, replica, "b", tty, 0, 0, null_fd);
2748 offerKeystroke(alloc, &ov, replica, "c", tty, 0, null_fd); 2756 offerKeystroke(alloc, &ov, replica, "c", tty, 0, 0, null_fd);
2749 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed); 2757 try std.testing.expectEqual(@as(u64, 0), ov.counters.displayed);
2750 replica.feed("b"); 2758 replica.feed("b");
2751 try std.testing.expectEqual( 2759 try std.testing.expectEqual(
@@ -2761,7 +2769,7 @@ test "prediction: a promotion mid-burst counts the cell it makes visible" {
2761 // The post-frame re-lay is where it reaches the screen — nobody typed 2769 // The post-frame re-lay is where it reaches the screen — nobody typed
2762 // anything to make that happen, so counting only at prediction time 2770 // anything to make that happen, so counting only at prediction time
2763 // would lose it. 2771 // would lose it.
2764 paintOverlay(alloc, &ov, replica.cursorPos(), tty, 0, p[1]); 2772 paintOverlay(alloc, &ov, replica.cursorPos(), tty, 0, 0, p[1]);
2765 std.posix.close(p[1]); 2773 std.posix.close(p[1]);
2766 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed); 2774 try std.testing.expectEqual(@as(u64, 1), ov.counters.displayed);
2767 2775
@@ -3220,7 +3228,7 @@ test "interact: a drag at the focus tile inverts what it crossed, and a click do
3220 // why the inversion count above is the whole story. 3228 // why the inversion count above is the whole story.
3221 try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") == null); 3229 try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") == null);
3222 try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") == null); 3230 try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") == null);
3223 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[2K")); 3231 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));
3224 3232
3225 // The release does not move the highlight, so it does not repaint one. 3233 // The release does not move the highlight, so it does not repaint one.
3226 _ = try core.forward(&tr, "\x1b[<0;7;2m"); 3234 _ = try core.forward(&tr, "\x1b[<0;7;2m");
@@ -3430,7 +3438,7 @@ test "interact: the anchor is an absolute row, and the paint converts it back" {
3430 // row literally would invert a row 500 below the one pointed at, or 3438 // row literally would invert a row 500 below the one pointed at, or
3431 // none at all. 3439 // none at all.
3432 const painted = drainPipe(p[0], &buf); 3440 const painted = drainPipe(p[0], &buf);
3433 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[2K\x1b[0mro\x1b[3G\x1b[0m\x1b[7m") != null); 3441 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[20X\x1b[0mro\x1b[3G\x1b[0m\x1b[7m") != null);
3434 } 3442 }
3435 3443
3436 test "interact: a drag repaint puts the predictions back on top" { 3444 test "interact: a drag repaint puts the predictions back on top" {
@@ -3534,8 +3542,8 @@ test "interact: a drag repaints the rows it changed, not the screen" {
3534 // No screen clear: that is what opens a full repaint. 3542 // No screen clear: that is what opens a full repaint.
3535 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null); 3543 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2J") == null);
3536 // One row addressed and cleared, and it is the row under the pointer. 3544 // One row addressed and cleared, and it is the row under the pointer.
3537 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[2K")); 3545 try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[20X"));
3538 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[2K") != null); 3546 try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[20X") != null);
3539 } 3547 }
3540 3548
3541 test "interact: a drag edge inside a wide cell repaints the row unshifted" { 3549 test "interact: a drag edge inside a wide cell repaints the row unshifted" {
src/paint.zig
Old New
@@ -52,6 +52,8 @@ pub fn renderClipped(
52 tty: proto.Size, 52 tty: proto.Size,
53 hl: Highlight, 53 hl: Highlight,
54 row_off: u16, 54 row_off: u16,
55 col_off: u16,
56 view_cols: u16,
55 owns_screen: bool, 57 owns_screen: bool,
56 out_fd: std.posix.fd_t, 58 out_fd: std.posix.fd_t,
57 ) !void { 59 ) !void {
@@ -64,11 +66,13 @@ pub fn renderClipped(
64 66
65 const grid_rows: u16 = @intCast(replica.term.rows); 67 const grid_rows: u16 = @intCast(replica.term.rows);
66 const limit = @min(grid_rows, tty.rows); 68 const limit = @min(grid_rows, tty.rows);
69 var ech_buf: [16]u8 = undefined;
70 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{view_cols}) catch "";
67 var y: u16 = 0; 71 var y: u16 = 0;
68 while (y < limit) : (y += 1) { 72 while (y < limit) : (y += 1) {
69 var cup: [20]u8 = undefined; 73 var cup: [24]u8 = undefined;
70 const clear: []const u8 = if (owns_screen) "" else "\x1b[2K"; 74 const clear: []const u8 = if (owns_screen) "" else ech;
71 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H{s}", .{ y + row_off + 1, clear })); 75 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ y + row_off + 1, col_off + 1, clear }));
72 const row = try dumpRow(alloc, replica, y, hl); 76 const row = try dumpRow(alloc, replica, y, hl);
73 defer alloc.free(row); 77 defer alloc.free(row);
74 try paint.appendSlice(alloc, row); 78 try paint.appendSlice(alloc, row);
@@ -76,7 +80,7 @@ pub fn renderClipped(
76 80
77 const cur = clampCursor(replica.cursorPos(), tty); 81 const cur = clampCursor(replica.cursorPos(), tty);
78 var cbuf: [16]u8 = undefined; 82 var cbuf: [16]u8 = undefined;
79 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + 1 })); 83 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + col_off + 1 }));
80 try paint.appendSlice(alloc, sync_end); 84 try paint.appendSlice(alloc, sync_end);
81 try proto.writeAllFd(out_fd, paint.items); 85 try proto.writeAllFd(out_fd, paint.items);
82 } 86 }
@@ -90,6 +94,8 @@ pub fn renderRowsClipped(
90 hl: Highlight, 94 hl: Highlight,
91 rows: []const u16, 95 rows: []const u16,
92 row_off: u16, 96 row_off: u16,
97 col_off: u16,
98 view_cols: u16,
93 out_fd: std.posix.fd_t, 99 out_fd: std.posix.fd_t,
94 ) !void { 100 ) !void {
95 if (rows.len == 0) return; 101 if (rows.len == 0) return;
@@ -99,12 +105,14 @@ pub fn renderRowsClipped(
99 105
100 const grid_rows: u16 = @intCast(replica.term.rows); 106 const grid_rows: u16 = @intCast(replica.term.rows);
101 const limit = @min(grid_rows, tty.rows); 107 const limit = @min(grid_rows, tty.rows);
108 var ech_buf: [16]u8 = undefined;
109 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{view_cols}) catch "";
102 for (rows) |y| { 110 for (rows) |y| {
103 if (y >= limit) continue; 111 if (y >= limit) continue;
104 // \x1b[2K per row: unlike a full render this never clears the 112 // ECH per row: unlike a full render this never clears the
105 // screen, so a shorter row would leave the old one showing. 113 // screen, so a shorter row would leave the old one showing.
106 var cup: [16]u8 = undefined; 114 var cup: [24]u8 = undefined;
107 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, y) + row_off + 1})); 115 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, y) + row_off + 1, col_off + 1, ech }));
108 const row = try dumpRow(alloc, replica, y, hl); 116 const row = try dumpRow(alloc, replica, y, hl);
109 defer alloc.free(row); 117 defer alloc.free(row);
110 try paint.appendSlice(alloc, row); 118 try paint.appendSlice(alloc, row);
@@ -112,7 +120,7 @@ pub fn renderRowsClipped(
112 120
113 const cur = clampCursor(replica.cursorPos(), tty); 121 const cur = clampCursor(replica.cursorPos(), tty);
114 var cbuf: [16]u8 = undefined; 122 var cbuf: [16]u8 = undefined;
115 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + 1 })); 123 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + col_off + 1 }));
116 try paint.appendSlice(alloc, sync_end); 124 try paint.appendSlice(alloc, sync_end);
117 try proto.writeAllFd(out_fd, paint.items); 125 try proto.writeAllFd(out_fd, paint.items);
118 } 126 }
@@ -135,6 +143,8 @@ pub fn paintDeltaClipped(
135 tty: proto.Size, 143 tty: proto.Size,
136 hl: Highlight, 144 hl: Highlight,
137 row_off: u16, 145 row_off: u16,
146 col_off: u16,
147 view_cols: u16,
138 out_fd: std.posix.fd_t, 148 out_fd: std.posix.fd_t,
139 ) !void { 149 ) !void {
140 const hdr = try proto.readDeltaHeader(payload); 150 const hdr = try proto.readDeltaHeader(payload);
@@ -144,11 +154,13 @@ pub fn paintDeltaClipped(
144 154
145 const grid_cols: u16 = @intCast(replica.term.cols); 155 const grid_cols: u16 = @intCast(replica.term.cols);
146 const grid_rows: u16 = @intCast(replica.term.rows); 156 const grid_rows: u16 = @intCast(replica.term.rows);
157 var ech_buf: [16]u8 = undefined;
158 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{view_cols}) catch "";
147 var it = proto.deltaRowIterator(payload); 159 var it = proto.deltaRowIterator(payload);
148 while (try it.next()) |row| { 160 while (try it.next()) |row| {
149 if (row.row >= tty.rows) continue; 161 if (row.row >= tty.rows) continue;
150 var cup: [16]u8 = undefined; 162 var cup: [24]u8 = undefined;
151 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, row.row) + row_off + 1})); 163 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row.row) + row_off + 1, col_off + 1, ech }));
152 // A row under the selection is redrawn from the replica, which has 164 // A row under the selection is redrawn from the replica, which has
153 // already been fed this very frame — same content, inversion on 165 // already been fed this very frame — same content, inversion on
154 // top. Every other row keeps the daemon's bytes verbatim, and that 166 // top. Every other row keeps the daemon's bytes verbatim, and that
@@ -165,7 +177,7 @@ pub fn paintDeltaClipped(
165 177
166 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, tty); 178 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, tty);
167 var cbuf: [16]u8 = undefined; 179 var cbuf: [16]u8 = undefined;
168 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + 1 })); 180 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + row_off + 1, cur.x + col_off + 1 }));
169 try paint.appendSlice(alloc, sync_end); 181 try paint.appendSlice(alloc, sync_end);
170 try proto.writeAllFd(out_fd, paint.items); 182 try proto.writeAllFd(out_fd, paint.items);
171 } 183 }
@@ -174,19 +186,20 @@ pub fn paintDeltaClipped(
174 /// viewing history, `[reconnecting]` when the transport is being rebuilt. 186 /// viewing history, `[reconnecting]` when the transport is being rebuilt.
175 /// Text only, so a caller mid-repaint can append it into its own paint 187 /// Text only, so a caller mid-repaint can append it into its own paint
176 /// buffer and keep the whole screen one synchronized update. 188 /// buffer and keep the whole screen one synchronized update.
177 fn bannerText(buf: []u8, size: proto.Size, label: []const u8, row_off: u16) ![]const u8 { 189 fn bannerText(buf: []u8, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) ![]const u8 {
178 // Columns are 1-based; a label wider than the whole tty would otherwise 190 // Right-aligned inside the pane: the label's left column is the pane's
179 // address column 0, which terminals only silently forgive. 191 // left edge plus the pane's width less the label, so at col_off 0 and
180 const col = @max(1, size.cols -| @as(u16, @intCast(label.len))); 192 // view_cols 80 the column is 66 — matching the plain client's edge.
193 const col = @max(col_off + 1, col_off +| view_cols -| @as(u16, @intCast(label.len)));
181 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label }); 194 return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label });
182 } 195 }
183 196
184 /// Drop a banner onto a screen that is otherwise staying put — the cursor is 197 /// Drop a banner onto a screen that is otherwise staying put — the cursor is
185 /// saved and restored around it, so the shell's cursor does not visibly jump 198 /// saved and restored around it, so the shell's cursor does not visibly jump
186 /// to the corner. Best-effort: a status marker is never worth failing over. 199 /// to the corner. Best-effort: a status marker is never worth failing over.
187 pub fn paintBanner(out_fd: std.posix.fd_t, size: proto.Size, label: []const u8, row_off: u16) void { 200 pub fn paintBanner(out_fd: std.posix.fd_t, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) void {
188 var buf: [96]u8 = undefined; 201 var buf: [96]u8 = undefined;
189 const mark = bannerText(&buf, size, label, row_off) catch return; 202 const mark = bannerText(&buf, view_cols, label, row_off, col_off) catch return;
190 var paint: [128]u8 = undefined; 203 var paint: [128]u8 = undefined;
191 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return; 204 const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return;
192 proto.writeAllFd(out_fd, text) catch {}; 205 proto.writeAllFd(out_fd, text) catch {};
@@ -196,8 +209,9 @@ pub fn paintBanner(out_fd: std.posix.fd_t, size: proto.Size, label: []const u8,
196 pub fn renderScrollback( 209 pub fn renderScrollback(
197 alloc: std.mem.Allocator, 210 alloc: std.mem.Allocator,
198 rows_vt: []const u8, 211 rows_vt: []const u8,
199 size: proto.Size,
200 row_off: u16, 212 row_off: u16,
213 col_off: u16,
214 view_cols: u16,
201 owns_screen: bool, 215 owns_screen: bool,
202 out_fd: std.posix.fd_t, 216 out_fd: std.posix.fd_t,
203 ) !void { 217 ) !void {
@@ -212,6 +226,8 @@ pub fn renderScrollback(
212 try paint.appendSlice(alloc, rows_vt); 226 try paint.appendSlice(alloc, rows_vt);
213 } else { 227 } else {
214 try paint.appendSlice(alloc, sync_begin); 228 try paint.appendSlice(alloc, sync_begin);
229 var ech_buf: [16]u8 = undefined;
230 const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{view_cols}) catch "";
215 var rest = rows_vt; 231 var rest = rows_vt;
216 var n: u16 = 0; 232 var n: u16 = 0;
217 while (rest.len > 0) : (n += 1) { 233 while (rest.len > 0) : (n += 1) {
@@ -221,14 +237,14 @@ pub fn renderScrollback(
221 var seg = rest[0..nl]; 237 var seg = rest[0..nl];
222 if (seg.len > 0 and seg[seg.len - 1] == '\r') seg = seg[0 .. seg.len - 1]; 238 if (seg.len > 0 and seg[seg.len - 1] == '\r') seg = seg[0 .. seg.len - 1];
223 var cup: [24]u8 = undefined; 239 var cup: [24]u8 = undefined;
224 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, n) + row_off + 1})); 240 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, n) + row_off + 1, col_off + 1, ech }));
225 try paint.appendSlice(alloc, seg); 241 try paint.appendSlice(alloc, seg);
226 if (nl == rest.len) break; 242 if (nl == rest.len) break;
227 rest = rest[nl + 1 ..]; 243 rest = rest[nl + 1 ..];
228 } 244 }
229 } 245 }
230 var mark_buf: [96]u8 = undefined; 246 var mark_buf: [96]u8 = undefined;
231 try paint.appendSlice(alloc, try bannerText(&mark_buf, size, "[scroll]", row_off)); 247 try paint.appendSlice(alloc, try bannerText(&mark_buf, view_cols, "[scroll]", row_off, col_off));
232 // The open above is the shared half; only this commit is deliberately 248 // The open above is the shared half; only this commit is deliberately
233 // unpaired, and it must not be "simplified" into sync_end: it closes the 249 // unpaired, and it must not be "simplified" into sync_end: it closes the
234 // update WITHOUT the cursor-show, because a cursor parked in a history 250 // update WITHOUT the cursor-show, because a cursor parked in a history
@@ -264,7 +280,7 @@ test "renderClipped inverts the highlighted rows and leaves the rest alone" {
264 const pipe = try std.posix.pipe(); 280 const pipe = try std.posix.pipe();
265 defer std.posix.close(pipe[0]); 281 defer std.posix.close(pipe[0]);
266 var h: TestHighlight = .{}; 282 var h: TestHighlight = .{};
267 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, h.hl(), 0, true, pipe[1]); 283 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, h.hl(), 0, 0, 80, true, pipe[1]);
268 std.posix.close(pipe[1]); 284 std.posix.close(pipe[1]);
269 285
270 var out: [8192]u8 = undefined; 286 var out: [8192]u8 = undefined;
@@ -289,7 +305,7 @@ test "renderClipped inverts the highlighted rows and leaves the rest alone" {
289 test "paintBanner parks an inverse label top-right without moving the cursor" { 305 test "paintBanner parks an inverse label top-right without moving the cursor" {
290 const pipe = try std.posix.pipe(); 306 const pipe = try std.posix.pipe();
291 defer std.posix.close(pipe[0]); 307 defer std.posix.close(pipe[0]);
292 paintBanner(pipe[1], .{ .cols = 80, .rows = 24 }, "[reconnecting]", 0); 308 paintBanner(pipe[1], 80, "[reconnecting]", 0, 0);
293 std.posix.close(pipe[1]); 309 std.posix.close(pipe[1]);
294 310
295 var out: [256]u8 = undefined; 311 var out: [256]u8 = undefined;
@@ -309,7 +325,7 @@ test "paintBanner on a narrow tty clamps to column 1 instead of underflowing" {
309 defer std.posix.close(pipe[0]); 325 defer std.posix.close(pipe[0]);
310 // Label longer than the whole terminal: the saturating subtraction must 326 // Label longer than the whole terminal: the saturating subtraction must
311 // land on column 1, never wrap around to a huge column. 327 // land on column 1, never wrap around to a huge column.
312 paintBanner(pipe[1], .{ .cols = 4, .rows = 24 }, "[reconnecting]", 0); 328 paintBanner(pipe[1], 4, "[reconnecting]", 0, 0);
313 std.posix.close(pipe[1]); 329 std.posix.close(pipe[1]);
314 330
315 var out: [256]u8 = undefined; 331 var out: [256]u8 = undefined;
@@ -322,7 +338,7 @@ test "paintBanner parks at row_off+1" {
322 // status marker in its own top-right corner, not the screen's. 338 // status marker in its own top-right corner, not the screen's.
323 const pipe = try std.posix.pipe(); 339 const pipe = try std.posix.pipe();
324 defer std.posix.close(pipe[0]); 340 defer std.posix.close(pipe[0]);
325 paintBanner(pipe[1], .{ .cols = 80, .rows = 24 }, "[reconnecting]", 2); 341 paintBanner(pipe[1], 80, "[reconnecting]", 2, 0);
326 std.posix.close(pipe[1]); 342 std.posix.close(pipe[1]);
327 343
328 var out: [256]u8 = undefined; 344 var out: [256]u8 = undefined;
@@ -341,7 +357,7 @@ test "renderScrollback paints rows with an inverse scroll marker" {
341 const alloc = std.testing.allocator; 357 const alloc = std.testing.allocator;
342 const pipe = try std.posix.pipe(); 358 const pipe = try std.posix.pipe();
343 defer std.posix.close(pipe[0]); 359 defer std.posix.close(pipe[0]);
344 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .cols = 80, .rows = 24 }, 0, true, pipe[1]); 360 try renderScrollback(alloc, "old-row-1\r\nold-row-2", 0, 0, 80, true, pipe[1]);
345 std.posix.close(pipe[1]); 361 std.posix.close(pipe[1]);
346 362
347 var out: [4096]u8 = undefined; 363 var out: [4096]u8 = undefined;
@@ -357,7 +373,7 @@ test "renderScrollback at row 0 is byte-identical to the plain client" {
357 const alloc = std.testing.allocator; 373 const alloc = std.testing.allocator;
358 const pipe = try std.posix.pipe(); 374 const pipe = try std.posix.pipe();
359 defer std.posix.close(pipe[0]); 375 defer std.posix.close(pipe[0]);
360 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .cols = 80, .rows = 24 }, 0, true, pipe[1]); 376 try renderScrollback(alloc, "old-row-1\r\nold-row-2", 0, 0, 80, true, pipe[1]);
361 std.posix.close(pipe[1]); 377 std.posix.close(pipe[1]);
362 378
363 var out: [4096]u8 = undefined; 379 var out: [4096]u8 = undefined;
@@ -382,7 +398,7 @@ test "renderScrollback at a row_off owns only its sub-rect" {
382 // CRLF-separated rows, the shape `dumpScrollback` composes: the split 398 // CRLF-separated rows, the shape `dumpScrollback` composes: the split
383 // this paint makes on the row breaks. The leading reset is the 399 // this paint makes on the row breaks. The leading reset is the
384 // formatter's own prefix on the first row. 400 // formatter's own prefix on the first row.
385 try renderScrollback(alloc, "\x1b[0mold-row-1\r\nold-row-2", size, row_off, false, pipe[1]); 401 try renderScrollback(alloc, "\x1b[0mold-row-1\r\nold-row-2", row_off, 0, 80, false, pipe[1]);
386 std.posix.close(pipe[1]); 402 std.posix.close(pipe[1]);
387 403
388 var out: [4096]u8 = undefined; 404 var out: [4096]u8 = undefined;
@@ -394,7 +410,7 @@ test "renderScrollback at a row_off owns only its sub-rect" {
394 // pattern `renderClipped` uses for a live tile at an offset. 410 // pattern `renderClipped` uses for a live tile at an offset.
395 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2J") == null); 411 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2J") == null);
396 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[H") == null); 412 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[H") == null);
397 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2K") != null); 413 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[80X") != null);
398 414
399 // Every CUP this paint emits lands inside [row_off+1, row_off+rows]: 415 // Every CUP this paint emits lands inside [row_off+1, row_off+rows]:
400 // a row outside that band belongs to another tile. 416 // a row outside that band belongs to another tile.
@@ -420,8 +436,8 @@ test "renderScrollback at a row_off owns only its sub-rect" {
420 // and each history row is addressed at row_off + n + 1. 436 // and each history row is addressed at row_off + n + 1.
421 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;72H\x1b[7m[scroll]\x1b[0m") != null); 437 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;72H\x1b[7m[scroll]\x1b[0m") != null);
422 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;72H") == null); 438 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;72H") == null);
423 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;1H\x1b[2K\x1b[0mold-row-1") != null); 439 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;1H\x1b[80X\x1b[0mold-row-1") != null);
424 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7;1H\x1b[2Kold-row-2") != null); 440 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7;1H\x1b[80Xold-row-2") != null);
425 // The unpaired close is unchanged: scroll mode hides the cursor. 441 // The unpaired close is unchanged: scroll mode hides the cursor.
426 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[?2026l")); 442 try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[?2026l"));
427 } 443 }
@@ -438,7 +454,7 @@ test "renderClipped paints only rows that fit and clamps the cursor" {
438 const pipe = try std.posix.pipe(); 454 const pipe = try std.posix.pipe();
439 defer std.posix.close(pipe[0]); 455 defer std.posix.close(pipe[0]);
440 // Local tty is smaller than the 100x30 grid. 456 // Local tty is smaller than the 100x30 grid.
441 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, true, pipe[1]); 457 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, 0, 80, true, pipe[1]);
442 std.posix.close(pipe[1]); 458 std.posix.close(pipe[1]);
443 459
444 var out: std.ArrayList(u8) = .empty; 460 var out: std.ArrayList(u8) = .empty;
@@ -477,7 +493,7 @@ test "renderClipped stops at the grid when the tty is the larger one" {
477 493
478 const pipe = try std.posix.pipe(); 494 const pipe = try std.posix.pipe();
479 defer std.posix.close(pipe[0]); 495 defer std.posix.close(pipe[0]);
480 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, true, pipe[1]); 496 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, 0, 80, true, pipe[1]);
481 std.posix.close(pipe[1]); 497 std.posix.close(pipe[1]);
482 498
483 var out: std.ArrayList(u8) = .empty; 499 var out: std.ArrayList(u8) = .empty;
@@ -506,7 +522,7 @@ test "renderClipped paints at a row offset" {
506 522
507 const pipe = try std.posix.pipe(); 523 const pipe = try std.posix.pipe();
508 defer std.posix.close(pipe[0]); 524 defer std.posix.close(pipe[0]);
509 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 2, false, pipe[1]); 525 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 2, 0, 80, false, pipe[1]);
510 std.posix.close(pipe[1]); 526 std.posix.close(pipe[1]);
511 527
512 var out: [8192]u8 = undefined; 528 var out: [8192]u8 = undefined;
@@ -538,12 +554,12 @@ test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
538 554
539 const pipe = try std.posix.pipe(); 555 const pipe = try std.posix.pipe();
540 defer std.posix.close(pipe[0]); 556 defer std.posix.close(pipe[0]);
541 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, pipe[1]); 557 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, 0, 80, pipe[1]);
542 std.posix.close(pipe[1]); 558 std.posix.close(pipe[1]);
543 var out: [4096]u8 = undefined; 559 var out: [4096]u8 = undefined;
544 const n = try std.posix.read(pipe[0], &out); 560 const n = try std.posix.read(pipe[0], &out);
545 561
546 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[2K\x1b[0mfits") != null); 562 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[80X\x1b[0mfits") != null);
547 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null); 563 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
548 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped 564 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
549 } 565 }
@@ -571,7 +587,7 @@ test "paintDeltaClipped re-inverts a delta row the selection covers" {
571 const pipe = try std.posix.pipe(); 587 const pipe = try std.posix.pipe();
572 defer std.posix.close(pipe[0]); 588 defer std.posix.close(pipe[0]);
573 var h: TestHighlight = .{}; 589 var h: TestHighlight = .{};
574 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, h.hl(), 0, pipe[1]); 590 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, h.hl(), 0, 0, 80, pipe[1]);
575 std.posix.close(pipe[1]); 591 std.posix.close(pipe[1]);
576 var out: [8192]u8 = undefined; 592 var out: [8192]u8 = undefined;
577 const n = try std.posix.read(pipe[0], &out); 593 const n = try std.posix.read(pipe[0], &out);
@@ -587,7 +603,7 @@ test "paintDeltaClipped re-inverts a delta row the selection covers" {
587 // The uncovered row is still the daemon's own bytes, verbatim: this 603 // The uncovered row is still the daemon's own bytes, verbatim: this
588 // path exists to keep a held selection off the full-repaint arm, so it 604 // path exists to keep a held selection off the full-repaint arm, so it
589 // must not turn every other row into a replica dump either. 605 // must not turn every other row into a replica dump either.
590 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[2K\x1b[0mrow-zero") != null); 606 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[80X\x1b[0mrow-zero") != null);
591 } 607 }
592 608
593 test "paintDeltaClipped brackets the whole paint in one synchronized update" { 609 test "paintDeltaClipped brackets the whole paint in one synchronized update" {
@@ -614,7 +630,7 @@ test "paintDeltaClipped brackets the whole paint in one synchronized update" {
614 630
615 const pipe = try std.posix.pipe(); 631 const pipe = try std.posix.pipe();
616 defer std.posix.close(pipe[0]); 632 defer std.posix.close(pipe[0]);
617 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, pipe[1]); 633 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, 0, 80, pipe[1]);
618 std.posix.close(pipe[1]); 634 std.posix.close(pipe[1]);
619 var out: [4096]u8 = undefined; 635 var out: [4096]u8 = undefined;
620 const n = try std.posix.read(pipe[0], &out); 636 const n = try std.posix.read(pipe[0], &out);
@@ -649,15 +665,15 @@ test "paintDeltaClipped offsets every delta row" {
649 665
650 const pipe = try std.posix.pipe(); 666 const pipe = try std.posix.pipe();
651 defer std.posix.close(pipe[0]); 667 defer std.posix.close(pipe[0]);
652 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 2, pipe[1]); 668 try paintDeltaClipped(alloc, payload.items, replica, .{ .cols = 80, .rows = 24 }, .{}, 2, 0, 80, pipe[1]);
653 std.posix.close(pipe[1]); 669 std.posix.close(pipe[1]);
654 var out: [8192]u8 = undefined; 670 var out: [8192]u8 = undefined;
655 const n = try std.posix.read(pipe[0], &out); 671 const n = try std.posix.read(pipe[0], &out);
656 const text = out[0..n]; 672 const text = out[0..n];
657 // Both delta rows shift by row_off: grid row 0 -> terminal row 3, grid 673 // Both delta rows shift by row_off: grid row 0 -> terminal row 3, grid
658 // row 1 -> terminal row 4. 674 // row 1 -> terminal row 4.
659 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[3;1H\x1b[2K") != null); 675 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[3;1H\x1b[80X") != null);
660 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[2K") != null); 676 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[80X") != null);
661 // The un-offset addresses never appear. 677 // The un-offset addresses never appear.
662 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H") == null); 678 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H") == null);
663 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2;1H") == null); 679 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2;1H") == null);
@@ -665,6 +681,26 @@ test "paintDeltaClipped offsets every delta row" {
665 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[3;6H") != null); 681 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[3;6H") != null);
666 } 682 }
667 683
684 test "a pane off the left edge paints inside its own span" {
685 // col_off 40, 39 cols: CUP lands at column 41 and the erase covers 39
686 // cells — the bytes a beside-neighbour's survival depends on.
687 const alloc = std.testing.allocator;
688 var replica = try Engine.init(alloc, .{ .cols = 40, .rows = 4 });
689 defer replica.deinit();
690 replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
691
692 const pipe = try std.posix.pipe();
693 defer std.posix.close(pipe[0]);
694 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, 0, 40, 39, false, pipe[1]);
695 std.posix.close(pipe[1]);
696
697 var out: [8192]u8 = undefined;
698 const n = try std.posix.read(pipe[0], &out);
699 const text = out[0..n];
700 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;41H\x1b[39X") != null);
701 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2K") == null);
702 }
703
668 // Forces semantic analysis of every pub decl under `zig build test`, so an 704 // Forces semantic analysis of every pub decl under `zig build test`, so an
669 // unreferenced decl must at least compile (the silent-module-loss hazard, 705 // unreferenced decl must at least compile (the silent-module-loss hazard,
670 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 706 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
src/wallview.zig
Old New
@@ -526,7 +526,7 @@ fn wallBanner(shared: *Shared, text: []const u8, row_off: u16) void {
526 if (!shared.is_tty) return; 526 if (!shared.is_tty) return;
527 shared.paint_mu.lock(); 527 shared.paint_mu.lock();
528 defer shared.paint_mu.unlock(); 528 defer shared.paint_mu.unlock();
529 paint.paintBanner(shared.out_fd, shared.size, text, row_off); 529 paint.paintBanner(shared.out_fd, shared.size.cols, text, row_off, 0);
530 } 530 }
531 531
532 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the 532 /// Under `paint_mu`: a clear spliced into a 64 KiB OSC 52 write eats the
test/e2e.sh
Old New
@@ -2579,9 +2579,9 @@ grep -q "$(printf '\033\[4mz')" "$OUT.p1.early" || {
2579 echo "--- early snapshot ---"; cat -v "$OUT.p1.early"; exit 1; 2579 echo "--- early snapshot ---"; cat -v "$OUT.p1.early"; exit 1;
2580 } 2580 }
2581 # ...and the daemon's own answer was NOT there yet, which is what makes the 2581 # ...and the daemon's own answer was NOT there yet, which is what makes the
2582 # line above mean anything. A delta paints a row with EL(2) before its 2582 # line above mean anything. A delta paints a row with ECH before its
2583 # content; the prediction never does. 2583 # content; the prediction never does.
2584 if grep -q "$(printf '\033\[2K\033\[0mz')" "$OUT.p1.early"; then 2584 if grep -q "$(printf '\033\[80X\033\[0mz')" "$OUT.p1.early"; then
2585 echo "e2e FAIL: the echo arrived within 450ms, so the path is not $((PDELAY * 2))ms;" 2585 echo "e2e FAIL: the echo arrived within 450ms, so the path is not $((PDELAY * 2))ms;"
2586 echo " either DELAY_MS was ignored (default is 150) or delaypipe is not delaying" 2586 echo " either DELAY_MS was ignored (default is 150) or delaypipe is not delaying"
2587 exit 1 2587 exit 1
@@ -3177,7 +3177,7 @@ expect 60 10000
3177 expect reconnecting 20000 3177 expect reconnecting 20000
3178 expect \x1b[0m100 20000 3178 expect \x1b[0m100 20000
3179 send t 3179 send t
3180 expect \x1b[2K\x1b[0mt 20000 3180 expect \x1b[80X\x1b[0mt 20000
3181 settle 500 15000 3181 settle 500 15000
3182 send \x1c\x1c 3182 send \x1c\x1c
3183 waitexit 15000 3183 waitexit 15000
@@ -3208,7 +3208,7 @@ TP1PID=$!
3208 # the last needle is STRUCTURAL, and it has to be. In canonical mode the 3208 # the last needle is STRUCTURAL, and it has to be. In canonical mode the
3209 # only thing a single keystroke produces is the line discipline's echo of 3209 # only thing a single keystroke produces is the line discipline's echo of
3210 # the very glyph the prediction just painted, so no content needle can 3210 # the very glyph the prediction just painted, so no content needle can
3211 # tell the daemon's answer from the client's own guess. `\x1b[2K` can: 3211 # tell the daemon's answer from the client's own guess. `\x1b[80X` can:
3212 # it is emitted by exactly one paint path in the client, the one that 3212 # it is emitted by exactly one paint path in the client, the one that
3213 # paints a DELTA (client.zig, paintDeltaClipped), so its arrival means a 3213 # paints a DELTA (client.zig, paintDeltaClipped), so its arrival means a
3214 # frame came back. The prediction paints the same glyph underlined and 3214 # frame came back. The prediction paints the same glyph underlined and
@@ -5620,7 +5620,7 @@ ok "a client with no terminal of its own forwards SGR-shaped bytes untouched"
5620 # type mark a with zs-one (a is focused from the first byte) 5620 # type mark a with zs-one (a is focused from the first byte)
5621 # Ctrl-\ n move the focus to tile 2 (session b) 5621 # Ctrl-\ n move the focus to tile 2 (session b)
5622 # type mark b with zs-two 5622 # type mark b with zs-two
5623 # Ctrl-\ l skip back to the last tile focused, which is a 5623 # Ctrl-\ 1 focus tile 1 (session a)
5624 # type mark a again with zs-three 5624 # type mark a again with zs-three
5625 # Ctrl-\ d leave 5625 # Ctrl-\ d leave
5626 # 5626 #
@@ -5666,7 +5666,7 @@ settle 700 20000
5666 send printf 'zs-%s\\n' two\n 5666 send printf 'zs-%s\\n' two\n
5667 expect zs-two 15000 5667 expect zs-two 15000
5668 settle 400 15000 5668 settle 400 15000
5669 send \x1cl 5669 send \x1c1
5670 settle 700 20000 5670 settle 700 20000
5671 send printf 'zs-%s\\n' three\n 5671 send printf 'zs-%s\\n' three\n
5672 expect zs-three 15000 5672 expect zs-three 15000
@@ -5679,7 +5679,7 @@ set -e
5679 unwatch_clients "$OUT.zswatch" 5679 unwatch_clients "$OUT.zswatch"
5680 ZSATT_AFTER=$(attaches_now "$SOCK37") 5680 ZSATT_AFTER=$(attaches_now "$SOCK37")
5681 [ "$RC" -eq 0 ] || { 5681 [ "$RC" -eq 0 ] || {
5682 echo "e2e FAIL: focus skip: ptyclient leg exited $RC (did \\x1cn and \\x1cl move the focus?):" 5682 echo "e2e FAIL: focus skip: ptyclient leg exited $RC (did \\x1cn and \\x1c1 move the focus?):"
5683 cat "$OUT.zspc"; exit 1; } 5683 cat "$OUT.zspc"; exit 1; }
5684 # Where each marker landed. `muxa capture` reads the session's own grid, so 5684 # Where each marker landed. `muxa capture` reads the session's own grid, so
5685 # none of this can be an echo of what this script typed at a terminal. 5685 # none of this can be an echo of what this script typed at a terminal.