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
26 changes: 22 additions & 4 deletions lib/phantom/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,28 @@ pub const App = struct {
// stdout alone and never falls back to stderr.
const is_tty = std.Io.File.stdout().isTty(init.io) catch false;
// Asked of lattice, not guessed from the environment: see
// `selectBackend`, and `window.available` for what lattice is asked.
const window_possible = phantom.window.available(init.gpa, init.io, init.environ_map);
switch (selectBackend(init.environ_map, window_possible, is_tty)) {
.gpu => return phantom.window.App.run(init, root, .{}),
// `selectBackend`, and `window.open` for what lattice is asked.
//
// The window it opens IS the window the session runs on. Opening one is
// the only honest way to know a window is possible, so the answer comes
// with the thing itself, and dropping it here would mean connecting a
// second time for something already in hand.
const opts = phantom.window.Options{};
var opened = phantom.window.open(init.gpa, init.io, init.environ_map, opts);
// Whatever this function does next, an unused window is given back. Every
// path that takes it over clears this first, so it is closed once or not
// at all.
defer if (opened) |*o| o.close();

switch (selectBackend(init.environ_map, opened != null, is_tty)) {
.gpu => {
const win = opened orelse
// Only reachable through `PHANTOM_BACKEND=gpu`, which asks
// for the window path on a machine that has no window.
return error.NoWindowBackend;
opened = null;
return phantom.window.App.runOn(init, win, root, opts);
},
.tui => return phantom.Tui.run(init, root, .{}),
.none => {
// A clear message, because "it did nothing" is the worst outcome here.
Expand Down
194 changes: 159 additions & 35 deletions lib/phantom/backend/prism.zig
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,41 @@ pub const PrismBackend = struct {
return self.atlas.ensureCoverage(self.gpa, key, cov);
}

/// Append the two triangles that draw one atlas bitmap, with its top-left at
/// `x`, `y` in device pixels and tinted by `color`.
///
/// A glyph, an icon and a mark standing in for a missing glyph are the same
/// thing here: coverage in one atlas, one pipeline, one tint by vertex
/// colour. The caller works out where the bitmap goes, which is the only
/// part that differs between them.
fn appendCoverageQuad(
self: *PrismBackend,
batch: *Batcher,
entry: GlyphAtlas.Entry,
x: f32,
y: f32,
color: geom.Color,
viewport: geom.PhysicalSize,
) !void {
const w: f32 = @floatFromInt(entry.w);
const h: f32 = @floatFromInt(entry.h);
const tl = toClip(x, y, viewport, self.flip_y);
const tr = toClip(x + w, y, viewport, self.flip_y);
const br = toClip(x + w, y + h, viewport, self.flip_y);
const bl = toClip(x, y + h, viewport, self.flip_y);
const cr = color.r;
const cg = color.g;
const cb = color.b;
const ca = color.a;
// Two triangles (CCW): TL-TR-BR, TL-BR-BL.
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tr[0], .y = tr[1], .u = entry.u1, .v = entry.v0, .r = cr, .g = cg, .b = cb, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = bl[0], .y = bl[1], .u = entry.u0, .v = entry.v1, .r = cr, .g = cg, .b = cb, .a = ca });
}

/// Rasterize `list` into `target`. The list is walked ONCE and the draws are
/// emitted in list order, so a primitive covers everything before it and nothing
/// after it. Consecutive primitives of one kind still share a single draw.
Expand Down Expand Up @@ -528,6 +563,44 @@ pub const PrismBackend = struct {
// Cast the type-erased font pointer back to the concrete type.
const font: *text.Font = @ptrCast(@alignCast(run.font));
for (run.glyphs) |g| {
// A face with no glyph for this codepoint draws `.notdef`,
// which is a replacement box. The bundled faces are display
// faces and cover little outside ASCII, so an interface that
// writes a tick or a chevron in ordinary text gets boxes here
// while a terminal, drawing with its own font, gets the real
// character. Standing a built-in mark in its place closes
// that split: the two backends show the same thing, and the
// caller writes the character it means.
//
// The mark goes in a square box the size of the text, resting
// on the baseline. Every built-in is drawn inside a margin on
// a centred grid, so that lands a tick at about cap height
// and the midline dots of an ellipsis at about half of it,
// which is where each belongs beside text. Layout already
// reserved this codepoint's advance, so the mark drops into
// the space the box would have taken and nothing shifts.
if (!font.hasGlyph(g.cp)) {
if (icon_builtin.iconForCodepoint(g.cp)) |id| {
const side = run.size;
const entry = try self.ensureIcon(id, .{ .width = side, .height = side });
if (entry.w == 0 or entry.h == 0) continue;
const baseline = run.origin.y + run.ascent + g.y;
// The icon arm below places a bitmap at
// `box_top + box_height + entry.top`. With the box
// bottom on the baseline that is
// `(baseline - side) + side + entry.top`, so the
// side cancels and the baseline carries it.
try self.appendCoverageQuad(
&batch,
entry,
run.origin.x + g.x + @as(f32, @floatFromInt(entry.left)) - cur_off.x,
baseline + @as(f32, @floatFromInt(entry.top)) - cur_off.y,
run.color,
viewport,
);
continue;
}
}
const entry = try self.atlas.ensure(self.gpa, font, run.size, g.cp);
// Skip zero-size glyphs (whitespace, missing).
if (entry.w == 0 or entry.h == 0) continue;
Expand All @@ -541,24 +614,7 @@ pub const PrismBackend = struct {
// origin.y + ascent (origin is the run's top-left).
const gx: f32 = run.origin.x + g.x + @as(f32, @floatFromInt(entry.left)) - cur_off.x;
const gy: f32 = run.origin.y + run.ascent + g.y + @as(f32, @floatFromInt(entry.top)) - cur_off.y;
const gw: f32 = @floatFromInt(entry.w);
const gh: f32 = @floatFromInt(entry.h);
// Four corners in clip space, paired with atlas UVs.
const tl = toClip(gx, gy, viewport, self.flip_y);
const tr = toClip(gx + gw, gy, viewport, self.flip_y);
const br = toClip(gx + gw, gy + gh, viewport, self.flip_y);
const bl = toClip(gx, gy + gh, viewport, self.flip_y);
const cr = run.color.r;
const cg = run.color.g;
const cb_c = run.color.b;
const ca = run.color.a;
// Two triangles (CCW): TL-TR-BR, TL-BR-BL.
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tr[0], .y = tr[1], .u = entry.u1, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = bl[0], .y = bl[1], .u = entry.u0, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try self.appendCoverageQuad(&batch, entry, gx, gy, run.color, viewport);
}
},
.icon => |ic| {
Expand All @@ -576,23 +632,7 @@ pub const PrismBackend = struct {
// primitive's origin.
const ix: f32 = ic.origin.x + @as(f32, @floatFromInt(entry.left)) - cur_off.x;
const iy: f32 = ic.origin.y + ic.size.height + @as(f32, @floatFromInt(entry.top)) - cur_off.y;
const iw: f32 = @floatFromInt(entry.w);
const ih: f32 = @floatFromInt(entry.h);
const tl = toClip(ix, iy, viewport, self.flip_y);
const tr = toClip(ix + iw, iy, viewport, self.flip_y);
const br = toClip(ix + iw, iy + ih, viewport, self.flip_y);
const bl = toClip(ix, iy + ih, viewport, self.flip_y);
const cr = ic.color.r;
const cg = ic.color.g;
const cb_c = ic.color.b;
const ca = ic.color.a;
// Two triangles (CCW): TL-TR-BR, TL-BR-BL, as the glyph path above.
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tr[0], .y = tr[1], .u = entry.u1, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = tl[0], .y = tl[1], .u = entry.u0, .v = entry.v0, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = br[0], .y = br[1], .u = entry.u1, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try batch.tverts.append(self.gpa, .{ .x = bl[0], .y = bl[1], .u = entry.u0, .v = entry.v1, .r = cr, .g = cg, .b = cb_c, .a = ca });
try self.appendCoverageQuad(&batch, entry, ix, iy, ic.color, viewport);
},
};
try batch.flush();
Expand Down Expand Up @@ -1180,3 +1220,87 @@ test "the atlas keeps one mark at two heights apart" {
try std.testing.expectEqual(short.h, again.h);
try std.testing.expectEqual(short.u0, again.u0);
}

/// Render one codepoint as a text run and give back the target's pixels, for the
/// fallback tests below. The caller frees the returned slice.
fn renderOneCodepoint(gpa: std.mem.Allocator, dev: prism.Device, font: *text.Font, cp: u21) ![]u8 {
var backend = try PrismBackend.init(dev, gpa);
defer backend.deinit();
const W: u32 = 64;
const H: u32 = 64;
const target = try dev.createResource(.{ .image = .{ .width = W, .height = H, .format = .rgba8_unorm, .usage = .{ .render_target = true } } });
defer dev.destroyResource(target);
const ctx = try dev.createContext();
defer ctx.deinit();

var list = dl.DisplayList{};
defer list.deinit(gpa);
const glyphs = [_]dl.PositionedGlyph{.{ .cp = cp, .x = 0, .y = 0 }};
try list.append(gpa, .{ .text = .{
.glyphs = &glyphs,
.text = "",
.font = font,
.size = 32,
.color = geom.Color.rgb(1, 1, 1),
.origin = geom.PhysicalOffset{ .x = 8, .y = 8 },
.ascent = 40,
} });
try backend.render(ctx, target, geom.PhysicalSize{ .width = 64, .height = 64 }, list, geom.Color.rgb(0, 0, 0));
return gpa.dupe(u8, try dev.mapResource(target));
}

test "a codepoint the face cannot draw becomes its built-in mark" {
// The bundled faces are display faces with almost nothing outside ASCII, so
// a tick or a chevron written in ordinary text came out as the replacement
// box glyph 0 draws, while a terminal showed the real character from its own
// font. The two backends have to agree.
const gpa = std.testing.allocator;
try requireRaster(gpa);
const sel = prism.drivers.createBestDevice(gpa) orelse return error.NoPrismDevice;
defer sel.device.deinit();
var font = try text.Font.load(gpa, text.builtin.neuropol_bytes);
defer font.deinit(gpa);

// The premise: neither of these is in the face. If one ever is, this test is
// measuring something else and says so rather than passing quietly.
try std.testing.expect(!font.hasGlyph('\u{25B8}'));
try std.testing.expect(!font.hasGlyph('\u{2603}'));

// U+25B8 has a mark; U+2603, a snowman, does not and keeps the box.
const marked = try renderOneCodepoint(gpa, sel.device, &font, '\u{25B8}');
defer gpa.free(marked);
const unmarked = try renderOneCodepoint(gpa, sel.device, &font, '\u{2603}');
defer gpa.free(unmarked);

// Both are glyph 0 to the face, so without the substitution these are the
// same pixels. That is the whole of the bug, and this is what separates
// them.
try std.testing.expect(!std.mem.eql(u8, marked, unmarked));

// And the mark drew something, rather than the substitution quietly
// dropping a codepoint it could not blit.
var lit: u32 = 0;
var i: usize = 0;
while (i < marked.len) : (i += 4) {
if (marked[i] > 40) lit += 1;
}
try std.testing.expect(lit > 10);
}

test "the two spellings of one mark draw the same thing" {
// U+25B6 and U+25B8 are the large and small right-pointing triangles. A
// caller writes whichever it prefers and means the same mark, so both reach
// the same built-in and draw identically.
const gpa = std.testing.allocator;
try requireRaster(gpa);
const sel = prism.drivers.createBestDevice(gpa) orelse return error.NoPrismDevice;
defer sel.device.deinit();
var font = try text.Font.load(gpa, text.builtin.neuropol_bytes);
defer font.deinit(gpa);

const large = try renderOneCodepoint(gpa, sel.device, &font, '\u{25B6}');
defer gpa.free(large);
const small = try renderOneCodepoint(gpa, sel.device, &font, '\u{25B8}');
defer gpa.free(small);
try std.testing.expectEqualSlices(u8, large, small);
}
52 changes: 52 additions & 0 deletions lib/phantom/icon/builtin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pub const Id = enum(u32) {
minus = 9,
rule_vertical = 10,
rule_horizontal = 11,

/// Three dots on the midline, for elided content. U+22EF, which no bundled
/// face has either.
ellipsis = 12,
};

/// What a cell backend draws in place of the mark.
Expand Down Expand Up @@ -84,6 +88,41 @@ pub fn cellMarkFor(id: Id) ?CellMark {
.minus => .{ .cp = '-' },
.rule_vertical => .{ .cp = '\u{2502}', .tile = true },
.rule_horizontal => .{ .cp = '\u{2500}', .tile = true },
.ellipsis => .{ .cp = '\u{22EF}' },
};
}

/// The mark to draw in place of `cp` when the face has no glyph for it, or null
/// when nothing here means that codepoint.
///
/// The bundled faces are display faces: every non-ASCII codepoint probed
/// resolves to glyph 0, so text carrying one of these draws a replacement box in
/// pixel mode while a terminal draws it correctly from its own font. That split
/// is what this closes. A caller writes the character it means, in ordinary
/// text, and both modes show the same thing.
///
/// This is `cellMarkFor` read backwards, plus the near neighbours: a caller
/// reaching for a right-pointing triangle may write the small one or the large
/// one, and both mean the same mark. Nothing here is a substitution between
/// DIFFERENT marks, only between spellings of one.
///
/// Only codepoints with a real mark belong here. A face that is missing a letter
/// is a fault to see, not to paper over.
pub fn iconForCodepoint(cp: u21) ?Id {
return switch (cp) {
'\u{2713}' => .check,
'\u{2717}' => .cross,
// Both sizes of each geometric triangle. U+25B8 and U+25BE are what a
// terminal interface usually reaches for, being lighter beside text.
'\u{25C0}', '\u{25C2}' => .chevron_left,
'\u{25B6}', '\u{25B8}' => .chevron_right,
'\u{25B2}', '\u{25B4}' => .chevron_up,
'\u{25BC}', '\u{25BE}' => .chevron_down,
'\u{2192}' => .arrow_right,
'\u{2502}' => .rule_vertical,
'\u{2500}' => .rule_horizontal,
'\u{22EF}' => .ellipsis,
else => null,
};
}

Expand All @@ -102,6 +141,7 @@ pub fn pathFor(id: Id) path.Path {
.minus => minus,
.rule_vertical => rule_vertical,
.rule_horizontal => rule_horizontal,
.ellipsis => ellipsis,
};
}

Expand Down Expand Up @@ -383,6 +423,18 @@ const minus = path.Path{ .verbs = &.{
.{ .line = .{ .x = 19, .y = 12 } },
} };

/// Three dots on the midline. Drawn as three of the shortest strokes the round
/// cap can make, so each reads as a dot rather than a dash: a zero length
/// segment would be culled before it reached the rasterizer.
const ellipsis = path.Path{ .verbs = &.{
.{ .move = .{ .x = 5, .y = 12 } },
.{ .line = .{ .x = 5.01, .y = 12 } },
.{ .move = .{ .x = 12, .y = 12 } },
.{ .line = .{ .x = 12.01, .y = 12 } },
.{ .move = .{ .x = 19, .y = 12 } },
.{ .line = .{ .x = 19.01, .y = 12 } },
} };

/// Full bleed and butt capped, so stacking one per row draws a continuous rail
/// with no seam at the row boundaries. This is U+2502's job in a terminal, and
/// the reason it is here is that no bundled font has that glyph.
Expand Down
11 changes: 11 additions & 0 deletions lib/phantom/text/Font.zig
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,17 @@ pub fn descent(self: *const Font) i16 {
return self.metrics.descent;
}

/// Whether this face can draw `cp` at all.
///
/// Glyph 0 is the `.notdef` box every face reserves for a codepoint it does not
/// cover, so a face that "draws" it draws a replacement box. The bundled faces
/// are display faces and cover little outside ASCII, which is what makes this
/// worth asking: see `backend/prism.zig`, which substitutes a built-in mark
/// rather than blit the box.
pub fn hasGlyph(self: *const Font, cp: u21) bool {
return self.metrics.glyphIndex(cp) != 0;
}

/// Horizontal advance of `cp` at `px_size` pixels (device space). Metrics only,
/// no rasterization, no allocation.
pub fn advance(self: *const Font, cp: u21, px_size: f32) f32 {
Expand Down
Loading