diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-15 17:36:32 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-15 17:43:13 -0300 |
| commit | cf7cfd96a62b26321de970496670005cf411c94c (patch) | |
| tree | fd3da24fb47b5d88cf2135874900a35397f7e1f9 | |
| parent | e462d566f710e714e3b7176672e98fc82a6dea62 (diff) | |
| download | pardes-cf7cfd96a62b26321de970496670005cf411c94c.tar.gz pardes-cf7cfd96a62b26321de970496670005cf411c94c.zip | |
Center GUI pane indicators using cached font ink bounds
| -rw-r--r-- | docs/tags.md | 2 | ||||
| -rw-r--r-- | src/gui/font.c | 23 | ||||
| -rw-r--r-- | src/gui/font.h | 4 | ||||
| -rw-r--r-- | src/gui/gui.zig | 186 | ||||
| -rw-r--r-- | src/macos/Sources/PardesView.swift | 67 | ||||
| -rw-r--r-- | src/web/app.mjs | 63 | ||||
| -rw-r--r-- | test/web-snapshots/touch.mjs | 43 |
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); |
