diff --git a/build.zig.zon b/build.zig.zon index fb7ffa8..8aa8c1d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,12 +9,12 @@ .hash = "webidl_zig-0.1.0-FrIyV_uUBgAGtNwP-OkLegPJMWUnpeKuPqDdVbWyC9Fn", }, .lattice = .{ - .url = "git+https://github.com/Midstall/lattice#17053e425b5e472f3a6a57a382fad18b0b4ea356", - .hash = "lattice-0.1.0-TdtxHEviCQCHdTwR8eVLuDYwIIKcxFQLjQs18onvIC1q", + .url = "git+https://github.com/Midstall/lattice#e22083d29efc14d30887888eded76424bf25b72e", + .hash = "lattice-0.1.0-TdtxHEviCQCZaOH7y3ls5kW8sWTefXfiT8g-MUJy913z", }, .prism = .{ - .url = "git+https://github.com/Midstall/prism#f24fce92e8306e581c1e6fa6fe36c924183bab6a", - .hash = "prism-0.1.0-PSE12AbIOAAMnhpTrIYz4xOe1wphrH0hdEBig6v50waz", + .url = "git+https://github.com/Midstall/prism#2b50ec14c7e6c0795c22f9778483d0954ff7550f", + .hash = "prism-0.1.0-PSE12EzaOADdJQZq2Z9qoEC978am8KnR-bX117W6SpoQ", }, }, .paths = .{ diff --git a/flake.nix b/flake.nix index 731d514..a5a41e3 100644 --- a/flake.nix +++ b/flake.nix @@ -80,7 +80,7 @@ zigDeps = pkgs.zig.fetchDeps { inherit (finalAttrs) src pname version; - hash = "sha256-yj/4cpEwPMhbKvgnichxCyAILE/FE9wnb0VEuK1x/og="; + hash = "sha256-Xt8LfYQ9tks5hb2x9GsM5a1kMQFKUzX7kKC2BNUTbd4="; }; nativeBuildInputs = with pkgs; [ diff --git a/lib/phantom/backend/tui_cells.zig b/lib/phantom/backend/tui_cells.zig index 0816b53..e9d6ce6 100644 --- a/lib/phantom/backend/tui_cells.zig +++ b/lib/phantom/backend/tui_cells.zig @@ -11,6 +11,7 @@ const DisplayList = dl.DisplayList; const text = @import("../text.zig"); const mono = @import("../text/mono.zig"); const image_mod = @import("../image/Image.zig"); +const icon_builtin = @import("../icon/builtin.zig"); const Attrs = grid_mod.Attrs; pub const Ctx = struct { @@ -274,7 +275,6 @@ fn averageColor(img: *image_mod) Rgb { } fn paintIconFallback(grid: *CellGrid, p: dl.IconPrimitive, ctx: Ctx) void { - // One solid block. The icon shape needs a graphics protocol, and mode B has none. const rect = geom.PhysicalRect{ .x = p.origin.x, .y = p.origin.y, @@ -283,9 +283,42 @@ fn paintIconFallback(grid: *CellGrid, p: dl.IconPrimitive, ctx: Ctx) void { }; const b = cellBounds(rect, ctx, grid); const color = Rgb.fromColor(p.color); + + // A mark that has no coverage draws nothing, which is what the block below + // does at alpha zero. Any coverage above that draws the whole character: a + // cell is either the character or it is not, so there is no partly drawn + // one. `paintText` treats its own colour the same way. + if (p.color.a <= 0) return; + + if (icon_builtin.cellMarkFor(p.id)) |mark| { + paintCellMark(grid, b, mark, color); + return; + } + // No character means this mark, so it degrades to one solid block. The icon + // shape needs a graphics protocol, and mode B has none. fillCellRect(grid, b, color, p.color.a); } +/// Put `mark` into the cells of `b`, one character for a symbol and the whole +/// box for a rule. +fn paintCellMark(grid: *CellGrid, b: Bounds, mark: icon_builtin.CellMark, color: Rgb) void { + // An empty box means the mark sits off the grid, or rounded away to nothing. + if (b.c1 <= b.c0 or b.r1 <= b.r0) return; + if (mark.tile) { + var row = b.r0; + while (row < b.r1) : (row += 1) { + var col = b.c0; + while (col < b.c1) : (col += 1) grid.putChar(col, row, mark.cp, color, .{}); + } + return; + } + // The middle of the box, biased left and up. A square mark is two cells wide + // whenever a cell is taller than it is wide, which is every terminal, and + // the box then has no true middle. Left is the better of the two: a mark + // usually leads the text beside it, and the far cell would put a gap there. + grid.putChar(b.c0 + (b.c1 - b.c0 - 1) / 2, b.r0 + (b.r1 - b.r0 - 1) / 2, mark.cp, color, .{}); +} + /// Shift a rect by the current scroll offset. `RenderScrollView.paintFn` paints its /// child at the plain, unscrolled offset and leaves the scroll to the backend (see /// `prism.zig`'s `appendQuad` doing the same for the GPU path), so every primitive @@ -1015,3 +1048,113 @@ test "an extreme scroll offset does not crash the text or rect paths" { try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); try std.testing.expectEqual(@as(u8, 0), g.cellAt(0, 0).?.bg.r); } + +test "a mark with a character becomes that character, not a block" { + const gpa = std.testing.allocator; + var g = try CellGrid.init(gpa, 8, 8); + defer g.deinit(); + g.clear(.{ .r = 0, .g = 0, .b = 0 }); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + // One cell, at the origin. + try list.append(gpa, .{ .icon = .{ + .id = .check, + .size = 16, + .color = .{ .r = 1, .g = 0, .b = 0, .a = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + + try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); + const cell = g.cellAt(0, 0).?; + try std.testing.expectEqual(@as(u21, '\u{2713}'), cell.ch); + try std.testing.expectEqual(@as(u8, 255), cell.fg.r); + // A block would have painted the background instead, which is the bug this + // test is about: the tick has to be readable, so the cell keeps its own. + try std.testing.expectEqual(@as(u8, 0), cell.bg.r); +} + +test "a rule fills every cell it covers, so a stacked rail has no gap" { + const gpa = std.testing.allocator; + var g = try CellGrid.init(gpa, 8, 8); + defer g.deinit(); + g.clear(.{ .r = 0, .g = 0, .b = 0 }); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + // Three cells tall, which is the shape a provenance rail asks for. + try list.append(gpa, .{ .icon = .{ + .id = .rule_vertical, + .size = 48, + .color = .{ .r = 1, .g = 1, .b = 1, .a = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + + try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); + for (0..3) |row| { + try std.testing.expectEqual(@as(u21, '\u{2502}'), g.cellAt(0, @intCast(row)).?.ch); + } +} + +test "a symbol taller than one cell draws once, not once for every cell" { + const gpa = std.testing.allocator; + var g = try CellGrid.init(gpa, 8, 8); + defer g.deinit(); + g.clear(.{ .r = 0, .g = 0, .b = 0 }); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .icon = .{ + .id = .check, + .size = 48, + .color = .{ .r = 1, .g = 1, .b = 1, .a = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + + try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); + var ticks: usize = 0; + for (0..8) |row| { + for (0..8) |col| { + if (g.cellAt(@intCast(col), @intCast(row)).?.ch == '\u{2713}') ticks += 1; + } + } + try std.testing.expectEqual(@as(usize, 1), ticks); +} + +test "a mark with no character keeps the block it always had" { + const gpa = std.testing.allocator; + var g = try CellGrid.init(gpa, 8, 8); + defer g.deinit(); + g.clear(.{ .r = 0, .g = 0, .b = 0 }); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .icon = .{ + .id = .torii, + .size = 16, + .color = .{ .r = 1, .g = 0, .b = 0, .a = 1 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + + try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); + try std.testing.expectEqual(@as(u8, 255), g.cellAt(0, 0).?.bg.r); +} + +test "a mark with no coverage draws nothing" { + const gpa = std.testing.allocator; + var g = try CellGrid.init(gpa, 8, 8); + defer g.deinit(); + g.clear(.{ .r = 0, .g = 0, .b = 0 }); + + var list: DisplayList = .{}; + defer list.deinit(gpa); + try list.append(gpa, .{ .icon = .{ + .id = .check, + .size = 16, + .color = .{ .r = 1, .g = 0, .b = 0, .a = 0 }, + .origin = .{ .x = 0, .y = 0 }, + } }); + + try render(&g, list, .{ .cell_w = 8, .cell_h = 16 }); + try std.testing.expectEqual(@as(u21, ' '), g.cellAt(0, 0).?.ch); +} diff --git a/lib/phantom/icon.zig b/lib/phantom/icon.zig index 8ef9eaa..a6889b1 100644 --- a/lib/phantom/icon.zig +++ b/lib/phantom/icon.zig @@ -4,6 +4,8 @@ pub const stroke = @import("icon/stroke.zig"); pub const builtin = @import("icon/builtin.zig"); pub const Id = builtin.Id; pub const pathFor = builtin.pathFor; +pub const CellMark = builtin.CellMark; +pub const cellMarkFor = builtin.cellMarkFor; pub const torii = builtin.torii; pub const Point = path.Point; pub const Verb = path.Verb; diff --git a/lib/phantom/icon/builtin.zig b/lib/phantom/icon/builtin.zig index 99ee5ce..dd513a8 100644 --- a/lib/phantom/icon/builtin.zig +++ b/lib/phantom/icon/builtin.zig @@ -42,6 +42,51 @@ pub const Id = enum(u32) { rule_horizontal = 11, }; +/// What a cell backend draws in place of the mark. +pub const CellMark = struct { + /// The codepoint to put in the cell. Every one of them is a single column + /// wide, so a mark never pushes the text beside it out of place. + cp: u21, + /// True for a mark that continues past its own box. A rule fills every cell + /// it covers, because a line with a gap in it is a different line. A symbol + /// draws once, in the cell at the middle of the box, because a tick + /// repeated four times is four ticks. + tile: bool = false, +}; + +/// The character a cell backend draws for `id`, or null when no character means +/// the mark and the backend must fall back to a block. +/// +/// A cell backend cannot draw a path, but it does not need to: the terminal +/// draws text with its own font, and a text font has these. This is the reverse +/// of the reason the paths exist (see `Id`). The bundled display faces have no +/// tick, so pixel mode draws one itself; the terminal font has one, so cell +/// mode asks for it by number. +/// +/// The four chevrons come out as the geometric triangles. A terminal font is +/// much more likely to have those than any of the chevron ornaments, and they +/// give the same direction, which is all the mark says. +pub fn cellMarkFor(id: Id) ?CellMark { + return switch (id) { + // The logomark is a drawing of a gate. No character stands for it, so a + // cell backend keeps its block. + .torii => null, + .check => .{ .cp = '\u{2713}' }, + .cross => .{ .cp = '\u{2717}' }, + .chevron_left => .{ .cp = '\u{25C0}' }, + .chevron_right => .{ .cp = '\u{25B6}' }, + .chevron_up => .{ .cp = '\u{25B2}' }, + .chevron_down => .{ .cp = '\u{25BC}' }, + .arrow_right => .{ .cp = '\u{2192}' }, + // Plus and minus are ASCII on purpose. Every font has them, and the + // typographic minus buys nothing at the size of one cell. + .plus => .{ .cp = '+' }, + .minus => .{ .cp = '-' }, + .rule_vertical => .{ .cp = '\u{2502}', .tile = true }, + .rule_horizontal => .{ .cp = '\u{2500}', .tile = true }, + }; +} + /// The centreline of `id`. pub fn pathFor(id: Id) path.Path { return switch (id) { @@ -195,6 +240,7 @@ pub const torii = path.Path{ .verbs = &.{ const std = @import("std"); const stroke = @import("stroke.zig"); const raster = @import("../text/raster.zig"); +const mono = @import("../text/mono.zig"); /// Coverage of the bitmap cell holding the grid point (x, y), or 0 when that /// cell lies outside the bitmap, which is the same thing as no coverage. @@ -461,3 +507,29 @@ test "the check mark is a tick and not a V: its vertex sits left of centre" { try std.testing.expectEqual(b.min_y, vertex.y); try std.testing.expect(vertex.x < grid / 2); } + +test "every mark a terminal font can draw is one column wide" { + // A mark two columns wide would push the text beside it out of place, and + // the cell backend has no way to know that happened. + inline for (@typeInfo(Id).@"enum".fields) |f| { + const id: Id = @enumFromInt(f.value); + if (cellMarkFor(id)) |mark| { + try std.testing.expectEqual(@as(u2, 1), mono.wcwidth(mark.cp)); + } + } +} + +test "only the rules tile, and every interface mark has a character" { + inline for (@typeInfo(Id).@"enum".fields) |f| { + const id: Id = @enumFromInt(f.value); + const mark = cellMarkFor(id); + if (id == .torii) { + // The logomark is a drawing, and no character means it. + try std.testing.expect(mark == null); + continue; + } + try std.testing.expect(mark != null); + const tiles = (id == .rule_vertical or id == .rule_horizontal); + try std.testing.expectEqual(tiles, mark.?.tile); + } +}