summaryrefslogtreecommitdiff
path: root/src/gui/gui.zig
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 /src/gui/gui.zig
parente462d566f710e714e3b7176672e98fc82a6dea62 (diff)
downloadpardes-cf7cfd96a62b26321de970496670005cf411c94c.tar.gz
pardes-cf7cfd96a62b26321de970496670005cf411c94c.zip
Center GUI pane indicators using cached font ink bounds
Diffstat (limited to 'src/gui/gui.zig')
-rw-r--r--src/gui/gui.zig186
1 files changed, 174 insertions, 12 deletions
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.