From 2663a4d1b3170cb2a4dca5e6f1f3535f44b30e44 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 16:47:26 -0300 Subject: No panic from a notice wider than its band, or from the rest of its class The showcase GUI died in joinNotice on a long shader-error notice: with the tagline narrower than the body, the band hides more columns (skip) than a long message has padding (pad), and `skip..@min(pad, ..)` ran backwards. An audit of draw.zig, Layer.zig, Presentation.zig and animation.zig found the same class elsewhere, all fixed: - a multi-row pane tag or header scrolled for its caret's line sliced a shorter row past its end (paintPaneTag, renderHeaderLayer); - a zero-width column tag kept an earlier frame's viewport, and a column move's clip took it below zero (now saturating, and the early return resets the viewport and caret); - a tag line past 65535 columns overflowed tag_scroll's u16; - a hover left over from a bigger window dashed past the grid; - a border drag over a column that went away, a move preview past a shrunken grid, a column-move rail at zero columns; - Layer.pointAt cast a host's unchecked float metrics (web) to u16; - Text.insertVerticalCursor read past its lines for a cursor whose text changed under it. Regressions: a test of a notice wider than its band through Msg and through setMessage (the Post.zig compile-error path), which panics without the fix; and a monkey over the core at a gui's metrics (random resizes to a cell, long notices, prompts, multi-row tags, clicks, keys), 400 steps in the suite, PARDES_FUZZ_SEED/STEPS for longer runs; 60 seeds x 3000 steps clean. Shared files touched: none of the list (draw.zig, Layer.zig, Text.zig). --- src/draw.zig | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 125 insertions(+), 12 deletions(-) (limited to 'src/draw.zig') diff --git a/src/draw.zig b/src/draw.zig index 3798bf59..0dbf5bae 100644 --- a/src/draw.zig +++ b/src/draw.zig @@ -219,13 +219,16 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (col > first) s.addRegion(.{ .kind = .guide, .rect = .{ .x = first, .y = d.cur_y, .w = col - first, .h = 1 } }); }; }, - .border_h => |d| if (d.cur_y < s.rows) { + // A column that went away mid-drag has nothing to guide. + .border_h => |d| if (d.cur_y < s.rows and d.col < p.ncol and p.col_x[d.col] < s.cols) { + const end = @min(s.cols, p.col_x[d.col] + p.col_w[d.col]); var col = p.col_x[d.col]; - while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); - s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = p.col_w[d.col], .h = 1 } }); + while (col < end) : (col += 1) s.overlayDash(col, d.cur_y, "╌"); + s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[d.col], .y = d.cur_y, .w = end - p.col_x[d.col], .h = 1 } }); }, .column_move => |d| rail: { const source = layout.columnBySerial(p, d.serial) orelse break :rail; + if (s.cols == 0) break :rail; // Where the column's left edge would land, reordered or not. const edge: u16 = switch (layout.columnDrop(p, source, d.cur_x, d.grab)) { .stay => break :rail, @@ -258,7 +261,7 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { if (row > run) s.addRegion(.{ .kind = .guide, .rect = .{ .x = x, .y = run, .w = 1, .h = row - run } }); }, .move => |d| if (d.cur_x < s.cols) { - if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| { + if (layout.movePlacement(p, d.id, d.cur_x, d.cur_y)) |placement| if (placement.row < s.rows and p.col_x[placement.preview_col] + p.col_w[placement.preview_col] <= s.cols) { var col: u16 = p.col_x[placement.preview_col]; while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) { s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = chrome.lineno } }); @@ -266,9 +269,10 @@ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface { s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); s.addRegion(.{ .kind = .guide, .rect = .{ .x = p.col_x[placement.preview_col], .y = placement.row, .w = p.col_w[placement.preview_col], .h = 1 } }); s.addRegion(.{ .kind = .guide, .rect = .{ .x = d.cur_x, .y = placement.row, .w = 1, .h = 1 } }); - } + }; }, - .none, .select => if (p.pointer_inside) { + // A hover left over from a bigger window points past this one. + .none, .select => if (p.pointer_inside and p.hover_row < s.rows and p.hover_col < s.cols) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { @@ -554,7 +558,7 @@ pub fn paintHeaders(p: *Pardes, s: *Surface, arena: std.mem.Allocator) !void { try renderHeaderLayer(p, arena, layer, .column, @intCast(c), region.rect, tagline.columnTag(p, c), top, bg, fg); if (p.col_x[c] < s.cols) joinHeaderRows(s, layer, fg); // Laid out at its full width; only shown short of the rail. - layer.viewport.w -= region.clip; + layer.viewport.w -|= region.clip; }; } @@ -623,7 +627,10 @@ fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, kind: TagKind, id: u16, viewport: Rect, text: []const u8, top: usize, bg: [3]u8, fg: [3]u8) !void { _ = arena; if (viewport.w == 0 or viewport.h == 0) { + // No layer, and nothing of an earlier frame's left in it. layer.rows = 0; + layer.viewport = viewport; + layer.cursor = null; return; } var widest: usize = 0; @@ -656,7 +663,8 @@ fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, layer: *pardes.Layer, const shown = modal.lineSlice(text, index + top); var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells[index * columns ..][0..columns] }; surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, shown[panes.File.rawAtDisplay(shown, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); + // The scroll is the caret's line's: a shorter line shows nothing. + _ = surface.print(0, 0, columns, shown[@min(shown.len, panes.File.rawAtDisplay(shown, scroll))..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); // The word under the pointer, a Look or Exec target: on a header even // while it is edited, on a notice, and on the grid's copy of either // (joined from this); not on a column a carried column's pointer is @@ -811,12 +819,13 @@ fn joinNotice(p: *Pardes, s: *Surface, arena: std.mem.Allocator, region: Region) joinCells(s, region.rect.x, region.rect.y, layer.cells[0..layer.cols], skip, chip, .{ .rgb = fg }); // The band's padding before the text is the chip's fill, which carries // no ink of its own (the layer prints it as spaces in the band's ink). - for (skip..@min(pad, skip + chip)) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; + // A notice wider than its band has less padding than the band hides. + for (skip..@max(skip, @min(pad, skip + chip))) |col| s.at(region.rect.x + @as(u16, @intCast(col - skip)), region.rect.y).style.fg = .default; if (layer.cursor) |caret| { if (caret.x >= skip) s.cursor = .{ .x = region.rect.x + caret.x - @as(u16, @intCast(skip)), .y = region.rect.y, .bar = caret.bar }; } // Laid out at its full width; only shown short of the rail. - layer.viewport.w -= region.clip; + layer.viewport.w -|= region.clip; } pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { @@ -873,10 +882,12 @@ pub fn paintPaneTag(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pa raw = modal.nextGrapheme(line, raw); scroll = panes.File.rawDisplayCol(line, raw); } - pane.tag_scroll = @intCast(scroll); + // A line past a u16 of columns keeps its caret off the band. + pane.tag_scroll = @intCast(@min(scroll, std.math.maxInt(u16))); } else if (pane.focus != .tag) pane.tag_scroll = 0; for (lines[0..rows], 0..) |line, row| { - const start = panes.File.rawAtDisplay(line, pane.tag_scroll); + // The scroll is the caret's line's: a shorter row shows nothing. + const start = @min(line.len, panes.File.rawAtDisplay(line, pane.tag_scroll)); _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); } const tag = lines[0]; @@ -1064,6 +1075,108 @@ test "the word under the pointer on a notice is lit in its layer and in the grid try std.testing.expect(lit_in_layer); } +test "a notice wider than its band, as a shader's compile error is, draws its end and nothing past it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20 }); + defer p.deinit(); + // A gui-shaped core: its tagline is narrower than the body, so a notice's + // band holds more columns than its chip has cells. + p.update(.{ .resize = .{ .cols = 80, .rows = 20, .row_metrics = .{ .body_w = 10, .body_h = 20, .tagline_w = 6, .tagline_h = 12 } } }); + _ = try p.setTestFile("text\n"); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const long = "Shader /home/someone/shaders/test_shadertoy_invalid.glsl: error: Linking fragment stage: No function definition (body) found: " ** 3; + for ([_][]const u8{ "Msg " ++ long, "" }) |line| { + // The Msg builtin, and the path a failed compile takes (Post.zig). + if (line.len > 0) try std.testing.expect(p.executeBuiltinLine(p.active, line)) else p.setMessage(p.active, long); + p.sync(); + for (0..30) |_| p.update(.tick); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + const notice = for (s.regionList()) |region| { + if (region.kind == .notice) break region; + } else return error.TestExpectedNotice; + // Text up to the chip's blank last cell, and no padding ink. + const row = s.cells[@as(usize, notice.rect.y) * s.cols ..][notice.rect.x..][0..notice.rect.w]; + try std.testing.expectEqualStrings(" ", row[row.len - 1].grapheme()); + try std.testing.expect(!std.mem.eql(u8, " ", row[row.len - 2].grapheme())); + } +} + +test "a monkey over notices, prompts, tags and tiny panes at a gui's metrics never panics" { + // The snapshot harness runs the tty, whose tagline pitch is the body's, + // so it never reaches what a narrower tagline does to a notice's band: + // this drives the core at a gui's metrics instead. PARDES_FUZZ_STEPS + // runs it longer (the suite's run is short). + const steps: usize = if (std.c.getenv("PARDES_FUZZ_STEPS")) |v| std.fmt.parseInt(usize, std.mem.span(v), 10) catch 400 else 400; + const seed: u64 = if (std.c.getenv("PARDES_FUZZ_SEED")) |v| std.fmt.parseInt(u64, std.mem.span(v), 10) catch 7 else 7; + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + _ = try p.setTestFile("fuzz\n\u{754c}\u{754c} wide\n" ** 20); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const words = [_][]const u8{ "error:", "\u{754c}", "x", "Shader /tmp/a.glsl:", "e\u{301}", "\t", "long-" ** 20, " " }; + const commands = [_][]const u8{ "New", "Newcol", "Collapse", "Tagbottom", "ColumnTags", "TreeContext", "Wrap", "Find", "Del", "FocusTint", "Debug" }; + var text: std.ArrayListUnmanaged(u8) = .empty; + defer text.deinit(std.testing.allocator); + for (0..steps) |_| { + switch (random.uintLessThan(u8, 8)) { + // A resize, down to a cell, at a gui's metrics: a tagline no + // wider and no taller than the body's cell, often narrower. + 0 => { + const body_w = random.intRangeAtMost(u16, 6, 12); + const body_h = random.intRangeAtMost(u16, 12, 24); + const cols = if (random.boolean()) random.intRangeAtMost(u16, 1, 12) else random.intRangeAtMost(u16, 13, 140); + const rows = if (random.boolean()) random.intRangeAtMost(u16, 1, 6) else random.intRangeAtMost(u16, 7, 50); + p.update(.{ .resize = .{ .cols = cols, .rows = rows, .row_metrics = .{ .body_w = body_w, .body_h = body_h, .tagline_w = random.intRangeAtMost(u16, 2, body_w), .tagline_h = random.intRangeAtMost(u16, 6, body_h) } } }); + }, + // A message, as long as a compile error or longer. + 1, 2 => { + text.clearRetainingCapacity(); + for (0..random.uintAtMost(usize, 60)) |_| try text.appendSlice(std.testing.allocator, words[random.uintLessThan(usize, words.len)]); + if (random.boolean()) p.setMessage(p.active, text.items) else { + const line = try std.fmt.allocPrint(arena.allocator(), "Msg {s}", .{text.items}); + _ = p.executeBuiltinLine(p.active, line); + } + }, + 3 => _ = p.executeBuiltinLine(p.active, commands[random.uintLessThan(usize, commands.len)]), + // Typing, escaping and entering, into a prompt when one is up. + 4 => p.update(.{ .key = .{ .cp = switch (random.uintLessThan(u8, 9)) { + 0 => pardes.Key.escape, + 1 => pardes.Key.enter, + 2 => 0x754c, + 3 => pardes.Key.end, + 4 => pardes.Key.home, + 5 => pardes.Key.up, + 6 => pardes.Key.down, + else => 'a' + random.uintLessThan(u21, 26), + } } }), + // A tag of several rows. + 5 => if (p.panes[p.active]) |pane| { + text.clearRetainingCapacity(); + for (0..random.uintAtMost(usize, 5)) |_| try text.appendSlice(std.testing.allocator, if (random.boolean()) " Look\nmore \u{754c}" else "\n" ++ "x" ** 150); + if (pane.tag.own) |own| std.testing.allocator.free(own); + pane.tag.own = try std.testing.allocator.dupe(u8, text.items); + }, + // Hover, and clicks: into a tag, a header, a body, a grip. + 6 => { + const col = random.uintAtMost(u16, p.screen_w); + const row = random.uintAtMost(u16, p.screen_h); + if (random.boolean()) p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }) else { + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = col, .row = row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = col, .row = row } }); + } + }, + else => for (0..random.uintAtMost(usize, 20)) |_| p.update(.tick), + } + p.sync(); + _ = arena.reset(.retain_capacity); + const s = try p.render(arena.allocator()); + p.acknowledgePanelPresentation(s.panelTracks()); + } +} + test "a wide glyph cut by a tag's or a notice's edge is joined into the grid as blank, never half drawn" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 41, .rows = 12 }); defer p.deinit(); -- cgit v1.3