f04db7c5
feat: a drag over a zoomed tile paints what it crossed
a73x 2026-08-22 13:13
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -232,7 +232,7 @@ const mod_table = [_]ModSpec{ | |||
| 232 | // must be drivable by anything holding a transport — the CLI client | 232 | // must be drivable by anything holding a transport — the CLI client |
| 233 | // today, a wall tile from the wall's phase-3 convergence on — and it | 233 | // today, a wall tile from the wall's phase-3 convergence on — and it |
| 234 | // names no transport type for exactly that reason (see its header). | 234 | // names no transport type for exactly that reason (see its header). |
| 235 | .{ .name = "interact", .path = "src/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint" } }, | 235 | .{ .name = "interact", .path = "src/interact.zig", .layer = 2, .imports = &.{ "engine", "protocol", "replica", "predict", "client_core", "paint", "select" } }, |
| 236 | // quic and quic_server both: the listener it owns, and the vocabulary | 236 | // quic and quic_server both: the listener it owns, and the vocabulary |
| 237 | // it names directly (the key it loads, the idle default it falls back | 237 | // it names directly (the key it loads, the idle default it falls back |
| 238 | // to). xdg is for endpoint_req's lazy bind — the default key path, | 238 | // to). xdg is for endpoint_req's lazy bind — the default key path, |
src/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -41,6 +41,41 @@ const client_core = @import("client_core"); | |||
| 41 | // Named `paint_mod` because paintOverlay holds a local ArrayList called | 41 | // Named `paint_mod` because paintOverlay holds a local ArrayList called |
| 42 | // `paint`, which a container-level `paint` would collide with. | 42 | // `paint`, which a container-level `paint` would collide with. |
| 43 | const paint_mod = @import("paint"); | 43 | const paint_mod = @import("paint"); |
| 44 | const select = @import("select"); | ||
| 45 | |||
| 46 | /// What `select` answers, in the shape `paint` asks for. | ||
| 47 | /// | ||
| 48 | /// The two are siblings at layer 1 and may not import one another, so | ||
| 49 | /// somebody above both has to join them; this is the smaller half of that | ||
| 50 | /// join, and the wall reuses it rather than keeping a second copy | ||
| 51 | /// (`wallview.paintStripe`). | ||
| 52 | /// | ||
| 53 | /// It converts as well as adapts. `select` speaks ABSOLUTE rows — | ||
| 54 | /// counted from the oldest row the daemon retains — and a painter speaks | ||
| 55 | /// grid rows, so the history count of the frame being painted is what | ||
| 56 | /// turns one into the other. Reading it from the same replica the paint | ||
| 57 | /// is walking is the point: a highlight resolved against one frame's | ||
| 58 | /// history and painted onto another's is a highlight on the wrong lines. | ||
| 59 | pub const Highlight = struct { | ||
| 60 | drag: *const select.Drag, | ||
| 61 | /// Which tile the drag belongs to. Zero for a zoomed `Core`, which is | ||
| 62 | /// the only session on its screen; the wall passes the real index. | ||
| 63 | tile: usize, | ||
| 64 | history_rows: u32, | ||
| 65 | |||
| 66 | /// A paint with no selection on it asks nothing per row and emits | ||
| 67 | /// exactly the bytes it emitted before this feature existed. | ||
| 68 | pub fn sink(self: *const Highlight) paint_mod.Highlight { | ||
| 69 | if (self.drag.range() == null) return .{}; | ||
| 70 | return .{ .ctx = @constCast(self), .span = &spanOf }; | ||
| 71 | } | ||
| 72 | |||
| 73 | fn spanOf(ctx: ?*anyopaque, row: u16, cols: u16) ?paint_mod.Span { | ||
| 74 | const self: *const Highlight = @ptrCast(@alignCast(ctx.?)); | ||
| 75 | const s = self.drag.span(self.tile, self.history_rows + row, cols) orelse return null; | ||
| 76 | return .{ .from = s.from, .to = s.to }; | ||
| 77 | } | ||
| 78 | }; | ||
| 44 | 79 | ||
| 45 | // Ctrl-\. In a live session it is the command prefix (see PrefixFilter); | 80 | // Ctrl-\. In a live session it is the command prefix (see PrefixFilter); |
| 46 | // while dialling or reconnecting there is no session to command, so a bare | 81 | // while dialling or reconnecting there is no session to command, so a bare |
| @@ -1185,6 +1220,11 @@ pub const Core = struct { | |||
| 1185 | /// Mouse reports arrive in the same reads as keystrokes; this splits | 1220 | /// Mouse reports arrive in the same reads as keystrokes; this splits |
| 1186 | /// them back out, across read boundaries. | 1221 | /// them back out, across read boundaries. |
| 1187 | mouse: MouseFilter = .{}, | 1222 | mouse: MouseFilter = .{}, |
| 1223 | /// The mux selection over THIS session, while this Core holds the | ||
| 1224 | /// terminal. A demoted wall tile has `claim == .none` and so never | ||
| 1225 | /// sees a report at all: unzoomed, the drag belongs to the wall's own | ||
| 1226 | /// keyboard loop, over its own hit-test. | ||
| 1227 | drag: select.Drag = .{}, | ||
| 1188 | /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N | 1228 | /// Scroll mode: 0 = live; N = viewing the screenful whose bottom sits N |
| 1189 | /// rows above live. Rows rather than pages because the wheel moves by a | 1229 | /// rows above live. Rows rather than pages because the wheel moves by a |
| 1190 | /// few lines and the keys move by a screen — one unit that expresses | 1230 | /// few lines and the keys move by a screen — one unit that expresses |
| @@ -1388,6 +1428,16 @@ pub const Core = struct { | |||
| 1388 | return self.rep.eng; | 1428 | return self.rep.eng; |
| 1389 | } | 1429 | } |
| 1390 | 1430 | ||
| 1431 | /// The selection as this frame's paint must see it. | ||
| 1432 | /// | ||
| 1433 | /// Returned BY VALUE and kept on the caller's stack for the length of | ||
| 1434 | /// the paint: `sink()` hands the painter a pointer to it, and the | ||
| 1435 | /// history count it carries is read from the replica the paint is | ||
| 1436 | /// about to walk. | ||
| 1437 | fn highlight(self: *Core) Highlight { | ||
| 1438 | return .{ .drag = &self.drag, .tile = 0, .history_rows = self.rep.history_rows }; | ||
| 1439 | } | ||
| 1440 | |||
| 1391 | /// May this Core paint now, and hold that answer until `endPaint`? | 1441 | /// May this Core paint now, and hold that answer until `endPaint`? |
| 1392 | /// Every write of GRID bytes goes through this pair; see `Sink`. | 1442 | /// Every write of GRID bytes goes through this pair; see `Sink`. |
| 1393 | fn beginPaint(self: *Core) bool { | 1443 | fn beginPaint(self: *Core) bool { |
| @@ -1410,7 +1460,8 @@ pub const Core = struct { | |||
| 1410 | pub fn repaint(self: *Core) !void { | 1460 | pub fn repaint(self: *Core) !void { |
| 1411 | if (!self.beginPaint()) return; | 1461 | if (!self.beginPaint()) return; |
| 1412 | defer self.endPaint(); | 1462 | defer self.endPaint(); |
| 1413 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | 1463 | const hl = self.highlight(); |
| 1464 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd); | ||
| 1414 | paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd); | 1465 | paintOverlay(self.alloc, &self.overlay, self.rep.eng.cursorPos(), self.size, self.out_fd); |
| 1415 | } | 1466 | } |
| 1416 | 1467 | ||
| @@ -1419,7 +1470,8 @@ pub const Core = struct { | |||
| 1419 | fn paintFull(self: *Core) !void { | 1470 | fn paintFull(self: *Core) !void { |
| 1420 | if (!self.beginPaint()) return; | 1471 | if (!self.beginPaint()) return; |
| 1421 | defer self.endPaint(); | 1472 | defer self.endPaint(); |
| 1422 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | 1473 | const hl = self.highlight(); |
| 1474 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd); | ||
| 1423 | } | 1475 | } |
| 1424 | 1476 | ||
| 1425 | /// A one-line marker in the corner, painted over by the next full | 1477 | /// A one-line marker in the corner, painted over by the next full |
| @@ -1496,7 +1548,13 @@ pub const Core = struct { | |||
| 1496 | // on scroll exit comes from it. | 1548 | // on scroll exit comes from it. |
| 1497 | if (self.scroll_rows == 0 and self.beginPaint()) { | 1549 | if (self.scroll_rows == 0 and self.beginPaint()) { |
| 1498 | defer self.endPaint(); | 1550 | defer self.endPaint(); |
| 1499 | if (self.repaint_after_resync or verdict == .contradicted) { | 1551 | // A held selection takes the full-repaint arm too. A delta |
| 1552 | // paints the rows the daemon sent AS THE DAEMON SENT THEM, so | ||
| 1553 | // a row under the highlight would come back un-inverted and | ||
| 1554 | // the selection would develop holes wherever the session was | ||
| 1555 | // still writing. Affordable for the same reason the arm above | ||
| 1556 | // is: a selection is a thing a hand holds for a moment. | ||
| 1557 | if (self.repaint_after_resync or verdict == .contradicted or self.drag.range() != null) { | ||
| 1500 | // First frame back after a reconnect. The daemon sent only | 1558 | // First frame back after a reconnect. The daemon sent only |
| 1501 | // what changed, which is correct — but the screen still | 1559 | // what changed, which is correct — but the screen still |
| 1502 | // carries the banner, so repaint the whole thing from the | 1560 | // carries the banner, so repaint the whole thing from the |
| @@ -1513,7 +1571,8 @@ pub const Core = struct { | |||
| 1513 | // the overlay below it are ONE hold of the sink (a zoom that | 1571 | // the overlay below it are ONE hold of the sink (a zoom that |
| 1514 | // moved between them would put the two halves of this frame | 1572 | // moved between them would put the two halves of this frame |
| 1515 | // on two different screens), and the sink is not reentrant. | 1573 | // on two different screens), and the sink is not reentrant. |
| 1516 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, self.out_fd); | 1574 | const hl = self.highlight(); |
| 1575 | try paint_mod.renderClipped(self.alloc, self.rep.eng, self.size, hl.sink(), self.out_fd); | ||
| 1517 | self.repaint_after_resync = false; | 1576 | self.repaint_after_resync = false; |
| 1518 | } else { | 1577 | } else { |
| 1519 | try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd); | 1578 | try paint_mod.paintDeltaClipped(self.alloc, payload, self.size, self.out_fd); |
| @@ -1746,13 +1805,31 @@ pub const Core = struct { | |||
| 1746 | // with the escape deleted. | 1805 | // with the escape deleted. |
| 1747 | var keys = typed; | 1806 | var keys = typed; |
| 1748 | var wheel: i32 = 0; | 1807 | var wheel: i32 = 0; |
| 1808 | // What is on screen before this read's reports are read, so the | ||
| 1809 | // repaint below is spent only on a highlight that actually moved. | ||
| 1810 | // A drag reports on every cell the pointer crosses. | ||
| 1811 | const was = self.drag.range(); | ||
| 1749 | if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { | 1812 | if (self.claim == .none or self.semantic.terminal_modes.appMouse()) { |
| 1750 | self.mouse.reset(); | 1813 | self.mouse.reset(); |
| 1814 | // The application asked for the mouse, so the application gets | ||
| 1815 | // the drag and there is no mux selection here — the same rule | ||
| 1816 | // the mode mirror already follows, and tmux's on | ||
| 1817 | // `mouse_any_flag`. Shift+drag stays the terminal's own escape | ||
| 1818 | // hatch. Cleared rather than merely ignored: a selection made | ||
| 1819 | // before the application asked would otherwise sit inverted on | ||
| 1820 | // a screen that no longer belongs to it. | ||
| 1821 | self.drag.clear(); | ||
| 1751 | } else { | 1822 | } else { |
| 1752 | const m = self.mouse.feed(typed, &self.mouse_buf); | 1823 | const m = self.mouse.feed(typed, &self.mouse_buf); |
| 1753 | keys = m.forward; | 1824 | keys = m.forward; |
| 1754 | wheel = m.wheel; | 1825 | wheel = m.wheel; |
| 1826 | self.dragReports(m.events); | ||
| 1755 | } | 1827 | } |
| 1828 | // This Core owns its own screen and its own thread — a tile's pump | ||
| 1829 | // is what runs `forward` — so the highlight is painted here rather | ||
| 1830 | // than left in shared state for somebody else to draw. The wall's | ||
| 1831 | // unzoomed loop cannot do that and does not (`wallview.paintStripe`). | ||
| 1832 | if (!std.meta.eql(was, self.drag.range())) try self.paintFull(); | ||
| 1756 | 1833 | ||
| 1757 | // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's | 1834 | // Alternate scroll (tmux calls it that; DEC 1007 is the terminal's |
| 1758 | // own version). The alt screen has no scrollback — `historyRows` | 1835 | // own version). The alt screen has no scrollback — `historyRows` |
| @@ -1847,6 +1924,59 @@ pub const Core = struct { | |||
| 1847 | return .ok; | 1924 | return .ok; |
| 1848 | } | 1925 | } |
| 1849 | 1926 | ||
| 1927 | /// Feed this read's mouse reports to the drag. | ||
| 1928 | /// | ||
| 1929 | /// Left button only. Middle is the terminal's own paste and right its | ||
| 1930 | /// menu, and stealing either would be a surprise with no answer. The | ||
| 1931 | /// button word arrives from the filter verbatim, motion bit and | ||
| 1932 | /// modifiers included, so it is the low two bits that name the button. | ||
| 1933 | /// | ||
| 1934 | /// A plain click is a defined no-op here, which is the difference | ||
| 1935 | /// between this driver and the wall's: at the wall a click moves the | ||
| 1936 | /// selection between stripes, and a zoomed tile is the only session on | ||
| 1937 | /// its screen, so there is nothing for a click to select. | ||
| 1938 | fn dragReports(self: *Core, events: []const MouseFilter.Event) void { | ||
| 1939 | for (events) |ev| { | ||
| 1940 | if (ev.button & 0b11 != 0) continue; | ||
| 1941 | const cell: select.Cell = .{ .row = ev.row, .col = ev.col }; | ||
| 1942 | switch (ev.kind) { | ||
| 1943 | .press => self.drag.press(cell, self.hitTest(ev)), | ||
| 1944 | .motion => self.drag.motion(cell, self.hitTest(ev)), | ||
| 1945 | .release => _ = self.drag.release(), | ||
| 1946 | } | ||
| 1947 | } | ||
| 1948 | } | ||
| 1949 | |||
| 1950 | /// Which line of this session a report landed on, or null for a report | ||
| 1951 | /// that names none. | ||
| 1952 | /// | ||
| 1953 | /// The mapping is trivial and that is the whole reason selection lives | ||
| 1954 | /// in the Core while zoomed: `renderClipped` is top-anchored, so a | ||
| 1955 | /// terminal row IS a grid row, and the only conversion left is the | ||
| 1956 | /// history the daemon is holding under it. | ||
| 1957 | fn hitTest(self: *Core, ev: MouseFilter.Event) ?select.Hit { | ||
| 1958 | // Scrolled back, nothing on this screen is addressable: | ||
| 1959 | // `renderScrollback` blits VT bytes the daemon composed, with no | ||
| 1960 | // engine behind them, so there is no row here to name. Refused | ||
| 1961 | // rather than half-answered — the alternative is a selection whose | ||
| 1962 | // coordinates belong to the live view the user is not looking at. | ||
| 1963 | if (self.scroll_rows > 0) return null; | ||
| 1964 | const grid_rows: u16 = @intCast(self.rep.eng.term.rows); | ||
| 1965 | const cols: u16 = @intCast(self.rep.eng.term.cols); | ||
| 1966 | // Rows past the grid (a terminal taller than the daemon's grid) | ||
| 1967 | // hold no session line: `renderClipped` never painted them. | ||
| 1968 | if (ev.row >= @min(grid_rows, self.size.rows)) return null; | ||
| 1969 | return .{ | ||
| 1970 | .tile = 0, | ||
| 1971 | .row = self.rep.history_rows + ev.row, | ||
| 1972 | // Columns clamp instead of refusing, because the right edge is | ||
| 1973 | // where a hand naturally overshoots. An out-of-range column is | ||
| 1974 | // what makes the daemon answer `.invalid`, and a round trip | ||
| 1975 | // spent to be told so is a copy the user does not get. | ||
| 1976 | .col = @min(ev.col, cols -| 1), | ||
| 1977 | }; | ||
| 1978 | } | ||
| 1979 | |||
| 1850 | /// Go back to the live view without painting it — what a driver does | 1980 | /// Go back to the live view without painting it — what a driver does |
| 1851 | /// BEFORE a reconnect, where the resync's own repaint is what will | 1981 | /// BEFORE a reconnect, where the resync's own repaint is what will |
| 1852 | /// arrive. | 1982 | /// arrive. |
| @@ -1877,6 +2007,10 @@ pub const Core = struct { | |||
| 1877 | /// predicted against a connection that no longer exists — dropping it | 2007 | /// predicted against a connection that no longer exists — dropping it |
| 1878 | /// is not an accusation, so the counters stay where they are. | 2008 | /// is not an accusation, so the counters stay where they are. |
| 1879 | pub fn reattached(self: *Core) void { | 2009 | pub fn reattached(self: *Core) void { |
| 2010 | // The absolute row space is counted from the oldest row the daemon | ||
| 2011 | // retains, and a resync renames it outright — so a highlight kept | ||
| 2012 | // across one is an inversion over rows nobody selected. | ||
| 2013 | self.drag.clear(); | ||
| 1880 | self.rep.state_since_attach = false; | 2014 | self.rep.state_since_attach = false; |
| 1881 | self.repaint_after_resync = true; | 2015 | self.repaint_after_resync = true; |
| 1882 | self.overlay.flush(); | 2016 | self.overlay.flush(); |
| @@ -2929,6 +3063,239 @@ test "interact: a promote takes the mouse, a demote gives it back, a demote twic | |||
| 2929 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | 3063 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); |
| 2930 | } | 3064 | } |
| 2931 | 3065 | ||
| 3066 | /// A transport that swallows everything. `forward` writes the keystrokes | ||
| 3067 | /// it did not consume, and none of these tests are about those bytes. | ||
| 3068 | const NullTransport = struct { | ||
| 3069 | fn writeFrame(_: *NullTransport, _: proto.MsgType, _: []const u8) !void {} | ||
| 3070 | }; | ||
| 3071 | |||
| 3072 | /// A zoomed Core on a pipe, with a claim, ready to be dragged over. | ||
| 3073 | fn dragFixture(alloc: std.mem.Allocator, out_fd: std.posix.fd_t) !Core { | ||
| 3074 | var core = try Core.initSized(alloc, -1, out_fd, .{ .cols = 20, .rows = 6 }); | ||
| 3075 | core.is_tty = true; | ||
| 3076 | // A drag only happens on a terminal this Core took over — which is | ||
| 3077 | // exactly the promoted-tile case, and the reason an unzoomed tile | ||
| 3078 | // never reaches any of this. | ||
| 3079 | _ = core.claimTerminal(); | ||
| 3080 | core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three"); | ||
| 3081 | return core; | ||
| 3082 | } | ||
| 3083 | |||
| 3084 | test "interact: a drag at a zoomed tile inverts what it crossed, and a click does not" { | ||
| 3085 | const alloc = std.testing.allocator; | ||
| 3086 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3087 | defer std.posix.close(p[0]); | ||
| 3088 | defer std.posix.close(p[1]); | ||
| 3089 | var core = try dragFixture(alloc, p[1]); | ||
| 3090 | defer core.deinit(); | ||
| 3091 | var tr: NullTransport = .{}; | ||
| 3092 | var buf: [8192]u8 = undefined; | ||
| 3093 | _ = drainPipe(p[0], &buf); // the claim | ||
| 3094 | |||
| 3095 | // Press on row 1, column 2. Nothing is painted: a press that turns out | ||
| 3096 | // to be a click must not flicker an inversion on its way past. | ||
| 3097 | _ = try core.forward(&tr, "\x1b[<0;3;2M"); | ||
| 3098 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3099 | |||
| 3100 | // Drag to column 6 of the same row. | ||
| 3101 | _ = try core.forward(&tr, "\x1b[<32;7;2M"); | ||
| 3102 | const painted = drainPipe(p[0], &buf); | ||
| 3103 | // Columns 2..6 of grid row 1, addressed by column and inverted: the | ||
| 3104 | // span opens at column 3 (1-based) and the tail resumes at column 8. | ||
| 3105 | try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[3G\x1b[0m\x1b[7m") != null); | ||
| 3106 | try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[0m\x1b[8G") != null); | ||
| 3107 | try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, painted, "\x1b[7m")); | ||
| 3108 | // The neighbouring rows are ordinary dumps in the same repaint. | ||
| 3109 | try std.testing.expect(std.mem.indexOf(u8, painted, "row-zero") != null); | ||
| 3110 | try std.testing.expect(std.mem.indexOf(u8, painted, "row-two") != null); | ||
| 3111 | |||
| 3112 | // The release does not move the highlight, so it does not repaint one. | ||
| 3113 | _ = try core.forward(&tr, "\x1b[<0;7;2m"); | ||
| 3114 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3115 | |||
| 3116 | // A press elsewhere puts the selection away — and the repaint that | ||
| 3117 | // says so carries no inversion at all. | ||
| 3118 | _ = try core.forward(&tr, "\x1b[<0;1;4M"); | ||
| 3119 | const cleared = drainPipe(p[0], &buf); | ||
| 3120 | try std.testing.expect(std.mem.indexOf(u8, cleared, "row-three") != null); | ||
| 3121 | try std.testing.expect(std.mem.indexOf(u8, cleared, "\x1b[7m") == null); | ||
| 3122 | // ...and that press is a click, which a zoomed tile answers with | ||
| 3123 | // nothing: there is one session on this screen to select. | ||
| 3124 | _ = try core.forward(&tr, "\x1b[<0;1;4m"); | ||
| 3125 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3126 | } | ||
| 3127 | |||
| 3128 | test "interact: the application that asked for the mouse gets the drag, and mux keeps no selection" { | ||
| 3129 | const alloc = std.testing.allocator; | ||
| 3130 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3131 | defer std.posix.close(p[0]); | ||
| 3132 | defer std.posix.close(p[1]); | ||
| 3133 | var core = try dragFixture(alloc, p[1]); | ||
| 3134 | defer core.deinit(); | ||
| 3135 | var tr: NullTransport = .{}; | ||
| 3136 | var buf: [8192]u8 = undefined; | ||
| 3137 | _ = drainPipe(p[0], &buf); | ||
| 3138 | |||
| 3139 | _ = try core.forward(&tr, "\x1b[<0;3;2M"); | ||
| 3140 | _ = try core.forward(&tr, "\x1b[<32;7;2M"); | ||
| 3141 | try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") != null); | ||
| 3142 | |||
| 3143 | // vim's `set mouse=a` arrives mid-drag. The reports are the | ||
| 3144 | // application's from here, and the selection standing on its screen is | ||
| 3145 | // not mux's to keep. | ||
| 3146 | core.semantic.terminal_modes = .{ | ||
| 3147 | .bracketed_paste = false, | ||
| 3148 | .mouse_normal = true, | ||
| 3149 | .mouse_button = true, | ||
| 3150 | .mouse_sgr = true, | ||
| 3151 | }; | ||
| 3152 | _ = try core.forward(&tr, "\x1b[<32;9;2M"); | ||
| 3153 | const after = drainPipe(p[0], &buf); | ||
| 3154 | try std.testing.expect(std.mem.indexOf(u8, after, "row-one") != null); | ||
| 3155 | try std.testing.expect(std.mem.indexOf(u8, after, "\x1b[7m") == null); | ||
| 3156 | // A further drag under the application selects nothing at all. | ||
| 3157 | _ = try core.forward(&tr, "\x1b[<32;11;2M"); | ||
| 3158 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3159 | } | ||
| 3160 | |||
| 3161 | test "interact: a drag while scrolled back selects nothing" { | ||
| 3162 | const alloc = std.testing.allocator; | ||
| 3163 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3164 | defer std.posix.close(p[0]); | ||
| 3165 | defer std.posix.close(p[1]); | ||
| 3166 | var core = try dragFixture(alloc, p[1]); | ||
| 3167 | defer core.deinit(); | ||
| 3168 | var tr: NullTransport = .{}; | ||
| 3169 | var buf: [8192]u8 = undefined; | ||
| 3170 | _ = drainPipe(p[0], &buf); | ||
| 3171 | |||
| 3172 | // Viewing history: `renderScrollback` blits daemon VT bytes with no | ||
| 3173 | // engine behind them, so nothing on this screen is a row this Core can | ||
| 3174 | // name. Refused outright rather than answered against the live view | ||
| 3175 | // the user is not looking at. | ||
| 3176 | core.scroll_rows = 4; | ||
| 3177 | _ = try core.forward(&tr, "\x1b[<0;3;2M"); | ||
| 3178 | _ = try core.forward(&tr, "\x1b[<32;7;2M"); | ||
| 3179 | try std.testing.expect(core.drag.range() == null); | ||
| 3180 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3181 | } | ||
| 3182 | |||
| 3183 | test "interact: a reattach drops the highlight, because the rows have been renamed" { | ||
| 3184 | const alloc = std.testing.allocator; | ||
| 3185 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3186 | defer std.posix.close(p[0]); | ||
| 3187 | defer std.posix.close(p[1]); | ||
| 3188 | var core = try dragFixture(alloc, p[1]); | ||
| 3189 | defer core.deinit(); | ||
| 3190 | var tr: NullTransport = .{}; | ||
| 3191 | var buf: [8192]u8 = undefined; | ||
| 3192 | _ = drainPipe(p[0], &buf); | ||
| 3193 | |||
| 3194 | _ = try core.forward(&tr, "\x1b[<0;3;2M"); | ||
| 3195 | _ = try core.forward(&tr, "\x1b[<32;7;3M"); | ||
| 3196 | try std.testing.expect(core.drag.range() != null); | ||
| 3197 | _ = drainPipe(p[0], &buf); | ||
| 3198 | |||
| 3199 | // Absolute rows count from the oldest row the daemon still retains, | ||
| 3200 | // and a resync renames that space outright. | ||
| 3201 | core.reattached(); | ||
| 3202 | try std.testing.expect(core.drag.range() == null); | ||
| 3203 | try core.repaint(); | ||
| 3204 | try std.testing.expect(std.mem.indexOf(u8, drainPipe(p[0], &buf), "\x1b[7m") == null); | ||
| 3205 | } | ||
| 3206 | |||
| 3207 | test "interact: the anchor is an absolute row, and the paint converts it back" { | ||
| 3208 | const alloc = std.testing.allocator; | ||
| 3209 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3210 | defer std.posix.close(p[0]); | ||
| 3211 | defer std.posix.close(p[1]); | ||
| 3212 | var core = try dragFixture(alloc, p[1]); | ||
| 3213 | defer core.deinit(); | ||
| 3214 | var tr: NullTransport = .{}; | ||
| 3215 | var buf: [8192]u8 = undefined; | ||
| 3216 | _ = drainPipe(p[0], &buf); | ||
| 3217 | |||
| 3218 | // A session with history behind it, which is every session that has | ||
| 3219 | // been used. The number is what makes the two directions of the | ||
| 3220 | // conversion distinguishable at all: with none retained, a grid row | ||
| 3221 | // and an absolute row are the same integer and neither half of the | ||
| 3222 | // arithmetic is under test. | ||
| 3223 | core.rep.history_rows = 500; | ||
| 3224 | |||
| 3225 | _ = try core.forward(&tr, "\x1b[<0;3;2M"); | ||
| 3226 | _ = try core.forward(&tr, "\x1b[<32;7;2M"); | ||
| 3227 | const r = core.drag.range().?; | ||
| 3228 | // Terminal row 1 is grid row 1 is the 501st retained row — the space | ||
| 3229 | // `protocol.SelectionReq` speaks, and the one a scroll cannot rename. | ||
| 3230 | try std.testing.expectEqual(@as(u32, 501), r.from.row); | ||
| 3231 | try std.testing.expectEqual(@as(u32, 501), r.to.row); | ||
| 3232 | // ...and the paint puts the inversion back on grid row 1, which is the | ||
| 3233 | // other half of the same arithmetic: a painter that took the absolute | ||
| 3234 | // row literally would invert a row 500 below the one pointed at, or | ||
| 3235 | // none at all. | ||
| 3236 | const painted = drainPipe(p[0], &buf); | ||
| 3237 | try std.testing.expect(std.mem.indexOf(u8, painted, "\x1b[2;1H\x1b[0mro\x1b[3G\x1b[0m\x1b[7m") != null); | ||
| 3238 | } | ||
| 3239 | |||
| 3240 | test "interact: only the left button drags" { | ||
| 3241 | const alloc = std.testing.allocator; | ||
| 3242 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3243 | defer std.posix.close(p[0]); | ||
| 3244 | defer std.posix.close(p[1]); | ||
| 3245 | var core = try dragFixture(alloc, p[1]); | ||
| 3246 | defer core.deinit(); | ||
| 3247 | var tr: NullTransport = .{}; | ||
| 3248 | var buf: [8192]u8 = undefined; | ||
| 3249 | _ = drainPipe(p[0], &buf); | ||
| 3250 | |||
| 3251 | // Middle button (1) and right (2). One is the terminal's own paste and | ||
| 3252 | // the other its menu; stealing either would be a surprise with no | ||
| 3253 | // answer, and the button word arrives with the motion bit set on a | ||
| 3254 | // drag — 33 is a middle-button drag, not a press of button 33. | ||
| 3255 | _ = try core.forward(&tr, "\x1b[<1;3;2M"); | ||
| 3256 | _ = try core.forward(&tr, "\x1b[<33;7;2M"); | ||
| 3257 | try std.testing.expect(core.drag.range() == null); | ||
| 3258 | _ = try core.forward(&tr, "\x1b[<2;3;3M"); | ||
| 3259 | _ = try core.forward(&tr, "\x1b[<34;7;3M"); | ||
| 3260 | try std.testing.expect(core.drag.range() == null); | ||
| 3261 | try std.testing.expectError(error.WouldBlock, std.posix.read(p[0], &buf)); | ||
| 3262 | } | ||
| 3263 | |||
| 3264 | test "interact: a row past the grid names no line, and a column past it clamps" { | ||
| 3265 | const alloc = std.testing.allocator; | ||
| 3266 | const p = try std.posix.pipe2(.{ .NONBLOCK = true }); | ||
| 3267 | defer std.posix.close(p[0]); | ||
| 3268 | defer std.posix.close(p[1]); | ||
| 3269 | // A terminal taller and wider than the daemon's grid, which latest-wins | ||
| 3270 | // leaves routinely. | ||
| 3271 | var core = try Core.initSized(alloc, -1, p[1], .{ .cols = 40, .rows = 12 }); | ||
| 3272 | core.is_tty = true; | ||
| 3273 | _ = core.claimTerminal(); | ||
| 3274 | defer core.deinit(); | ||
| 3275 | var tr: NullTransport = .{}; | ||
| 3276 | var buf: [8192]u8 = undefined; | ||
| 3277 | _ = drainPipe(p[0], &buf); | ||
| 3278 | |||
| 3279 | // The grid is smaller than the Core's clip size: 20x6 against 40x12. | ||
| 3280 | try core.rep.eng.resize(20, 6); | ||
| 3281 | core.rep.eng.feed("row-zero\r\nrow-one\r\nrow-two"); | ||
| 3282 | |||
| 3283 | // Row 8 is inside the terminal and past the grid: `renderClipped` | ||
| 3284 | // never painted a session line there. | ||
| 3285 | _ = try core.forward(&tr, "\x1b[<0;3;9M"); | ||
| 3286 | try std.testing.expect(core.drag.range() == null); | ||
| 3287 | |||
| 3288 | // Column 34 is past the grid's right edge. It clamps to the last | ||
| 3289 | // column instead of refusing — the right edge is where a hand | ||
| 3290 | // overshoots — and an out-of-range column is what makes the daemon | ||
| 3291 | // answer `.invalid` when the selection is asked for. | ||
| 3292 | _ = try core.forward(&tr, "\x1b[<0;35;2M"); | ||
| 3293 | _ = try core.forward(&tr, "\x1b[<32;3;2M"); | ||
| 3294 | const r = core.drag.range().?; | ||
| 3295 | try std.testing.expectEqual(@as(u16, 19), r.to.col); | ||
| 3296 | try std.testing.expectEqual(@as(u16, 2), r.from.col); | ||
| 3297 | } | ||
| 3298 | |||
| 2932 | test "interact: only lifecycle and agent-channel frames come back to the driver" { | 3299 | test "interact: only lifecycle and agent-channel frames come back to the driver" { |
| 2933 | const alloc = std.testing.allocator; | 3300 | const alloc = std.testing.allocator; |
| 2934 | // No terminal at either end: every paint and every side channel is | 3301 | // No terminal at either end: every paint and every side channel is |
src/paint.zig
| Old | New | ||
|---|---|---|---|
| @@ -18,6 +18,36 @@ const proto = @import("protocol"); | |||
| 18 | pub const sync_begin = "\x1b[?2026h\x1b[?25l"; | 18 | pub const sync_begin = "\x1b[?2026h\x1b[?25l"; |
| 19 | pub const sync_end = "\x1b[?25h\x1b[?2026l"; | 19 | pub const sync_end = "\x1b[?25h\x1b[?2026l"; |
| 20 | 20 | ||
| 21 | /// Inclusive grid columns of one row, painted inverted. | ||
| 22 | pub const Span = struct { from: u16, to: u16 }; | ||
| 23 | |||
| 24 | /// Which columns of a grid row a painter must invert, asked per row. | ||
| 25 | /// | ||
| 26 | /// A callback and not a list of rows, for a layering reason and a | ||
| 27 | /// practical one. The answer comes from `select.zig`, which is layer 1 | ||
| 28 | /// like this module — neither may import the other — so the shape of a | ||
| 29 | /// selection cannot be named here; and a painter emits row by row anyway, | ||
| 30 | /// so per-row is the question it already has to ask. `cols` is handed | ||
| 31 | /// DOWN rather than remembered by the caller: the width the highlight is | ||
| 32 | /// clamped to must be the width this paint is about to use, and | ||
| 33 | /// `Engine.dumpVtRowSpan` asserts it. | ||
| 34 | /// | ||
| 35 | /// Null `span` — the default — is a paint with no selection on it, which | ||
| 36 | /// is nearly every paint. | ||
| 37 | pub const Highlight = struct { | ||
| 38 | ctx: ?*anyopaque = null, | ||
| 39 | span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null, | ||
| 40 | }; | ||
| 41 | |||
| 42 | /// One grid row, inverted where the highlight says so. The single place | ||
| 43 | /// the two painters agree about what a selection does to a row. | ||
| 44 | fn dumpRow(alloc: std.mem.Allocator, replica: *Engine, y: u16, hl: Highlight) ![]u8 { | ||
| 45 | const ask = hl.span orelse return replica.dumpVtRow(alloc, y); | ||
| 46 | const s = ask(hl.ctx, y, @intCast(replica.term.cols)) orelse | ||
| 47 | return replica.dumpVtRow(alloc, y); | ||
| 48 | return replica.dumpVtRowSpan(alloc, y, s.from, s.to); | ||
| 49 | } | ||
| 50 | |||
| 21 | pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos { | 51 | pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos { |
| 22 | return .{ | 52 | return .{ |
| 23 | .x = @min(cur.x, tty.cols -| 1), | 53 | .x = @min(cur.x, tty.cols -| 1), |
| @@ -29,7 +59,13 @@ pub fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos { | |||
| 29 | /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty | 59 | /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty |
| 30 | /// are skipped and long rows clip at the right edge because autowrap is | 60 | /// are skipped and long rows clip at the right edge because autowrap is |
| 31 | /// off (DECAWM, set at attach). | 61 | /// off (DECAWM, set at attach). |
| 32 | pub fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size, out_fd: std.posix.fd_t) !void { | 62 | pub fn renderClipped( |
| 63 | alloc: std.mem.Allocator, | ||
| 64 | replica: *Engine, | ||
| 65 | tty: proto.Size, | ||
| 66 | hl: Highlight, | ||
| 67 | out_fd: std.posix.fd_t, | ||
| 68 | ) !void { | ||
| 33 | var paint: std.ArrayList(u8) = .empty; | 69 | var paint: std.ArrayList(u8) = .empty; |
| 34 | defer paint.deinit(alloc); | 70 | defer paint.deinit(alloc); |
| 35 | try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J"); | 71 | try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J"); |
| @@ -40,7 +76,7 @@ pub fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size | |||
| 40 | while (y < limit) : (y += 1) { | 76 | while (y < limit) : (y += 1) { |
| 41 | var cup: [16]u8 = undefined; | 77 | var cup: [16]u8 = undefined; |
| 42 | try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1})); | 78 | try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1})); |
| 43 | const row = try replica.dumpVtRow(alloc, y); | 79 | const row = try dumpRow(alloc, replica, y, hl); |
| 44 | defer alloc.free(row); | 80 | defer alloc.free(row); |
| 45 | try paint.appendSlice(alloc, row); | 81 | try paint.appendSlice(alloc, row); |
| 46 | } | 82 | } |
| @@ -154,6 +190,7 @@ pub fn renderStripe( | |||
| 154 | replica: *Engine, | 190 | replica: *Engine, |
| 155 | row_off: u16, | 191 | row_off: u16, |
| 156 | view: proto.Size, | 192 | view: proto.Size, |
| 193 | hl: Highlight, | ||
| 157 | out_fd: std.posix.fd_t, | 194 | out_fd: std.posix.fd_t, |
| 158 | ) !void { | 195 | ) !void { |
| 159 | var paint: std.ArrayList(u8) = .empty; | 196 | var paint: std.ArrayList(u8) = .empty; |
| @@ -171,7 +208,7 @@ pub fn renderStripe( | |||
| 171 | }) { | 208 | }) { |
| 172 | var cup: [16]u8 = undefined; | 209 | var cup: [16]u8 = undefined; |
| 173 | try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row})); | 210 | try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{out_row})); |
| 174 | const row = try replica.dumpVtRow(alloc, y); | 211 | const row = try dumpRow(alloc, replica, y, hl); |
| 175 | defer alloc.free(row); | 212 | defer alloc.free(row); |
| 176 | try paint.appendSlice(alloc, row); | 213 | try paint.appendSlice(alloc, row); |
| 177 | } | 214 | } |
| @@ -210,7 +247,7 @@ test "renderStripe paints the grid bottom at the stripe offset, no wipe, no curs | |||
| 210 | defer std.posix.close(pipe[0]); | 247 | defer std.posix.close(pipe[0]); |
| 211 | // 3 view rows over a 5-row grid, cursor on row 4: the window keeps the | 248 | // 3 view rows over a 5-row grid, cursor on row 4: the window keeps the |
| 212 | // cursor on its last row — grid rows 2..4 — at offset 4. | 249 | // cursor on its last row — grid rows 2..4 — at offset 4. |
| 213 | try renderStripe(alloc, replica, 4, .{ .cols = 20, .rows = 3 }, pipe[1]); | 250 | try renderStripe(alloc, replica, 4, .{ .cols = 20, .rows = 3 }, .{}, pipe[1]); |
| 214 | std.posix.close(pipe[1]); | 251 | std.posix.close(pipe[1]); |
| 215 | 252 | ||
| 216 | var out: [4096]u8 = undefined; | 253 | var out: [4096]u8 = undefined; |
| @@ -245,7 +282,7 @@ test "renderStripe with the cursor high shows the grid top, not blank bottom row | |||
| 245 | 282 | ||
| 246 | const pipe = try std.posix.pipe(); | 283 | const pipe = try std.posix.pipe(); |
| 247 | defer std.posix.close(pipe[0]); | 284 | defer std.posix.close(pipe[0]); |
| 248 | try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 8 }, pipe[1]); | 285 | try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 8 }, .{}, pipe[1]); |
| 249 | std.posix.close(pipe[1]); | 286 | std.posix.close(pipe[1]); |
| 250 | 287 | ||
| 251 | var out: [8192]u8 = undefined; | 288 | var out: [8192]u8 = undefined; |
| @@ -266,7 +303,7 @@ test "renderStripe clears stripe rows a short grid does not reach" { | |||
| 266 | defer std.posix.close(pipe[0]); | 303 | defer std.posix.close(pipe[0]); |
| 267 | // 4 view rows over a 2-row grid at offset 0: rows 3 and 4 must be | 304 | // 4 view rows over a 2-row grid at offset 0: rows 3 and 4 must be |
| 268 | // cleared, not skipped. | 305 | // cleared, not skipped. |
| 269 | try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 4 }, pipe[1]); | 306 | try renderStripe(alloc, replica, 0, .{ .cols = 20, .rows = 4 }, .{}, pipe[1]); |
| 270 | std.posix.close(pipe[1]); | 307 | std.posix.close(pipe[1]); |
| 271 | 308 | ||
| 272 | var out: [4096]u8 = undefined; | 309 | var out: [4096]u8 = undefined; |
| @@ -277,6 +314,78 @@ test "renderStripe clears stripe rows a short grid does not reach" { | |||
| 277 | try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[2K") != null); | 314 | try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4;1H\x1b[2K") != null); |
| 278 | } | 315 | } |
| 279 | 316 | ||
| 317 | /// A highlight of two fixed rows, standing in for what `select.Drag` | ||
| 318 | /// answers: rows 1 and 2, from column 3 to the width the painter offers. | ||
| 319 | const TestHighlight = struct { | ||
| 320 | cols_seen: u16 = 0, | ||
| 321 | |||
| 322 | fn span(ctx: ?*anyopaque, row: u16, cols: u16) ?Span { | ||
| 323 | const self: *TestHighlight = @ptrCast(@alignCast(ctx.?)); | ||
| 324 | self.cols_seen = cols; | ||
| 325 | if (row != 1 and row != 2) return null; | ||
| 326 | return .{ .from = 3, .to = cols -| 1 }; | ||
| 327 | } | ||
| 328 | |||
| 329 | fn hl(self: *TestHighlight) Highlight { | ||
| 330 | return .{ .ctx = self, .span = &span }; | ||
| 331 | } | ||
| 332 | }; | ||
| 333 | |||
| 334 | test "renderClipped inverts the highlighted rows and leaves the rest alone" { | ||
| 335 | const alloc = std.testing.allocator; | ||
| 336 | var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 }); | ||
| 337 | defer replica.deinit(); | ||
| 338 | replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three"); | ||
| 339 | |||
| 340 | const pipe = try std.posix.pipe(); | ||
| 341 | defer std.posix.close(pipe[0]); | ||
| 342 | var h: TestHighlight = .{}; | ||
| 343 | try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, h.hl(), pipe[1]); | ||
| 344 | std.posix.close(pipe[1]); | ||
| 345 | |||
| 346 | var out: [8192]u8 = undefined; | ||
| 347 | const n = try std.posix.read(pipe[0], &out); | ||
| 348 | const text = out[0..n]; | ||
| 349 | // The two highlighted rows carry an inversion; the rows above and | ||
| 350 | // below are ordinary dumps and must not. | ||
| 351 | try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "\x1b[7m")); | ||
| 352 | const r0 = std.mem.indexOf(u8, text, "row-zero").?; | ||
| 353 | const r1 = std.mem.indexOf(u8, text, "\x1b[7m").?; | ||
| 354 | const r3 = std.mem.indexOf(u8, text, "row-three").?; | ||
| 355 | try std.testing.expect(r0 < r1 and r1 < r3); | ||
| 356 | // The span begins at column 4 (0-based 3), which only the CHA says. | ||
| 357 | try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null); | ||
| 358 | // The width the highlight was clamped to is the GRID's, not the tty's: | ||
| 359 | // a 12-column replica painted on an 80-column terminal has 68 columns | ||
| 360 | // no cell can be selected in, and asking the daemon for one is a round | ||
| 361 | // trip spent to be told `.invalid`. | ||
| 362 | try std.testing.expectEqual(@as(u16, 12), h.cols_seen); | ||
| 363 | } | ||
| 364 | |||
| 365 | test "renderStripe inverts a highlighted row inside the stripe" { | ||
| 366 | const alloc = std.testing.allocator; | ||
| 367 | var replica = try Engine.init(alloc, .{ .cols = 12, .rows = 4 }); | ||
| 368 | defer replica.deinit(); | ||
| 369 | replica.feed("row-zero\r\nrow-one\r\nrow-two\r\nrow-three"); | ||
| 370 | |||
| 371 | const pipe = try std.posix.pipe(); | ||
| 372 | defer std.posix.close(pipe[0]); | ||
| 373 | var h: TestHighlight = .{}; | ||
| 374 | // The whole grid fits the view, so the window starts at grid row 0 and | ||
| 375 | // the highlight's rows 1 and 2 are the stripe's second and third. | ||
| 376 | try renderStripe(alloc, replica, 0, .{ .cols = 12, .rows = 4 }, h.hl(), pipe[1]); | ||
| 377 | std.posix.close(pipe[1]); | ||
| 378 | |||
| 379 | var out: [8192]u8 = undefined; | ||
| 380 | const n = try std.posix.read(pipe[0], &out); | ||
| 381 | const text = out[0..n]; | ||
| 382 | try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "\x1b[7m")); | ||
| 383 | // GRID rows, not terminal rows: the highlight is asked about the row | ||
| 384 | // of the session, and the stripe offset is the painter's business. | ||
| 385 | try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2;1H\x1b[2K\x1b[0mrow\x1b[4G\x1b[0m\x1b[7m") != null); | ||
| 386 | try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[2K\x1b[0mrow-zero") != null); | ||
| 387 | } | ||
| 388 | |||
| 280 | test "paintBanner parks an inverse label top-right without moving the cursor" { | 389 | test "paintBanner parks an inverse label top-right without moving the cursor" { |
| 281 | const pipe = try std.posix.pipe(); | 390 | const pipe = try std.posix.pipe(); |
| 282 | defer std.posix.close(pipe[0]); | 391 | defer std.posix.close(pipe[0]); |
| @@ -333,7 +442,7 @@ test "renderClipped paints only rows that fit and clamps the cursor" { | |||
| 333 | const pipe = try std.posix.pipe(); | 442 | const pipe = try std.posix.pipe(); |
| 334 | defer std.posix.close(pipe[0]); | 443 | defer std.posix.close(pipe[0]); |
| 335 | // Local tty is smaller than the 100x30 grid. | 444 | // Local tty is smaller than the 100x30 grid. |
| 336 | try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]); | 445 | try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, pipe[1]); |
| 337 | std.posix.close(pipe[1]); | 446 | std.posix.close(pipe[1]); |
| 338 | 447 | ||
| 339 | var out: std.ArrayList(u8) = .empty; | 448 | var out: std.ArrayList(u8) = .empty; |
| @@ -372,7 +481,7 @@ test "renderClipped stops at the grid when the tty is the larger one" { | |||
| 372 | 481 | ||
| 373 | const pipe = try std.posix.pipe(); | 482 | const pipe = try std.posix.pipe(); |
| 374 | defer std.posix.close(pipe[0]); | 483 | defer std.posix.close(pipe[0]); |
| 375 | try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]); | 484 | try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, .{}, pipe[1]); |
| 376 | std.posix.close(pipe[1]); | 485 | std.posix.close(pipe[1]); |
| 377 | 486 | ||
| 378 | var out: std.ArrayList(u8) = .empty; | 487 | var out: std.ArrayList(u8) = .empty; |
src/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -833,7 +833,7 @@ fn paintStripe(t: *Tile, alloc: std.mem.Allocator, eng: *Engine, history_rows: u | |||
| 833 | .history_rows = history_rows, | 833 | .history_rows = history_rows, |
| 834 | .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), | 834 | .win_start = paint.stripeWinStart(@intCast(eng.term.rows), eng.cursorPos().y, view.rows), |
| 835 | }; | 835 | }; |
| 836 | paint.renderStripe(alloc, eng, t.stripe.top + 1, view, t.shared.out_fd) catch {}; | 836 | paint.renderStripe(alloc, eng, t.stripe.top + 1, view, .{}, t.shared.out_fd) catch {}; |
| 837 | return true; | 837 | return true; |
| 838 | } | 838 | } |
| 839 | 839 | ||