From e29faca73e07eb90aa0c8f6f33e55c8ace77a3c4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 18:40:43 -0300 Subject: A blank whose ink alone changed is skipped in vaxis's own frame diff, not weighed up per cell in the tty --- src/tty/tty.zig | 108 +++++++++++--------------------------------------------- 1 file changed, 20 insertions(+), 88 deletions(-) (limited to 'src') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index efccd98e..df1e5d02 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -1453,7 +1453,7 @@ const Shell = struct { const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface; const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed; const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed; - paintCells(vx, win, trailed.cells, trailed.cols, trailed.rows); + paintCells(win, trailed.cells, trailed.cols, trailed.rows); tz_cells.end(); for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; @@ -2170,94 +2170,26 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event { } }; } -fn paintCells(vx: *const vaxis.Vaxis, win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { +/// A blank whose ink alone changed is not sent: vaxis's render skips it +/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows +/// nowhere. Deciding that here cost every cell of every frame a look at the +/// frame before; vaxis already makes that comparison. +fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void { win.clear(); var y: u16 = 0; while (y < rows) : (y += 1) { - const row = cells[@as(usize, y) * cols ..][0..cols]; var x: u16 = 0; - // The cell to the left as written, for a blank that needs it. - var left: ?Written = null; while (x < cols) : (x += 1) { - const cell = &row[x]; - if (cell.default) { - left = null; - continue; - } - const grapheme = cell.grapheme(); - var style = vaxisStyle(cell.style); - if (inkless(grapheme, style)) blankInk(vx, row, x, y, &style, left); - left = .{ .grapheme = grapheme, .style = style }; - win.writeCell(x, y, .{ .char = .{ .grapheme = grapheme }, .style = style }); + const cell = &cells[@as(usize, y) * cols + x]; + if (cell.default) continue; + win.writeCell(x, y, .{ + .char = .{ .grapheme = cell.grapheme() }, + .style = vaxisStyle(cell.style), + }); } } } -const Written = struct { grapheme: []const u8, style: vaxis.Style }; - -/// A blank's ink shows nowhere (no underline, strike or reverse to show it). -fn inkless(grapheme: []const u8, style: vaxis.Style) bool { - return std.mem.eql(u8, grapheme, " ") and !style.reverse and !style.strikethrough and style.ul_style == .off; -} - -/// A blank whose ink alone changed (a fade: InactiveDim on a focus change) -/// keeps the ink the terminal has there and so is not sent at all: -/// otherwise every blank of every pane a fade touched went, 7 KB a click -/// for three shells. Only a short gap between two cells that are sent -/// anyway is cheaper sent than jumped (a cursor move): it takes the ink of -/// the cell before it, so the run needs no colour change either. -fn blankInk(vx: *const vaxis.Vaxis, row: []const pardes.Cell, x: u16, y: u16, style: *vaxis.Style, left: ?Written) void { - const last = lastCell(vx, x, y) orelse return; - if (vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, " ")) return; - if (sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style.*)) return; - var kept = style.*; - kept.fg = last.style.fg; - if (left) |l| if (sends(vx, x - 1, y, l.grapheme, l.style)) { - var end = x + 1; - while (end < row.len and end - x < 8) : (end += 1) { - const next = &row[end]; - if (next.default) break; - const next_style = vaxisStyle(next.style); - if (inkless(next.grapheme(), next_style)) continue; - if (sends(vx, end, y, next.grapheme(), next_style)) { - style.fg = l.style.fg; - return; - } - break; - } - }; - style.* = kept; -} - -/// `Style.eql` but for the ink, and cheap: this runs for every blank of -/// every frame. -fn sameButInk(a: vaxis.Style, b: vaxis.Style) bool { - return sameColor(a.bg, b.bg) and sameColor(a.ul, b.ul) and a.ul_style == b.ul_style and - a.bold == b.bold and a.dim == b.dim and a.italic == b.italic and a.blink == b.blink and - a.reverse == b.reverse and a.invisible == b.invisible and a.strikethrough == b.strikethrough; -} - -fn sameColor(a: vaxis.Color, b: vaxis.Color) bool { - return switch (a) { - .default => b == .default, - .index => |i| b == .index and b.index == i, - .rgb => |rgb| b == .rgb and std.mem.eql(u8, &rgb, &b.rgb), - }; -} - -/// Whether vaxis sends this cell: it differs from what the terminal shows. -fn sends(vx: *const vaxis.Vaxis, x: u16, y: u16, grapheme: []const u8, style: vaxis.Style) bool { - const last = lastCell(vx, x, y) orelse return true; - return vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, grapheme) or - !sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style); -} - -fn lastCell(vx: *const vaxis.Vaxis, x: u16, y: u16) ?*const vaxis.AllocatingScreen.InternalCell { - if (x >= vx.screen.width) return null; - const i = @as(usize, y) * vx.screen.width + x; - return if (i < vx.screen_last.buf.len) &vx.screen_last.buf[i] else null; -} - test "a fade sends the text it dims, not the blanks beside it" { const gpa = std.testing.allocator; var env = try std.testing.environ.createMap(gpa); @@ -2267,8 +2199,8 @@ test "a fade sends the text it dims, not the blanks beside it" { defer out.deinit(); defer vx.deinit(gpa, &out.writer); try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 }); - // `a`, a long gap, `b`, a two-cell gap, `c`, a long gap, an underlined - // blank (which shows its ink) and one more blank. + // `a`, a gap, `b`, a two-cell gap, `c`, a gap, an underlined blank + // (which shows its ink) and one more blank. var cells: [32]pardes.Cell = @splat(.{ .default = false }); cells[0].text[0] = 'a'; cells[12].text[0] = 'b'; @@ -2279,16 +2211,16 @@ test "a fade sends the text it dims, not the blanks beside it" { for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| { for (&cells) |*cell| cell.style.fg = .{ .rgb = ink }; out.clearRetainingCapacity(); - paintCells(&vx, vx.window(), &cells, 32, 1); + paintCells(vx.window(), &cells, 32, 1); try vx.render(&out.writer); sent[frame] = try out.toOwnedSlice(); } // Against a cleared screen every cell goes. After the fade the letters - // go, the short gap between two of them (cheaper than a cursor move), - // and the underlined blank; the long gaps are jumped. + // go and the underlined blank; every plain blank is jumped. try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " ")); - try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, sent[1], " ")); - try std.testing.expect(std.mem.indexOf(u8, sent[1], "b c") != null); + try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, sent[1], " ")); + try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'b') != null); + try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'c') != null); try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null); try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null); } @@ -2688,7 +2620,7 @@ const Attach = struct { fn paint(a: *Attach) void { a.dirty = false; const win = a.vx.window(); - paintCells(a.vx, win, a.client.grid.items, a.client.cols, a.client.rows); + paintCells(win, a.client.grid.items, a.client.cols, a.client.rows); if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); a.vx.render(a.tty.writer()) catch {}; } -- cgit v1.3 From a15c05b3d15bc269b3aec195c5e813a0f0203d4f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 18:36:10 -0300 Subject: The 9P server offers 64 KiB frames, not 8 KiB 8192 was kept for round 24 because 64 KiB seemed to hang fs-test's restore detached space; that was the batch-wake overflow fixed in kmqtwqsm, which any msize could hit, and fs.py passes at 64 KiB with it. A client asking for more now gets it, so a write up to 64 KiB less 24 is one Twrite, and round 24's no-newline cutoff (write_room, the negotiated msize less 24) follows: fs.py checks a 20000-byte ctl line from a 64 KiB client is taken whole. Each of the 16 connection slots holds its buffers at the larger size. Co-Authored-By: Claude Opus 5.5 --- src/9p.zig | 2 +- test/fs.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/9p.zig b/src/9p.zig index 5ffe9058..837525cb 100644 --- a/src/9p.zig +++ b/src/9p.zig @@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min; /// The smallest msize a native listener offers (src/9p_io.zig). pub const min_msize: u32 = 4096; /// What a native listener negotiates. -pub const msize: u32 = 8192; +pub const msize: u32 = 65536; /// The editor's engine: native fid count, native file names, and room to /// park a write of any size a frame can carry -- a request that would change diff --git a/test/fs.py b/test/fs.py index db10f32d..18563183 100644 --- a/test/fs.py +++ b/test/fs.py @@ -953,6 +953,20 @@ def test(binary, quic=False): with Client(address, msize=256) as small: assert set(small.list('/os' + str(listing))) == { f'entry-{number:02}' for number in range(24)} + # 64 KiB frames: a ctl line with no newline, whole in its one + # Twrite, runs with it however long -- the cutoff for a write + # that may go on is the frame the client asked for, less 24. + with Client(address, msize=65536) as big: + assert big.msize == 65536 + ctl = big.open('/pane/1/ctl', 1) + line = b'bogus' + b'x' * 20000 + try: + big.rpc(118, struct.pack(' Date: Tue, 29 Sep 2026 19:49:19 -0300 Subject: Over a slow link the tty keeps one frame in flight: it asks the terminal to answer after each, and draws the latest state when it does --- src/tty/tty.zig | 172 ++++++++++++++++++++++++++++++++++++++++++++++++++- test/e2e_harness.zig | 28 +++++++-- 2 files changed, 194 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/tty/tty.zig b/src/tty/tty.zig index df1e5d02..f5ae078a 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -132,7 +132,14 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { if (carried == buf.len) return error.InputSequenceTooLong; const received = try tty.read(buf[carried..]); if (received == 0) return; - const end = carried + received; + // The terminal's answers to frames (FrameAck) come out first, + // wherever they sit: never events, and never glued to a key (an + // Escape pressed just before one would read as Alt on it). + const end = FrameAck.take(buf[0 .. carried + received], loop); + if (end == 0) { + carried = 0; + continue; + } if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| { kitty_shm_probing.store(false, .release); kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release); @@ -153,7 +160,8 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // its writes whole, so no sequence arrives cut right after its ESC. // Waiting on it cost every Escape press 50 ms on a legacy terminal // (tmux, most ssh sessions), and 1 s before kitty's flags were on. - var waited = carried == 0 and received == 1; + // (A terminal's answer to a frame is one write too: FrameAck.) + var waited = carried == 0 and end == 1; while (consumed < parse_end) { // A lone ESC that ends a longer read is the Escape key, or the // first byte of a sequence whose rest the read boundary held @@ -162,6 +170,9 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { // kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC // can only start a sequence and the wait can be long. const rest = buf[consumed..parse_end]; + // A frame's answer cut short by the read waits for its rest. + if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and + std.mem.startsWith(u8, FrameAck.answer, rest)) break; if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) { waited = true; if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break; @@ -191,6 +202,63 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void { } } +/// One frame in flight at most. Each frame the tty draws ends with a device +/// status request (`CSI 5 n`), and no other frame is drawn until the +/// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling +/// or typing would queue behind each other (sshd's window holds megabytes) +/// are never drawn, and the burst ends one frame and one round trip after +/// its last input. On a local terminal the answer is back in well under a +/// millisecond. A terminal that never answers turns this off for good after +/// `timeout_ns`. +const FrameAck = struct { + const request = "\x1b[5n"; + const answer = "\x1b[0n"; + const timeout_ns: u64 = std.time.ns_per_s; + var waiting = std.atomic.Value(bool).init(false); + var off = std.atomic.Value(bool).init(false); + /// When the request went out (the loop's thread only). + var sent_ns: u64 = 0; + + /// Take every answer out of `bytes`, closing up the rest; the new + /// length. The loop is woken for the frame that waited on one. + fn take(bytes: []u8, loop: *Loop) usize { + var len = bytes.len; + var found = false; + while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| { + std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]); + len -= answer.len; + found = true; + } + if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {}; + return len; + } + + /// Whether a new frame must wait (and the loop has a wake at the + /// timeout); past the timeout the terminal is taken to never answer. + fn holds(now_ns: u64, io: std.Io) bool { + if (off.load(.acquire) or !waiting.load(.acquire)) return false; + const waited_ns = now_ns -| sent_ns; + if (waited_ns >= timeout_ns) { + off.store(true, .release); + waiting.store(false, .release); + return false; + } + requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms)); + return true; + } + + /// After a frame that wrote something: ask for the answer. + fn ask(w: *std.Io.Writer, now_ns: u64) void { + if (off.load(.acquire)) return; + // Waiting before the request goes: a local terminal can answer + // before the write returns, and an answer nobody waited for is lost. + sent_ns = now_ns; + waiting.store(true, .release); + w.writeAll(request) catch return waiting.store(false, .release); + w.flush() catch return waiting.store(false, .release); + } +}; + /// Whether more input is readable within `ms`, polled in short slices so /// that a cancel of the input thread is still seen. A test reader answers /// for itself. @@ -521,6 +589,98 @@ test "an ESC read by itself is the Escape key at once" { vx.caps.kitty_keyboard = false; } +test "a frame's answer is taken wherever the reads cut it, and never typed" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{}); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: [2][]const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn inputFollows(self: *@This(), _: u32) bool { + return self.next < self.parts.len; + } + fn read(self: *@This(), buf: []u8) !usize { + while (self.next < self.parts.len) { + const part = self.parts[self.next]; + self.next += 1; + if (part.len == 0) continue; + @memcpy(buf[0..part.len], part); + return part.len; + } + return 0; + } + }; + const all = "x" ++ FrameAck.answer ++ "y"; + defer FrameAck.waiting.store(false, .release); + for (0..all.len) |cut| { + FrameAck.waiting.store(true, .release); + var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(!FrameAck.waiting.load(.acquire)); + var typed: std.ArrayList(u8) = .empty; + defer typed.deinit(gpa); + var woke = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .nop => woke = true, + .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey), + else => return error.UnexpectedInputEvent, + }; + std.testing.expectEqualStrings("xy", typed.items) catch |err| { + std.debug.print("cut at {d}\n", .{cut}); + return err; + }; + try std.testing.expect(woke); + } + // An Escape pressed just before an answer, in the same read, is the + // Escape key, not Alt on the answer. + FrameAck.waiting.store(true, .release); + var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } }; + try readInput(&loop, &glued, &cache); + try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?)); + try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?)); + const escape = (try loop.tryEvent()).?.key_press; + try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint); + try std.testing.expect(!escape.mods.alt); + try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint); + try std.testing.expect(try loop.tryEvent() == null); +} + +test "a terminal that never answers a frame stops being asked after one timeout" { + const io = std.testing.io; + defer { + FrameAck.off.store(false, .release); + FrameAck.waiting.store(false, .release); + FrameAck.sent_ns = 0; + } + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + const t0: u64 = 5 * std.time.ns_per_s; + FrameAck.ask(&out.writer, t0); + try std.testing.expectEqualStrings(FrameAck.request, out.written()); + // Unanswered: the next frame waits, until the timeout. + try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io)); + try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io)); + try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io)); + // Then never again: no request, no wait. + out.clearRetainingCapacity(); + FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s); + try std.testing.expectEqual(@as(usize, 0), out.written().len); + try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io)); +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; @@ -1438,6 +1598,12 @@ const Shell = struct { const s = of(ctx); const vx = s.vx; s.tracks = canonical.panelTracks(); + // The last frame is not answered yet: this one stays owed, and the + // answer's wake draws the state as it is then. + if (FrameAck.holds(host_io.monotonicNs(), s.io)) { + s.core.present_skipped = true; + return; + } _ = s.frame.reset(.retain_capacity); const surface = panel_compositor.compose( s.frame.allocator(), @@ -1523,7 +1689,9 @@ const Shell = struct { } if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar); const tz_render = tracy.zone(@src(), "vx.render"); + const before = s.tty.tty_writer.pos + s.tty.writer().end; vx.render(s.tty.writer()) catch {}; + if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs()); tz_render.end(); } diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index eaa19b43..9e67c49f 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -64,6 +64,8 @@ pub const Harness = struct { /// when true, print the captured screen state after each pump/waitFor and on /// every assertion, so live test runs can be inspected (zig build test -Dtrace). trace: bool = false, + /// How much of a `CSI 5 n` the output has ended on (`feed`). + status_request: u8 = 0, /// every raw byte the app has emitted (accumulated in pump). Lets tests assert /// on control sequences the emulator consumes and never renders (e.g. OSC 52 /// clipboard writes). Capture is bounded explicitly so a runaway child cannot @@ -147,6 +149,24 @@ pub const Harness = struct { /// Read pty output and feed it to our ghostty terminal for `ms` ms. After /// this, the grid reflects everything the app rendered so far. + /// The app's output into the emulator. The one query answered is the + /// device status request (`CSI 5 n` → `CSI 0 n`), as every terminal + /// does: the tty asks it after each frame and draws no other until the + /// answer (FrameAck in src/tty/tty.zig). + fn feed(self: *Harness, bytes: []const u8) void { + self.stream.nextSlice(bytes); + const request = "\x1b[5n"; + for (bytes) |b| { + if (b == request[self.status_request]) { + self.status_request += 1; + } else self.status_request = if (b == 0x1b) 1 else 0; + if (self.status_request == request.len) { + self.status_request = 0; + _ = libc.write(self.master, "\x1b[0n", 4); + } + } + } + pub fn pump(self: *Harness, ms: i64) !void { const deadline = nowMs() + ms; var buf: [4096]u8 = undefined; @@ -157,7 +177,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); } } if (self.trace) self.traceScreen("pump"); @@ -176,7 +196,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch return false; if (n == 0) return false; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); return true; } @@ -243,7 +263,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); } const text = try self.screenText(); defer self.gpa.free(text); @@ -270,7 +290,7 @@ pub const Harness = struct { const n = posix.read(self.master, &buf) catch break; if (n == 0) break; self.recordRaw(buf[0..n]); - self.stream.nextSlice(buf[0..n]); + self.feed(buf[0..n]); if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true; } return false; -- cgit v1.3 From 576cee8a444d07a050df433f043367ca57fe36c4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 19:23:32 -0300 Subject: Newcol shares a column's rows out again when its narrower tags would leave a pane under its tag and two rows, or is refused A seeded sweep of placements (New, pane/new, Edit =, Tty, look, Delcol, deletes, refusals included) over screens 6 to 40 rows high found no refused placement that leaves a pane short since lyltlqnz and lrpzwyom; the pane a row short came from Newcol, which halves a column's width so long tags wrap onto more rows, raising those panes' minimums with no rows moved. Newcol now shares each such column's rows out again, and where a column has not the rows for every minimum there is no new column (Newcol: no space for a column: the panes' tags would not fit), as a size too small is refused. The sweep, 300 seeds, is the test; a user's drag or a tag grown by editing may still take a pane under, as before. Co-Authored-By: Claude Opus 5.5 --- src/ninep/tree.zig | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ src/pardes.zig | 9 +++++++++ 2 files changed, 58 insertions(+) (limited to 'src') diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 30bf073d..c86a689c 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -1578,6 +1578,55 @@ test "editor paths resolve to the same nodes the wire serves" { try testing.expectEqual(before, p.next_serial); } +test "random placements, refused or not, leave no pane under its tag and two rows" { + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var look_line: [4200]u8 = undefined; + const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir}); + var seed: u64 = 0; + while (seed < 300) : (seed += 1) { + var prng = std.Random.DefaultPrng.init(seed); + const r = prng.random(); + const rows = r.intRangeAtMost(u16, 6, 40); + const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + _ = try p.setTestFile("x\n"); + while (p.nextEffect()) |_| {} + for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n"); + if (r.boolean()) p.settings.placement = .pardes; + for (0..40) |step| { + var serials: [pardes.MAX_PANES]u32 = undefined; + var n: usize = 0; + for (p.panes) |slot| if (slot) |pn| { + serials[n] = pn.serial; + n += 1; + }; + if (n == 0) break; + const at = serials[r.uintLessThan(usize, n)]; + const action = r.uintLessThan(u8, 8); + switch (action) { + 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"), + 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }), + 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"), + 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"), + 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"), + 5 => _ = th.wr(p, Node.of(at, .look), looked), + 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"), + else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"), + } + for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) { + std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c }); + for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) }); + return error.TestUnexpectedResult; + }; + } + } +} + test "at the pane cap, pane/new, look and New each say so, and look reads back empty" { const p = try th.withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/src/pardes.zig b/src/pardes.zig index 19fe51d1..22d5f7a1 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5082,6 +5082,15 @@ pub const Pardes = struct { else "Newcol: no space for a column: this one is too narrow to split"); }; + // A narrower column wraps long tags onto more rows, which can leave + // a pane under its tag and two rows: its column's rows are shared + // out again, and where they cannot hold every minimum there is no + // new column, as a size too small is refused. + for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) { + layout.dropColumn(p, c); + layout.compute(p); + return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit"); + }; tagline.enterHeader(p, c); } -- cgit v1.3