summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-15 17:36:32 -0300
committerGabriel Schneider <[email protected]>2026-09-15 17:43:13 -0300
commitcf7cfd96a62b26321de970496670005cf411c94c (patch)
treefd3da24fb47b5d88cf2135874900a35397f7e1f9
parente462d566f710e714e3b7176672e98fc82a6dea62 (diff)
downloadpardes-cf7cfd96a62b26321de970496670005cf411c94c.tar.gz
pardes-cf7cfd96a62b26321de970496670005cf411c94c.zip
Center GUI pane indicators using cached font ink bounds
-rw-r--r--docs/tags.md2
-rw-r--r--src/gui/font.c23
-rw-r--r--src/gui/font.h4
-rw-r--r--src/gui/gui.zig186
-rw-r--r--src/macos/Sources/PardesView.swift67
-rw-r--r--src/web/app.mjs63
-rw-r--r--test/web-snapshots/touch.mjs43
7 files changed, 363 insertions, 25 deletions
diff --git a/docs/tags.md b/docs/tags.md
index b0af5824..c4e93bc6 100644
--- a/docs/tags.md
+++ b/docs/tags.md
@@ -7,6 +7,8 @@ pane (or its first pane when focus comes from another column). This makes
`New` appears only in the column tag by default; pane tags keep their own
save, terminal, close, and collapse commands. `Tty` opens a new embedded terminal.
Pane and column command text leave a small gap after their aligned drag grips.
+GUI pane mode symbols are centered by their visible ink; changing the font or
+its size refreshes the cached measurements.
The column grip has a different
color from a pane grip. It stays blank and muted even when its column is active.
Drag it left or right to move the whole column; the
diff --git a/src/gui/font.c b/src/gui/font.c
index 9fc5e039..5989dd4e 100644
--- a/src/gui/font.c
+++ b/src/gui/font.c
@@ -126,9 +126,9 @@ void ui_font_cell_metrics(UIFont *f, float px, int32_t *cell_w, int32_t *cell_h,
*ascent = asc > 0 ? asc : 1;
}
-int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
- int32_t stride, int32_t cell_w, int32_t cell_h,
- int32_t ascent) {
+static int raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent, int centered) {
if (!f || !out || stride < cell_w || cell_w <= 0 || cell_h <= 0)
return 0;
for (int y = 0; y < cell_h; y++)
@@ -151,8 +151,10 @@ int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
const int advance = ceil_26_6(glyph->advance.x);
// Fallbacks may be proportional. Center their advance in the primary face's
// fixed cell, exactly as Ghostty centers a narrower fallback face.
- const int gx = glyph->bitmap_left + (cell_w - advance) / 2;
- const int gy = ascent - glyph->bitmap_top;
+ const int gx = centered ? (cell_w - (int)bitmap->width) / 2
+ : glyph->bitmap_left + (cell_w - advance) / 2;
+ const int gy = centered ? (cell_h - (int)bitmap->rows) / 2
+ : ascent - glyph->bitmap_top;
const int pitch = bitmap->pitch;
const int row_bytes = pitch < 0 ? -pitch : pitch;
@@ -184,3 +186,14 @@ int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
}
return 1;
}
+
+int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t cell_w, int32_t cell_h,
+ int32_t ascent) {
+ return raster(f, px, cp, out, stride, cell_w, cell_h, ascent, 0);
+}
+
+int ui_font_raster_centered(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t width, int32_t height) {
+ return raster(f, px, cp, out, stride, width, height, 0, 1);
+}
diff --git a/src/gui/font.h b/src/gui/font.h
index 2aff8b1a..d965b418 100644
--- a/src/gui/font.h
+++ b/src/gui/font.h
@@ -38,6 +38,10 @@ int ui_font_raster(UIFont *f, float px, int32_t cp, uint8_t *out,
int32_t stride, int32_t cell_w, int32_t cell_h,
int32_t ascent);
+// Center bitmap ink bounds, independent of advance and baseline bearings.
+int ui_font_raster_centered(UIFont *f, float px, int32_t cp, uint8_t *out,
+ int32_t stride, int32_t width, int32_t height);
+
void ui_font_free(UIFont *f);
#ifdef __cplusplus
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 87e19687..dbad10d4 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -82,6 +82,7 @@ const atlas_h: u32 = 2048;
const Slot = struct { u: u32, v: u32 };
const GlyphKey = struct {
codepoint: u32,
+ centered: bool = false,
role: pardes.FontRole,
decoration: GlyphDecoration = .{},
};
@@ -3439,7 +3440,7 @@ test "tagline pointer mapping retains physical grips and carries logical text co
const body_h: f32 = 20;
const tag_w: f32 = 8;
const tag_y = r.y;
- const x = @as(f32, @floatFromInt(r.x + config.GUTTER)) * body_w + 3.5 * tag_w;
+ const x = @as(f32, @floatFromInt(r.x + pardes.TAG_TEXT_INSET)) * body_w + 3.5 * tag_w;
const tag = gridCellAtDimensions(core, x, (@as(f32, @floatFromInt(tag_y)) + 0.5) * body_h, body_w, body_h, tag_w);
try std.testing.expectEqual(pixelCell(x, 10), tag.col);
@@ -4933,7 +4934,7 @@ fn renderFrame(
active.effect == .dissolve
else
false;
- const data_diff = data_effect and surface.panelCellChanged(col, row);
+ const data_diff = data_effect and gripCellChanged(surface, col, row);
const logical_idx: u32 = @as(u32, row) * surface.cols + col;
const instance_count = if (data_diff)
cellInstanceCount(&surface.previous_cells[logical_idx]) + cellInstanceCount(surface.at(col, row))
@@ -5035,7 +5036,7 @@ fn renderFrame(
active.effect == .dissolve
else
false;
- const data_diff = data_effect and surface.panelCellChanged(col, row);
+ const data_diff = data_effect and gripCellChanged(surface, col, row);
if (data_diff) {
emitSurfaceCell(
g,
@@ -5050,6 +5051,7 @@ fn renderFrame(
track,
&surface.previous_cells[logical_idx],
&tagline_base,
+ paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
true,
false,
page,
@@ -5068,6 +5070,7 @@ fn renderFrame(
if (data_effect and !data_diff) null else track,
surface.at(col, row),
&tagline_base,
+ paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row),
false,
paint_plan.len == 1 and logical_idx == cursor_idx and !cursor_bar and bodyLayerAt(surface, col, row) == null,
page,
@@ -5129,6 +5132,7 @@ fn renderFrame(
if (duplicate_under) null else track,
&surface.previous_cells[logical_idx],
&tagline_base,
+ paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row),
false,
false,
page,
@@ -5503,6 +5507,35 @@ fn emitBodyLayer(g: *Gui, instances: [*]CellInstance, next: *u32, layer: *const
}
}
+const GripCell = struct { cell: *const pardes.Cell, offset: u16 };
+
+fn gripCellChanged(surface: *const pardes.Surface, col: u16, row: u16) bool {
+ if (surface.panelCellChanged(col, row)) return true;
+ if (paneGripCell(surface.tagLayers(), surface.cells, surface.cols, col, row)) |grip| {
+ if (surface.panelCellChanged(col - grip.offset, row)) return true;
+ }
+ if (paneGripCell(surface.previous_tag_layers, surface.previous_cells, surface.cols, col, row)) |grip| {
+ if (surface.panelCellChanged(col - grip.offset, row)) return true;
+ }
+ return false;
+}
+
+fn paneGripCell(layers: []const pardes.TagLayer, cells: []const pardes.Cell, cols: u16, col: u16, row: u16) ?GripCell {
+ const at = @as(usize, row) * cols + col;
+ if (at >= cells.len or cells[at].default or cells[at].style.font_role != .tagline) return null;
+ for (layers) |layer| {
+ if (layer.cols == 0 or layer.kind != .pane or layer.viewport.x < pardes.TAG_TEXT_INSET or layer.viewport.y != row) continue;
+ const x = layer.viewport.x - pardes.TAG_TEXT_INSET;
+ if (col < x or col - x >= config.GUTTER) continue;
+ const index = @as(usize, row) * cols + x;
+ if (index >= cells.len) return null;
+ const cell = &cells[index];
+ if (cell.default or cell.printableAscii() == ' ' or cell.style.invisible) return null;
+ return .{ .cell = cell, .offset = col - x };
+ }
+ return null;
+}
+
fn emitSurfaceCell(
g: *Gui,
core: ?*const pardes.Pardes,
@@ -5516,6 +5549,7 @@ fn emitSurfaceCell(
track: ?pardes.layout.Track,
cell: *const pardes.Cell,
tagline_base: *const pardes.Cell,
+ grip: ?GripCell,
old_layer: bool,
is_cursor: bool,
page: Ground,
@@ -5531,6 +5565,18 @@ fn emitSurfaceCell(
next.* += 1;
// Tag glyphs keep their font size inside a full physical-width grip.
emitInstance(g, instances, next.*, col, row, body_layout, win_w, win_h, track, .tagline, cell, old_layer, is_cursor, page);
+ if (grip) |indicator| {
+ const cached = cachedGlyph(g, cellCodepoint(indicator.cell), .tagline, GlyphDecoration.fromCell(indicator.cell), true);
+ const slot = cached orelse g.space_slot;
+ const offset: u32 = if (cached != null) @as(u32, indicator.offset) * g.cell_w else 0;
+ const resolved = resolveCell(g, indicator.cell, .tagline, false, page);
+ instances[next.*].fr = @as(f32, @floatFromInt(resolved.fg[0])) / 255;
+ instances[next.*].fg = @as(f32, @floatFromInt(resolved.fg[1])) / 255;
+ instances[next.*].fb = @as(f32, @floatFromInt(resolved.fg[2])) / 255;
+ instances[next.*].u0 = @as(f32, @floatFromInt(slot.u + offset)) / atlas_w;
+ instances[next.*].v0 = @as(f32, @floatFromInt(slot.v)) / atlas_h;
+ instances[next.*].v1 = @as(f32, @floatFromInt(slot.v + g.tagline_height)) / atlas_h;
+ }
instances[next.*].u1 = instances[next.*].u0 + @as(f32, @floatFromInt(g.cell_w)) / atlas_w;
next.* += 1;
return;
@@ -5552,7 +5598,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
var instances: [6]CellInstance = undefined;
var next: u32 = 0;
for (0..3) |row| {
- emitSurfaceCell(&g, core, &instances, &next, 0, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, false, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, core, &instances, &next, 0, @intCast(row), fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, false, false, Ground.opaqueRgb(bg_default));
const base = instances[row * 2];
const ink = instances[row * 2 + 1];
try std.testing.expectApproxEqAbs(@as(f32, 20.0 / 480.0 * 2), base.y0 - base.y1, 0.0001);
@@ -5563,7 +5609,7 @@ test "small tagline fonts leave no background gutters between adjacent tags" {
if (row > 0) try std.testing.expectApproxEqAbs(instances[(row - 1) * 2].y1, base.y0, 0.0001);
}
next = 0;
- emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, true, false, Ground.opaqueRgb(bg_default));
+ emitSurfaceCell(&g, null, &instances, &next, 0, 2, fixedCellLayout(&g), 800, 480, null, &cell, &cell, null, true, false, Ground.opaqueRgb(bg_default));
try std.testing.expectEqual(@as(u32, 2), next);
try std.testing.expectApproxEqAbs(@as(f32, 16.0 / 480.0 * 2), instances[1].y0 - instances[1].y1, 0.0001);
}
@@ -5756,28 +5802,144 @@ fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void {
}
fn ensureGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration) Slot {
- const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration };
+ return cachedGlyph(g, cp, role, decoration, false) orelse g.space_slot;
+}
+
+fn cachedGlyph(g: *Gui, cp: u32, role: pardes.FontRole, decoration: GlyphDecoration, centered: bool) ?Slot {
+ const key: GlyphKey = .{ .codepoint = cp, .role = role, .decoration = decoration, .centered = centered };
+ const slot_w = g.cell_w * @as(u32, if (centered) config.GUTTER else 1);
if (g.glyphs.get(key)) |s| return s;
- if (g.pen_x + g.cell_w > atlas_w) {
+ if (g.pen_x + slot_w > atlas_w) {
g.pen_x = 0;
g.pen_y += g.cell_h;
}
- if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back
+ if (g.pen_y + g.cell_h > atlas_h) return null; // atlas full: fall back
const s = Slot{ .u = g.pen_x, .v = g.pen_y };
const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u;
const face = fontForCodepoint(g, cp);
const scale = if (role == .tagline) g.tagline_scale else g.scale;
const baseline = if (role == .tagline) g.tagline_baseline else g.ascent;
- const raster_w = if (role == .tagline) g.tagline_width else g.cell_w;
+ const raster_w = if (centered) slot_w else if (role == .tagline) g.tagline_width else g.cell_w;
const raster_h = if (role == .tagline) g.tagline_height else g.cell_h;
- _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline);
+ if (centered) {
+ _ = c.ui_font_raster_centered(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h));
+ } else {
+ _ = c.ui_font_raster(face, scale, @intCast(cp), out, @intCast(atlas_w), @intCast(raster_w), @intCast(raster_h), baseline);
+ }
decorateGlyph(out, atlas_w, raster_w, raster_h, baseline, decoration);
- g.glyphs.put(key, s) catch return g.space_slot;
- g.pen_x += g.cell_w;
+ if (centered) centerGlyphInk(out, raster_w, raster_h);
+ g.glyphs.put(key, s) catch return null;
+ g.pen_x += slot_w;
g.atlas_dirty = true;
return s;
}
+// Run once when a grip glyph enters the atlas, after decorations. Copy only
+// visible ink so bearings, empty bitmap margins and bold all share one center.
+fn centerGlyphInk(out: [*]u8, width: usize, height: usize) void {
+ const b = inkBounds(out[0 .. (height - 1) * atlas_w + width], atlas_w, width, height) orelse return;
+ const w = b.max_x - b.min_x + 1;
+ const h = b.max_y - b.min_y + 1;
+ const left = (width - w) / 2;
+ const top = (height - h) / 2;
+ var scratch: [atlas_w]u8 = undefined;
+ for (0..h) |step| {
+ const row = if (top > b.min_y) h - 1 - step else step;
+ const source = (b.min_y + row) * atlas_w + b.min_x;
+ const target = (top + row) * atlas_w;
+ @memcpy(scratch[0..w], out[source..][0..w]);
+ @memset(out[target..][0..width], 0);
+ @memcpy(out[target + left ..][0..w], scratch[0..w]);
+ }
+ for (0..height) |row| {
+ if (row < top or row >= top + h) @memset(out[row * atlas_w ..][0..width], 0);
+ }
+}
+
+test "pane indicator cached ink centers bearings styles and sizes" {
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?;
+ defer c.ui_font_free(font);
+ var g: Gui = undefined;
+ g.font = font;
+ g.fallback_count = 0;
+ g.atlas_stage = try std.testing.allocator.alloc(u8, atlas_w * atlas_h);
+ defer std.testing.allocator.free(g.atlas_stage);
+ g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
+ defer g.glyphs.deinit();
+ for ([_]f32{ 13, 27, 40 }) |px| {
+ g.scale = c.ui_font_scale_for_height(font, px);
+ var cw: c_int = 0;
+ var ch: c_int = 0;
+ c.ui_font_cell_metrics(font, g.scale, &cw, &ch, &g.ascent);
+ g.cell_w = @intCast(cw);
+ g.cell_h = @intCast(ch);
+ for ([_]u8{ 40, 82, 100 }) |percent| {
+ const tag = taglineRaster(font, px, g.cell_w, g.cell_h, percent);
+ g.tagline_width = tag.width;
+ g.tagline_height = tag.height;
+ g.tagline_scale = tag.scale;
+ g.tagline_baseline = tag.baseline;
+ resetGlyphAtlas(&g);
+ try std.testing.expectEqual(@as(usize, 0), g.glyphs.count());
+ for ([_]u32{ '^', '$', 'j' }) |cp| for ([_]bool{ false, true }) |bold| {
+ const slot = cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?;
+ const bounds = inkBounds(g.atlas_stage[@as(usize, slot.v) * atlas_w + slot.u ..], atlas_w, g.cell_w * config.GUTTER, tag.height).?;
+ const center_x: isize = @intCast(bounds.min_x + bounds.max_x);
+ const center_y: isize = @intCast(bounds.min_y + bounds.max_y);
+ try std.testing.expect(@abs(center_x - @as(isize, @intCast(g.cell_w * config.GUTTER - 1))) <= 1);
+ try std.testing.expect(@abs(center_y - @as(isize, @intCast(tag.height - 1))) <= 1);
+ const count = g.glyphs.count();
+ const pen = g.pen_x;
+ try std.testing.expectEqualDeep(slot, cachedGlyph(&g, cp, .tagline, .{ .bold = bold }, true).?);
+ try std.testing.expectEqual(count, g.glyphs.count());
+ try std.testing.expectEqual(pen, g.pen_x);
+ };
+ }
+ }
+ resetGlyphAtlas(&g);
+ g.pen_y = atlas_h;
+ try std.testing.expectEqual(@as(?Slot, null), cachedGlyph(&g, '^', .tagline, .{}, true));
+}
+
+test "pane indicator halves share dissolve changes and safe exhausted atlas" {
+ var cells: [12]pardes.Cell = @splat(.{ .default = false, .style = .{ .font_role = .tagline } });
+ cells[0].text[0] = '$';
+ var previous = cells;
+ previous[0].text[0] = '^';
+ var layers = [_]pardes.TagLayer{.{ .kind = .pane, .serial = 1, .viewport = .{ .x = pardes.TAG_TEXT_INSET, .y = 0, .w = 3, .h = 1 }, .cols = 3 }};
+ var diffs: [12]pardes.PanelCellDiff = @splat(.unchanged);
+ diffs[0] = pardes.PanelCellDiff.between(&previous[0], &cells[0]);
+ var surface: pardes.Surface = .{ .cols = 6, .rows = 2, .cells = &cells, .previous_cells = &previous, .previous_tag_layers = &layers, .cell_diffs = &diffs };
+ surface.tag_layers[0] = layers[0];
+ try std.testing.expect(gripCellChanged(&surface, 0, 0));
+ try std.testing.expect(gripCellChanged(&surface, 1, 0));
+ try std.testing.expect(!gripCellChanged(&surface, 2, 0));
+ try std.testing.expect(paneGripCell(surface.tagLayers(), &cells, 6, 2, 0) == null);
+ layers[0].kind = .column;
+ try std.testing.expect(paneGripCell(&layers, &cells, 6, 0, 0) == null);
+
+ var g: Gui = undefined;
+ g.cell_w = 10;
+ g.cell_h = 20;
+ g.tagline_width = 8;
+ g.tagline_height = 16;
+ g.space_slot = .{ .u = 0, .v = 0 };
+ g.pen_x = 0;
+ g.pen_y = atlas_h;
+ g.glyphs = std.AutoHashMap(GlyphKey, Slot).init(std.testing.allocator);
+ defer g.glyphs.deinit();
+ for ([_]bool{ false, true }) |old| {
+ var instances: [4]CellInstance = undefined;
+ var next: u32 = 0;
+ for (0..2) |col| {
+ emitSurfaceCell(&g, null, &instances, &next, @intCast(col), 0, fixedCellLayout(&g), 100, 100, null, &cells[col], &cells[2], .{ .cell = &cells[0], .offset = @intCast(col) }, old, false, Ground.opaqueRgb(bg_default));
+ try std.testing.expectEqual(@as(f32, 0), instances[col * 2 + 1].u0);
+ try std.testing.expectEqual(old, instances[col * 2 + 1].effect & old_layer_bit != 0);
+ }
+ try std.testing.expectEqual(@as(u32, 4), next);
+ }
+}
+
// Decorations stay inside the existing atlas slot, so text advance, caret and
// selection geometry are identical to the regular glyph. The cached mask keeps
// this work out of steady-state rendering and also decorates terminal spaces.
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index e2234264..f64678ee 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -562,6 +562,11 @@ final class PardesView: NSView {
// body and tagline variants whenever the body face changes.
private var bodyGlyphCache: [UInt32: CGGlyph] = [:]
private var taglineGlyphCache: [UInt32: CGGlyph] = [:]
+ private struct GripGlyph {
+ let line: CTLine
+ let ink: CGRect
+ }
+ private var gripGlyphCache: [String: GripGlyph] = [:]
// Scratch for one batched run of glyphs. Held rather than made per row so a
// full redraw does not allocate 24 times.
private var runGlyphs: [CGGlyph] = []
@@ -710,6 +715,7 @@ final class PardesView: NSView {
// it would draw a different character, not a missing one.
bodyGlyphCache.removeAll(keepingCapacity: true)
taglineGlyphCache.removeAll(keepingCapacity: true)
+ gripGlyphCache.removeAll(keepingCapacity: true)
metrics = next
taglines = TaglineMetrics(body: next, percent: taglines.percent, scale: scale)
metricsScale = scale
@@ -728,6 +734,7 @@ final class PardesView: NSView {
guard percent != taglines.percent else { return false }
taglines = TaglineMetrics(body: metrics, percent: percent, scale: metricsScale)
taglineGlyphCache.removeAll(keepingCapacity: true)
+ gripGlyphCache.removeAll(keepingCapacity: true)
pardes_row_metrics(UInt16(max(1,(cellWidth*metricsScale).rounded())),UInt16(max(1,(cellHeight*metricsScale).rounded())),
UInt16(max(1,(taglines.width*metricsScale).rounded())),UInt16(max(1,(taglines.height*metricsScale).rounded())))
needsDisplay = true
@@ -1085,11 +1092,25 @@ final class PardesView: NSView {
ctx.translateBy(x: 0, y: bounds.height)
ctx.scaleBy(x: 1, y: -1)
let height = bounds.height
+ var paneGripCells = Set<Int>()
+ if frozenImages == nil {
+ for index in 0..<pardes_tag_layer_limit() {
+ if pardes_tag_layer_value(index, 0) == 0 || pardes_tag_layer_value(index, 1) != 2 { continue }
+ let x = Int(pardes_tag_layer_value(index, 4)) - Int(pardes_tag_text_inset())
+ let y = Int(pardes_tag_layer_value(index, 5))
+ if x >= 0 && x < cols && y >= 0 && y < rows { paneGripCells.insert(y * cols + x) }
+ }
+ } else {
+ for layer in presentedTagLayers where layer.values[1] == 2 {
+ let x = layer.values[4] - Int(pardes_tag_text_inset()), y = layer.values[5]
+ if x >= 0 && x < cols && y >= 0 && y < rows { paneGripCells.insert(y * cols + x) }
+ }
+ }
for row in 0..<rows {
drawRow(ctx, cells, base: row * cols, cols: cols, row: row,
baseline: height - (CGFloat(row) * cellHeight + metrics.ascent),
taglineTop: taglines.top(row: row, canvasHeight: canvasHeight),
- blockCol: blockY == row ? blockX : -1)
+ blockCol: blockY == row ? blockX : -1, paneGripCells: paneGripCells)
}
ctx.restoreGState()
@@ -1170,16 +1191,49 @@ final class PardesView: NSView {
}
}
+ private func gripGlyph(_ text: String, face: Face) -> GripGlyph? {
+ let key = "\(face.rawValue):\(text)"
+ if let cached = gripGlyphCache[key] { return cached }
+ let attributed = NSAttributedString(string: text, attributes: [fontAttribute: taglines.fonts[face.rawValue],
+ NSAttributedString.Key(kCTForegroundColorFromContextAttributeName as String): true])
+ let line = CTLineCreateWithAttributedString(attributed as CFAttributedString)
+ let ink = CTLineGetBoundsWithOptions(line, [.useGlyphPathBounds])
+ guard !ink.isNull, !ink.isInfinite, !ink.isEmpty else { return nil }
+ let measured = GripGlyph(line: line, ink: ink)
+ gripGlyphCache[key] = measured
+ return measured
+ }
+
private func drawTagGrip(_ ctx: CGContext, values: [Int], canonical: UnsafePointer<pardes_cell_s>, cols: Int, rows: Int) {
let width = Int(pardes_grip_columns())
let x = values[4] - Int(pardes_tag_text_inset()), y = values[5]
- if values[1] != 2 || x < 0 || x >= cols || y >= rows || taglines.height < 6 || taglines.width < 3 { return }
+ if values[1] != 2 || x < 0 || x >= cols || y < 0 || y >= rows { return }
var cell = canonical[y * cols + x]
let text = withUnsafeBytes(of: &cell.text) { String(decoding: $0.prefix(Int(cell.len)), as: UTF8.self) }
- if text != " " { return }
+ if text.isEmpty { return }
let style = resolve(cell, block:false, ground:themeBG ?? pardesDefaultBG, clearGround:false)
let centerX = (CGFloat(x) + CGFloat(width) / 2) * cellWidth
let centerY = CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height) + taglines.height / 2
+ if text != " " {
+ let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
+ italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
+ guard style.visible, let measured = gripGlyph(text, face: face) else { return }
+ let originX = ((centerX - measured.ink.midX) * metricsScale).rounded() / metricsScale
+ let baseline = ((bounds.height - centerY - measured.ink.midY) * metricsScale).rounded() / metricsScale
+ ctx.saveGState()
+ ctx.clip(to: CGRect(x: CGFloat(x) * cellWidth,
+ y: CGFloat(y) * cellHeight + taglines.top(row:y, canvasHeight:bounds.height),
+ width: CGFloat(width) * cellWidth, height: taglines.height))
+ ctx.translateBy(x: 0, y: bounds.height)
+ ctx.scaleBy(x: 1, y: -1)
+ ctx.textMatrix = .identity
+ ctx.textPosition = CGPoint(x: originX, y: baseline)
+ setFill(ctx, style.fg, style.alpha)
+ CTLineDraw(measured.line, ctx)
+ ctx.restoreGState()
+ return
+ }
+ if taglines.height < 6 || taglines.width < 3 { return }
let halfWidth = max(1, min(3, cellWidth - 2))
fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY-2), width:2*halfWidth, height:1), style.fg, 1)
fill(ctx, CGRect(x:floor(centerX-halfWidth), y:floor(centerY+1), width:2*halfWidth, height:1), style.fg, 1)
@@ -1482,7 +1536,8 @@ final class PardesView: NSView {
/// rather than recomputed per cell: it is one answer per row, and the
/// glyph has to sit in the band the background pass painted.
taglineTop: CGFloat,
- blockCol: Int
+ blockCol: Int,
+ paneGripCells: Set<Int>
) {
var runFace = Face.regular
var runTagline = false
@@ -1544,6 +1599,10 @@ final class PardesView: NSView {
let face = Face(bold: cell.attrs & UInt16(PARDES_ATTR_BOLD) != 0,
italic: cell.attrs & UInt16(PARDES_ATTR_ITALIC) != 0)
+ if paneGripCells.contains(base + col) && gripGlyph(text, face: face) != nil {
+ // The layer pass centers this symbol by its actual ink bounds.
+ continue
+ }
let units = text.utf16
let known = units.count == 1 ? glyph(face, units.first!, tagline: tagline) : 0
if known != 0 {
diff --git a/src/web/app.mjs b/src/web/app.mjs
index 477186a3..11d8f228 100644
--- a/src/web/app.mjs
+++ b/src/web/app.mjs
@@ -34,6 +34,9 @@ export class DomRenderer {
this.cols = 0;
this.rowCount = 0;
this.snapshot = null;
+ this.gripFonts = new Map();
+ this.gripMetrics = new Map();
+ this.root.ownerDocument.fonts?.addEventListener("loadingdone", () => this.resetGripMetrics());
const taglinePercent = this.wasm.pardes_gui_tagline_font_percent();
if (taglinePercent < 1 || taglinePercent > 100) throw new Error(`bad tagline font percentage ${taglinePercent}`);
this.root.style.setProperty("--pardes-tagline-font-size", `${taglinePercent}%`);
@@ -56,6 +59,7 @@ export class DomRenderer {
const raw = new Array(count);
const tagCovered = new Uint8Array(count);
const tagPrefixBands = new Map();
+ const paneIndicators = new Set();
if (this.wasm.pardes_tag_layer_limit) {
for (let i = 0; i < this.wasm.pardes_tag_layer_limit(); i++) {
const val = field => this.wasm.pardes_tag_layer_value(i, field);
@@ -65,6 +69,7 @@ export class DomRenderer {
if (val(1) !== 0 && val(4) >= textInset && this.cellHeight) {
const offset = this.wasm.pardes_tagline_band_offset(val(5),rows*this.cellHeight*256,Math.round(this.cellHeight*256),Math.round(this.taglineHeight*256))/256;
for (let x = val(4)-textInset; x < val(4); x++) tagPrefixBands.set(val(5)*cols+x,offset);
+ if (val(1) === 2) paneIndicators.add(val(5)*cols+val(4)-textInset);
}
for (let x = val(4); x < Math.min(cols, val(4) + val(6)); x++) {
if (val(5) < rows) tagCovered[val(5) * cols + x] = 1;
@@ -88,7 +93,8 @@ export class DomRenderer {
cell.style.height = gripOffset === undefined ? "" : `${this.taglineHeight}px`;
cell.style.lineHeight = gripOffset === undefined ? "" : `${this.taglineHeight}px`;
cell.style.marginTop = gripOffset === undefined ? "" : `${gripOffset}px`;
- if (cell.textContent !== text) cell.textContent = text;
+ const displayedText = paneIndicators.has(index) ? " " : text;
+ if (cell.textContent !== displayedText) cell.textContent = displayedText;
const styleKey = `${fg}:${bg}:${attrs}:${cursor ? 1 : 0}:${cursorBar ? 1 : 0}`;
if (cell.pardesStyle !== styleKey) {
applyStyle(cell, fg, bg, attrs, cursor, cursorBar);
@@ -124,8 +130,8 @@ export class DomRenderer {
const gripCols = this.wasm.pardes_grip_columns?.() ?? 2;
const gripX = values[4] - (this.wasm.pardes_tag_text_inset?.() ?? gripCols);
const gripCell = canonical[values[5]*this.cols+gripX];
- const grip = values[1] === 2 && gripX >= 0 && gripCell?.text === " " && th >= 6 && tw >= 3;
- const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg].join(":");
+ const grip = values[1] === 2 && gripX >= 0 && gripCell && th >= 1 && tw >= 1;
+ const geometry = [bw,bh,tw,th,offset,...values,grip,gripCell?.fg,gripCell?.bg,gripCell?.attrs,gripCell?.text,globalThis.devicePixelRatio].join(":");
const cached = this.tagNodes.get(i);
if (cached && cached.geometry === geometry && cached.bytes.length === bytes.length && cached.bytes.every((byte,index)=>byte===bytes[index])) {
nodes.push(cached.node);
@@ -149,7 +155,13 @@ export class DomRenderer {
layer.append(cell);
}
let gripNode = null;
- if (grip) {
+ if (grip && gripCell.text.trim()) {
+ const cell = this.cells[values[5]*this.cols+gripX];
+ gripNode = this.drawGripGlyph(cell, gripCell, gripCols*bw, th);
+ gripNode.dataset.tagGrip = String(i);
+ gripNode.style.left = `${gripX*bw}px`;
+ gripNode.style.top = `${values[5]*bh+offset}px`;
+ } else if (grip && th >= 6 && tw >= 3) {
gripNode = this.root.ownerDocument.createElement("div");
gripNode.dataset.tagGrip = String(i);
const centerX = (gripX+gripCols/2)*bw, centerY = values[5]*bh+offset+th/2;
@@ -168,6 +180,43 @@ export class DomRenderer {
if (nodes.length !== this.tagRoot.childNodes.length || nodes.some((node,index)=>node!==this.tagRoot.childNodes[index])) this.tagRoot.replaceChildren(...nodes);
}
+ resetGripMetrics() {
+ this.gripFonts.clear();
+ this.gripMetrics.clear();
+ this.tagNodes?.clear();
+ }
+
+ drawGripGlyph(cell, glyph, width, height) {
+ const canvas = this.root.ownerDocument.createElement("canvas");
+ const ratio = globalThis.devicePixelRatio || 1;
+ canvas.width = Math.max(1, Math.ceil(width*ratio));
+ canvas.height = Math.max(1, Math.ceil(height*ratio));
+ canvas.style.cssText = `position:absolute;pointer-events:none;width:${width}px;height:${height}px`;
+ canvas.setAttribute("aria-label", glyph.text);
+ const ctx = canvas.getContext("2d");
+ // Resolve a face once per style, then retain its ink bearings for each
+ // symbol. Font loading and runtime metric changes clear both caches.
+ let font = this.gripFonts.get(glyph.attrs);
+ if (!font) {
+ const style = this.root.ownerDocument.defaultView.getComputedStyle(cell);
+ font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
+ this.gripFonts.set(glyph.attrs, font);
+ }
+ ctx.scale(ratio, ratio);
+ ctx.font = font;
+ const key = `${font}:${ratio}:${glyph.text}`;
+ let ink = this.gripMetrics.get(key);
+ if (!ink) {
+ const measured = ctx.measureText(glyph.text);
+ ink = { left:measured.actualBoundingBoxLeft, right:measured.actualBoundingBoxRight,
+ ascent:measured.actualBoundingBoxAscent, descent:measured.actualBoundingBoxDescent };
+ this.gripMetrics.set(key, ink);
+ }
+ ctx.fillStyle = cell.style.color;
+ ctx.fillText(glyph.text, (width-ink.right+ink.left)/2, (height+ink.ascent-ink.descent)/2);
+ return canvas;
+ }
+
renderBodyLayers() {
if (!this.wasm.pardes_body_layer_limit || !this.cellWidth) return;
if (!this.layerRoot?.isConnected) {
@@ -332,6 +381,12 @@ export class PardesRuntime {
this.taglineWidth = this.cellWidth * ratio;
this.taglineHeight = this.cellHeight * ratio;
Object.assign(this.renderer, { cellWidth:this.cellWidth, cellHeight:this.cellHeight, taglineWidth:this.taglineWidth, taglineHeight:this.taglineHeight });
+ const style = this.root.ownerDocument.defaultView.getComputedStyle(this.root);
+ const fontKey = [style.fontFamily,style.fontSize,style.fontWeight,style.fontStyle,ratio,globalThis.devicePixelRatio].join(":");
+ if (fontKey !== this.gripFontKey) {
+ this.renderer.resetGripMetrics();
+ this.gripFontKey = fontKey;
+ }
}
gridSize() {
diff --git a/test/web-snapshots/touch.mjs b/test/web-snapshots/touch.mjs
index 1351712e..69f8a4a5 100644
--- a/test/web-snapshots/touch.mjs
+++ b/test/web-snapshots/touch.mjs
@@ -105,6 +105,41 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou
tagValues[1]=2;tagValues[5]=1;bytes[40]=32;data.setUint32(48,0x778899,true);
renderer.render();
result.paneMarks=marks(renderer.tagRoot.querySelector('[data-tag-grip]'));
+ const inkCenter=canvas=>{
+ const pixels=canvas.getContext('2d').getImageData(0,0,canvas.width,canvas.height).data;
+ let minX=canvas.width,minY=canvas.height,maxX=-1,maxY=-1;
+ for(let y=0;y<canvas.height;y++) for(let x=0;x<canvas.width;x++) if(pixels[(y*canvas.width+x)*4+3]>8) {
+ minX=Math.min(minX,x);maxX=Math.max(maxX,x);minY=Math.min(minY,y);maxY=Math.max(maxY,y);
+ }
+ return {x:(minX+maxX+1-canvas.width)/2,y:(minY+maxY+1-canvas.height)/2,visible:maxX>=0};
+ };
+ Object.assign(renderer,{cellWidth:20,cellHeight:40,taglineWidth:10,taglineHeight:28});
+ renderer.cells[2].style.font='18px serif';
+ renderer.resetGripMetrics();
+ result.modeGlyphs=[];
+ for(const glyph of ['^','$']) {
+ bytes[40]=glyph.charCodeAt(0);
+ renderer.render();
+ const canvas=renderer.tagRoot.querySelector('canvas[data-tag-grip]');
+ const center=inkCenter(canvas),size=renderer.gripMetrics.size;
+ renderer.render();
+ result.modeGlyphs.push({...center,suppressed:renderer.cells[2].textContent===' ',
+ reused:renderer.tagRoot.querySelector('canvas[data-tag-grip]')===canvas,
+ measuredOnce:renderer.gripMetrics.size===size});
+ }
+ const glyphCell=renderer.cells[2];
+ glyphCell.style.font='italic 24px serif';
+ renderer.resetGripMetrics();
+ const italic=renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40);
+ result.italic=inkCenter(italic);
+ const saved=renderer.gripMetrics.values().next().value;
+ renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40);
+ result.italic.cached=renderer.gripMetrics.size===1 && renderer.gripMetrics.values().next().value===saved;
+ document.fonts.dispatchEvent(new Event('loadingdone'));
+ result.fontInvalidation=renderer.gripMetrics.size===0 && renderer.gripFonts.size===0 && renderer.tagNodes.size===0;
+ glyphCell.style.font='italic 30px serif';
+ renderer.drawGripGlyph(glyphCell,{text:'j',attrs:2},40,40);
+ result.fontRemeasured=renderer.gripMetrics.values().next().value!==saved && [...renderer.gripFonts.values()][0].includes('30px');
root.remove();return result;
})()`);
assert.deepEqual(compactGeometry.bands, [[0,10],[10,20],[30,20]], "compact headers release real height to additional body rows");
@@ -123,6 +158,14 @@ export default async function ({ grid, touch, wait, stable, delay, evaluate, mou
assert.deepEqual(compactGeometry.columnMarks,[],"column handles have no mark");
assert.deepEqual(compactGeometry.paneMarks,[[7,33,6],[7,36,6]],"pane handles keep their physical mark geometry");
+ for (const glyph of [...compactGeometry.modeGlyphs,compactGeometry.italic]) {
+ assert(glyph.visible,"mode indicator has visible rasterized ink");
+ assert(Math.abs(glyph.x)<=1 && Math.abs(glyph.y)<=1,`ink center offset ${glyph.x},${glyph.y}`);
+ }
+ for (const glyph of compactGeometry.modeGlyphs) assert(glyph.suppressed && glyph.reused && glyph.measuredOnce,"mode ink is drawn once and cached");
+ assert(compactGeometry.italic.cached,"asymmetric italic glyph reuses cached ink bounds");
+ assert(compactGeometry.fontInvalidation && compactGeometry.fontRemeasured,"font loading clears cached faces and bounds before remeasurement");
+
let frame = await grid();
assert.equal(tags(frame).length, 1);
styled(frame);