Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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 = .{
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; [
Expand Down
145 changes: 144 additions & 1 deletion lib/phantom/backend/tui_cells.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
2 changes: 2 additions & 0 deletions lib/phantom/icon.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
72 changes: 72 additions & 0 deletions lib/phantom/icon/builtin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}