From d88e381793c6505bf3f2af43705bd2fa32f55ef6 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 14:37:29 -0300 Subject: One Layer for tags, notices, headers and bodies; a taller tag is one layer of rows; wire v8 src/Layer.zig merges TagLayer and BodyLayer. `rows` (0 = no layer) and a cursor at {x, y}. A tag of N rows is ONE layer of N grid rows: tagHit answers the row as `line`, bodyHit keeps its meaning, and the per-line layer bases (TAG_LINE_LAYER_BASE, HEADER_LINE_LAYER_BASE) are gone, not aliased. Wire v8, the one bump: tag layers carry rows and cursor y, and the frame carries the placed region list. v7 and v9 peers are refused in both directions (server test over both, a new frontend test over both). web: tag_layer_value 11 = rows, 12 = cursor y; app.mjs lays every row. macOS: the Zig side compiles against Layer; pardes.h still sees one row per tag layer (accepted, the macOS shell is ignored for now). No visual change: snapshot goldens and the 17 GUI goldens byte-identical. Shared files touched: pardes.zig, Messages.zig, gui.zig, macos.zig, detached/client.zig, detached/server.zig, detached/wire.zig (plus web.zig, app.mjs, edit.zig, look.zig). Not touched: mouse.zig, tagline.zig, colors.zig, tty.zig, dump.zig, exec.zig, host_io.zig, panes.zig. --- src/Layer.zig | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/Layer.zig (limited to 'src/Layer.zig') diff --git a/src/Layer.zig b/src/Layer.zig new file mode 100644 index 00000000..e979eab7 --- /dev/null +++ b/src/Layer.zig @@ -0,0 +1,198 @@ +//! A band of cells a pixel shell draws over the grid at its own pitch: a +//! pane's, a column's or the workspace's tag rows, a notice chip, or a file +//! body whose compact tree-sitter context rows outnumber its grid rows. The +//! grid under it holds a copy of what fits there (draw.zig joins them), so a +//! terminal client, which has only the grid, loses nothing but the pitch. +//! +//! Its viewport is in grid cells, so compact text never moves a hit target. +//! A tag's rows are each one grid row, their glyphs at the tagline's size; a +//! body's first `context_rows` rows are stacked at the tagline's height, the +//! rest at the body's. `rows == 0` is no layer. +const std = @import("std"); +const pardes = @import("pardes.zig"); +const config = @import("config.zig"); +const limits = @import("memory.zig").limits; + +const Layer = @This(); + +/// `notice` is a pane's transient line -- a builtin's name, a pending leader +/// chord -- carried as a tagline-shaped band so it gets the tagline's height, +/// small-font metrics, band offset and border instead of being a body-grid row +/// wearing a tagline font. +pub const Kind = enum(u8) { workspace, column, pane, notice, body }; + +pub const Metrics = struct { + body_w: u16 = 1, + body_h: u16 = 1, + tagline_w: u16 = 1, + tagline_h: u16 = 1, +}; + +/// A point on a tag, notice or header layer: which row of it and which +/// column at the tagline's pitch. +pub const TagHit = struct { + kind: Kind, + id: u16, + serial: u32, + col: u16, + /// Which row of a taller tag. + line: u16 = 0, + pixel_x: f32, + pixel_y: f32, + metrics: Metrics, +}; + +kind: Kind = .body, +/// The pane slot, or the column, it belongs to. +id: u16 = 0, +serial: u32 = 0, +viewport: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, +cols: u16 = 0, +rows: u16 = 0, +/// A body's compact rows, from its top. +context_rows: u16 = 0, +context_separators: std.bit_set.StaticBitSet(limits.wrap_rows) = .initEmpty(), +/// `rows * cols`, row after row. +cells: []pardes.Cell = &.{}, +cursor: ?struct { x: u16, y: u16, bar: bool = false } = null, +/// A band's fill, where its cells run out. +bg: [3]u8 = .{ 0, 0, 0 }, +/// Vertical offset in rows (negative: above its viewport), for a band +/// that is moving into place. A pixel shell draws it offset and clipped +/// to the rows below the viewport's top; the grid has no such thing. +slide: f32 = 0, +/// How present a notice band is (1 = fully) while it fades up or +/// dissolves. `bg` and the cells are already blended by it; a pixel +/// shell uses it for what it draws around the band, like its rule. +fade: f32 = 1, + +/// Tag columns a viewport this wide holds at the tagline's pitch. +pub fn capacity(layer: *const Layer, metrics: Metrics) u16 { + return @intCast(@min(std.math.maxInt(u16), @max(1, @as(u32, layer.viewport.w) * @max(1, metrics.body_w) / @max(1, metrics.tagline_w)))); +} + +pub fn setContextLines(layer: *Layer, lines: []const i32) void { + layer.context_separators = .initEmpty(); + const count = @min(lines.len, layer.context_rows); + var index: usize = 0; + while (index + 1 < count and index < limits.wrap_rows) : (index += 1) { + if (lines[index + 1] != lines[index] +| 1) layer.context_separators.set(index); + } +} + +pub fn hasContextBorderAfter(layer: *const Layer, index: usize) bool { + if (index >= layer.context_rows or index >= limits.wrap_rows) return false; + return index + 1 == layer.context_rows or layer.context_separators.isSet(index); +} + +/// Pixel top of row `index`: a body's compact rows stacked at the tagline's +/// height, every other row a grid row. +pub fn rowTop(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + const body = @max(1, body_h); + const tag = std.math.clamp(tagline_h, 1, body); + return @as(f32, @floatFromInt(layer.viewport.y)) * body + + @as(f32, @floatFromInt(@min(index, layer.context_rows))) * tag + + @as(f32, @floatFromInt(index -| layer.context_rows)) * body; +} + +pub fn rowHeight(layer: *const Layer, index: u16, body_h: f32, tagline_h: f32) f32 { + return if (index < layer.context_rows) std.math.clamp(tagline_h, 1, @max(1, body_h)) else @max(1, body_h); +} + +const Point = struct { index: u16, col: u16, metrics: Metrics }; + +/// The row and column a window pixel lands on, each row at its own pitch: +/// a tag's at the tagline's width, a body's compact rows too. +fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { + if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; + // A band's hit is refused outright on metrics no host could have; a + // body's has always been floored to a pixel instead. + if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| + if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + const bw = @max(1, body_w); + const bh = @max(1, body_h); + const tw = std.math.clamp(tagline_w, 1, bw); + const th = std.math.clamp(tagline_h, 1, bh); + const dx = x - @as(f32, @floatFromInt(layer.viewport.x)) * bw; + const dy = y - @as(f32, @floatFromInt(layer.viewport.y)) * bh; + if (dx < 0 or dy < 0 or dx >= @as(f32, @floatFromInt(layer.viewport.w)) * bw or dy >= @as(f32, @floatFromInt(layer.viewport.h)) * bh) return null; + const headers = @as(f32, @floatFromInt(layer.context_rows)) * th; + const index: u16 = @intFromFloat(if (dy < headers) @floor(dy / th) else @as(f32, @floatFromInt(layer.context_rows)) + @floor((dy - headers) / bh)); + if (index >= layer.rows) return null; + const pitch = if (layer.kind != .body or index < layer.context_rows) tw else bw; + return .{ + .index = index, + .col = @intFromFloat(@min(@as(f32, @floatFromInt(layer.cols -| 1)), @floor(dx / pitch))), + .metrics = .{ .body_w = @intFromFloat(bw), .body_h = @intFromFloat(bh), .tagline_w = @intFromFloat(tw), .tagline_h = @intFromFloat(th) }, + }; +} + +/// A body layer's pixel as the body cell under it (grid columns, from the +/// pane's text edge). +pub fn bodyHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?pardes.Mouse.BodyHit { + if (layer.kind != .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .serial = layer.serial, .row = point.index, .col = point.col + config.GUTTER, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +/// A band's pixel as the tag cell under it. +pub fn tagHit(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?TagHit { + if (layer.kind == .body) return null; + const point = layer.pointAt(x, y, body_w, body_h, tagline_w, tagline_h) orelse return null; + return .{ .kind = layer.kind, .id = layer.id, .serial = layer.serial, .line = point.index, .col = point.col, .pixel_x = x, .pixel_y = y, .metrics = point.metrics }; +} + +test "compact body layer rows fill reclaimed height and invert exact boundaries" { + const layer: Layer = .{ .serial = 7, .viewport = .{ .x = 4, .y = 3, .w = 40, .h = 10 }, .cols = 40, .rows = 15, .context_rows = 5 }; + try std.testing.expectEqual(@as(f32, 120), layer.rowTop(5, 20, 12)); + try std.testing.expectEqual(@as(f32, 240), layer.rowTop(11, 20, 12)); + try std.testing.expectEqual(@as(f32, 12), layer.rowHeight(4, 20, 12)); + try std.testing.expectEqual(@as(f32, 20), layer.rowHeight(5, 20, 12)); + for (0..12) |index| { + const r: u16 = @intCast(index); + const y = layer.rowTop(r, 20, 12) + layer.rowHeight(r, 20, 12) / 2; + const width: f32 = if (r < 5) 6 else 10; + const hit = layer.bodyHit(40 + 3.5 * width, y, 10, 20, 6, 12).?; + try std.testing.expectEqual(r, hit.row); + try std.testing.expectEqual(@as(u16, 3 + config.GUTTER), hit.col); + } + try std.testing.expectEqual(@as(u16, 4), layer.bodyHit(45, 119.99, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 5), layer.bodyHit(45, 120, 10, 20, 6, 12).?.row); + try std.testing.expectEqual(@as(u16, 11), layer.bodyHit(45, 259.99, 10, 20, 6, 12).?.row); + try std.testing.expect(layer.bodyHit(45, 260, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(39.99, 80, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(std.math.nan(f32), 80, 10, 20, 6, 12) == null); +} + +test "compact context borders separate source gaps and preserve multiline headers" { + var layer: Layer = .{ .context_rows = 6 }; + layer.setContextLines(&.{ 3, 4, 8, 9, 10, 17 }); + const expected = [_]bool{ false, true, false, false, true, true, false }; + for (expected, 0..) |border, index| try std.testing.expectEqual(border, layer.hasContextBorderAfter(index)); + layer.context_rows = 2; + layer.setContextLines(&.{ 9, 10 }); + try std.testing.expect(!layer.hasContextBorderAfter(0)); + try std.testing.expect(layer.hasContextBorderAfter(1)); + try std.testing.expect(!layer.hasContextBorderAfter(4)); + try std.testing.expect(!layer.context_separators.isSet(4)); +} + +test "tag text capacity uses its own pitch and excludes the physical grip" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 1 }, .cols = 90, .rows = 1 }; + try std.testing.expectEqual(@as(u16, 63), layer.capacity(.{ .body_w = 10, .tagline_w = 6 })); + try std.testing.expect(layer.tagHit(119.99, 85, 10, 20, 6, 12) == null); + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 85, 10, 20, 6, 12).?.col); + try std.testing.expectEqual(@as(u16, 50), layer.tagHit(423, 85, 10, 20, 6, 12).?.col); + try std.testing.expect(layer.tagHit(500, 85, 10, 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, std.math.nan(f32), 20, 6, 12) == null); + try std.testing.expect(layer.tagHit(120, 85, 10, 20, 0, 12) == null); +} + +test "a taller tag's rows are grid rows, each hit on its own line" { + const layer: Layer = .{ .kind = .pane, .id = 2, .serial = 7, .viewport = .{ .x = 12, .y = 4, .w = 38, .h = 3 }, .cols = 90, .rows = 3 }; + try std.testing.expectEqual(@as(u16, 0), layer.tagHit(120, 80, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 1), layer.tagHit(120, 100, 10, 20, 6, 12).?.line); + try std.testing.expectEqual(@as(u16, 2), layer.tagHit(120, 139.99, 10, 20, 6, 12).?.line); + try std.testing.expect(layer.tagHit(120, 140, 10, 20, 6, 12) == null); + try std.testing.expect(layer.bodyHit(120, 100, 10, 20, 6, 12) == null); +} -- cgit v1.3 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/Layer.zig | 9 ++-- src/Text.zig | 3 +- src/draw.zig | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 133 insertions(+), 16 deletions(-) (limited to 'src/Layer.zig') diff --git a/src/Layer.zig b/src/Layer.zig index e979eab7..a9827304 100644 --- a/src/Layer.zig +++ b/src/Layer.zig @@ -106,9 +106,12 @@ const Point = struct { index: u16, col: u16, metrics: Metrics }; fn pointAt(layer: *const Layer, x: f32, y: f32, body_w: f32, body_h: f32, tagline_w: f32, tagline_h: f32) ?Point { if (layer.rows == 0 or !std.math.isFinite(x) or !std.math.isFinite(y)) return null; // A band's hit is refused outright on metrics no host could have; a - // body's has always been floored to a pixel instead. - if (layer.kind != .body) for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| - if (!std.math.isFinite(metric) or metric < 1 or metric > std.math.maxInt(u16)) return null; + // body's has always been floored to a pixel instead, and both refuse + // what is no number or past a u16 (a host's floats: web's exports). + for ([_]f32{ body_w, body_h, tagline_w, tagline_h }) |metric| { + if (!std.math.isFinite(metric) or metric > std.math.maxInt(u16)) return null; + if (layer.kind != .body and metric < 1) return null; + } const bw = @max(1, body_w); const bh = @max(1, body_h); const tw = std.math.clamp(tagline_w, 1, bw); diff --git a/src/Text.zig b/src/Text.zig index 43dd8a95..f6780935 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -118,7 +118,8 @@ pub fn fromModalCursor(t: *Text, c: modal.Cursor) void { pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { if (lines.len == 0) return c; - const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; + // A cursor past the lines (its text changed under it) moves from the last. + const row = if (down) @min(c.row + 1, lines.len - 1) else @min(c.row, lines.len) -| 1; const target = lines[row]; if (target.len == 0) return .{ .row = row, .col = 0 }; const source = if (c.row < lines.len) lines[c.row] else ""; 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