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 /src/gui/gui.zig | |
| parent | e462d566f710e714e3b7176672e98fc82a6dea62 (diff) | |
| download | pardes-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.zig | 186 |
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. |
