From d395739c8051e28726a4111eec1111a9c70567ec Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 24 Sep 2026 13:01:25 -0300 Subject: Turn ligatures off with a Ligatures setting, in the shell that shapes text The SDL shell shapes words with HarfBuzz, so a font's `->` and `!=` draw as ligatures, and there was no way to have the plain glyphs back short of changing font. `Ligatures` is a toggle, on by default; off, no cell goes to the shaper and every cell draws its own glyph, exactly as a font without ligatures does. It exists only where it means something. A new `ligatures` capability, true for the gui shell alone, gates it like Font and WindowOpacity are gated: it is not a builtin elsewhere, has no leader path, and Config does not list it, rather than print a row the TTY could never change. macOS draws CoreText ligatures of its own, but nothing there reads the setting, so it stays off there. The table check that kept every toggle unconditional now lets the ligatures toggle, and only it, carry a capability, and requires that it carry `ligatures`; every other setting's rule is as it was. The gui keeps the setting beside its text caches, which were resolved under it: when the core's value changes, the per-codepoint cells (which record whether a cell is shaped) and the shaped words are dropped, and the frame the toggle asked for draws every cell again. The atlas keeps its glyphs: plain ones draw either way, and a ligature's strip is reused when it comes back. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/gui/gui.zig | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 4 deletions(-) (limited to 'src/gui') diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 98a203a6..e3229b38 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2453,6 +2453,7 @@ fn localSession( core.native_image_patches = true; observeGuiFont(g, core); syncTaglineFont(g, core); + syncLigatures(g, core); var prompt_rcs = host_io.Shell.prepare(); defer prompt_rcs.deinit(); @@ -2580,6 +2581,7 @@ fn localSession( shell.surface = null; observeGuiFont(g, core); syncTaglineFont(g, core); + syncLigatures(g, core); } } @@ -3966,6 +3968,7 @@ fn pollFrame(ctx: ?*anyopaque) void { }; syncWindowOpacity(g, core); syncTaglineFont(g, core); + syncLigatures(g, core); if (core.takeFontRequest()) |path| blk: { const bytes = filesystem.readFile(s.gpa, path) catch { core.rejectFont(); @@ -6056,6 +6059,17 @@ fn resetGlyphAtlas(g: *Gui) void { g.atlas_dirty = true; } +/// A changed Ligatures setting drops what was resolved under the old one: the +/// per-codepoint cells (which say whether a cell is shaped) and the shaped +/// words. The atlas keeps its glyphs, plain and strip alike; the plain ones +/// draw either way, and the strips come back into use when ligatures do. +/// The frame this runs before redraws every cell with the new setting. +fn syncLigatures(g: *Gui, core: *pardes.Pardes) void { + if (core.settings.ligatures == g.words.ligatures) return; + g.words.clear(); + g.words.ligatures = core.settings.ligatures; +} + fn syncTaglineFont(g: *Gui, core: *pardes.Pardes) void { const percent = core.settings.font.tagline_percent; if (percent == g.tagline_percent) return; @@ -6083,6 +6097,10 @@ const Words = struct { text: std.ArrayListUnmanaged(u32) = .empty, slots: std.ArrayListUnmanaged(Slot) = .empty, shaped: std.ArrayListUnmanaged(c.UIShapedCell) = .empty, + /// The Ligatures setting. Off, no cell is shaped, so each draws its own + /// glyph, as in a font without ligatures. It lives here because every + /// cell and word above was resolved under it (`syncLigatures`). + ligatures: bool = true, /// The word the last cell was in, so the rest of it costs a compare. It /// is positional, so every frame forgets it: the cells change under it. memo: Memo = .{}, @@ -6144,9 +6162,9 @@ fn wordBreak(a: WordCell, b: WordCell, i: usize, cursor: ?usize) bool { /// The atlas slot `line[at]` draws: blank for a plain space (or concealed /// text), its own glyph when no ligature could replace that glyph (every -/// cell, in a font without ligatures), else its slice of the word around it, -/// shaped once and cached by the word's text. The cells of a word being walked -/// cost a compare. Taglines are not shaped: they are drawn at two pitches +/// cell, in a font without ligatures or with Ligatures off), else its slice +/// of the word around it, shaped once and cached by the word's text. The +/// cells of a word being walked cost a compare. Taglines are not shaped: they are drawn at two pitches /// (tagline_width in a tag layer, a whole cell in the grid), and a strip would /// not line up in one of them. fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: pardes.FontRole) Slot { @@ -6163,7 +6181,7 @@ fn cellSlot(g: *Gui, line: []const pardes.Cell, at: usize, cursor: ?usize, role: if (!known.found_existing) { const drawn = glyphForCodepoint(g, here.cp); const font = if (drawn.face == 0) g.font else g.fallbacks[drawn.face - 1].?.face; - known.value_ptr.* = if (role == .body and c.ui_font_substitutes(font, drawn.glyph) != 0) + known.value_ptr.* = if (role == .body and words.ligatures and c.ui_font_substitutes(font, drawn.glyph) != 0) Words.shape_slot else cachedGlyph(g, .{ .face = drawn.face, .glyph = drawn.glyph, .role = role, .decoration = here.decoration }) orelse g.space_slot; @@ -6328,6 +6346,40 @@ test "a ligature draws across its cells, one slice each, and comes apart under t try std.testing.expectEqual(c.ui_font_glyph(font, '-'), (try glyphKeyAt(&g, cellSlot(&g, &line, 1, null, .tagline))).glyph); } +test "Ligatures off draws a ligature as its cells' own glyphs, and on shapes it again" { + const bytes = try filesystem.readFile(std.testing.allocator, "assets/MapleMono-NF-Regular.ttf"); + defer std.testing.allocator.free(bytes); + const font = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse return error.FontInit; + defer c.ui_font_free(font); + var g = try textTestGui(font, 27); + defer font_allocator = std.heap.smp_allocator; + defer std.testing.allocator.free(g.atlas_stage); + defer g.glyphs.deinit(); + defer g.words.deinit(std.testing.allocator); + const core = try pardes.Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer core.deinit(); + + const text = "a->b"; + const line = textTestLine(text); + var shaped: [text.len]Slot = undefined; + for (&shaped, 0..) |*slot, i| slot.* = cellSlot(&g, &line, i, null, .body); + try std.testing.expectEqual(shaped[1].u + g.cell_w, shaped[2].u); + + // Off, every cell is the slot the per-glyph path gives its codepoint. + try std.testing.expect(core.executeBuiltinLine(core.active, "Ligatures off")); + syncLigatures(&g, core); + for (text, 0..) |byte, i| { + const plain = cachedGlyph(&g, .{ .face = 0, .glyph = c.ui_font_glyph(font, byte), .role = .body }).?; + try std.testing.expectEqual(plain, cellSlot(&g, &line, i, null, .body)); + } + try std.testing.expect(!std.meta.eql(shaped[1], cellSlot(&g, &line, 1, null, .body))); + + // On again, the arrow is its strip once more. + try std.testing.expect(core.executeBuiltinLine(core.active, "Ligatures on")); + syncLigatures(&g, core); + for (shaped, 0..) |slot, i| try std.testing.expectEqual(slot, cellSlot(&g, &line, i, null, .body)); +} + fn glyphKeyAt(g: *Gui, slot: Slot) !GlyphKey { var it = g.glyphs.iterator(); while (it.next()) |entry| if (std.meta.eql(entry.value_ptr.*, slot)) return entry.key_ptr.*; -- cgit v1.3