summaryrefslogtreecommitdiff
path: root/src/draw.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 16:47:26 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit2663a4d1b3170cb2a4dca5e6f1f3535f44b30e44 (patch)
tree070aca7142684fe4c1c646e0fbd4d592951221dd /src/draw.zig
parenta007457cb007b03f310bca55c339e8cd5d9baafc (diff)
downloadpardes-2663a4d1b3170cb2a4dca5e6f1f3535f44b30e44.tar.gz
pardes-2663a4d1b3170cb2a4dca5e6f1f3535f44b30e44.zip
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).
Diffstat (limited to 'src/draw.zig')
-rw-r--r--src/draw.zig137
1 files changed, 125 insertions, 12 deletions
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();