diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-24 13:01:25 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | d395739c8051e28726a4111eec1111a9c70567ec (patch) | |
| tree | 1e2ecebb1fa5cfc45009b286d1480a1779fc3898 | |
| parent | 19a93afb5958b6a6548e1112813d4aa687a8976f (diff) | |
| download | pardes-d395739c8051e28726a4111eec1111a9c70567ec.tar.gz pardes-d395739c8051e28726a4111eec1111a9c70567ec.zip | |
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) <[email protected]>
| -rw-r--r-- | docs/config.md | 17 | ||||
| -rw-r--r-- | docs/design.typ | 3 | ||||
| -rw-r--r-- | src/builtins.zig | 26 | ||||
| -rw-r--r-- | src/config.zig | 24 | ||||
| -rw-r--r-- | src/gui/gui.zig | 60 |
5 files changed, 120 insertions, 10 deletions
diff --git a/docs/config.md b/docs/config.md index d1cc2e6e..9c51f329 100644 --- a/docs/config.md +++ b/docs/config.md @@ -25,16 +25,19 @@ which `Config` reports as `no per-user config path`. `+Config` pane. It reports the startup path and every live config-like value: theme, colors, focus tint, syntax weight, wrapping, tag position, debug mode, the requested shell and the executable actually resolved at the last spawn, requested/effective GUI font -and size, tagline scale, window opacity, panel transition, +and size, tagline scale, window opacity, ligatures (SDL GUI only), panel transition, scene effects, hover delay, platform, native-image support, and (on SDL) whether the executable uses live-built shaders or the paired prebuilt shader snapshot. Platform-dependent rows say `unsupported` instead of looking like an off or -empty supported setting. The five fields of `config.Runtime.Capabilities` gate +empty supported setting. The fields of `config.Runtime.Capabilities` gate them and are stated once as plain data in `builtins.capabilities`: `font_picker` is the SDL GUI and native macOS only, `scene_shaders` the same two, `panel_transitions` every -hosted shell, `window_opacity` the SDL GUI only, and `tagline_font_size` -everything but the TTY and the board. So +hosted shell, `window_opacity` the SDL GUI and macOS, `window_blur` macOS +only, `ligatures` the SDL GUI only, and `tagline_font_size` +everything but the TTY and the board. `ligatures` is the exception to +`unsupported`: where it is off, `Ligatures` is not a builtin at all and +`Config` has no row for it. So the TTY reports Font, TaglineSize and the scene shaders as unsupported; the browser reports Font, panel transitions and the scene shaders as unsupported, and its TaglineSize row reads `82 (build-time only)` — tagline font size is @@ -133,6 +136,12 @@ the current value. A `FocusTint` line in a fresh startup configuration disables the tint; a `SyntaxBold` line enables the stronger keyword weight. Those two appearance controls leave focus, selections and editing behavior unchanged. +`Ligatures` (SDL GUI only) toggles a font's programming ligatures, such as +Maple Mono's `->` and `!=` drawn across their cells; it is on by default. +Off, every cell draws its own glyph, exactly as a font without ligatures does. +`Ligatures on` and `Ligatures off` set it explicitly. The native macOS shell +draws CoreText ligatures of its own, which this setting does not reach. + `TreeContext` toggles sticky declaration headers for the current source pane. It is off by default and appears in the default pane tag when a tree-sitter grammar supports that file. `TreeContext on` and `TreeContext off` set it diff --git a/docs/design.typ b/docs/design.typ index 8ec025f1..01b850b9 100644 --- a/docs/design.typ +++ b/docs/design.typ @@ -2043,7 +2043,8 @@ a tagline or a one-cell-tolerant pane separator latches to a left-mouse gesture for its lifetime, keeping layout drags out of the scroll heuristic. Touch input translation and scroll/tap state live entirely in the JavaScript shell. The native SDL shell uses a FreeType light-hinted grayscale atlas over the SDL GPU API (SPIR-V), -with words shaped by HarfBuzz so a font's ligatures span their cells. +with words shaped by HarfBuzz so a font's ligatures span their cells +(`Ligatures off` draws every cell as its own glyph). The browser exposes each cell as selectable, inspectable text and applies the surface styles with CSS. The browser `.snap` harness drives real Chromium touch input and reads both text and per-cell styles from the DOM renderer's packed diff --git a/src/builtins.zig b/src/builtins.zig index 146789b0..a5dbf6cd 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -19,6 +19,9 @@ pub const capabilities: config.Runtime.Capabilities = .{ .tagline_font_size = pardes.platform != .tty and pardes.platform != .esp32p4, .window_opacity = pardes.platform == .gui or pardes.platform == .macos, .window_blur = pardes.platform == .macos, + // Only the SDL shell shapes with HarfBuzz and honours the setting. macOS + // draws CoreText ligatures of its own, but nothing there reads it yet. + .ligatures = pardes.platform == .gui, }; pub const Ctx = struct { @@ -164,6 +167,29 @@ test "capabilities exactly gate setting and effect-source builtins" { try std.testing.expectEqual(EffectCode.enabled, effect_code_registered); } +test "Ligatures is a setting only in the shell that shapes text" { + const exists = @hasField(registry.Builtin(), "Ligatures"); + try std.testing.expectEqual(pardes.platform == .gui, exists); + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqual(exists, p.executeBuiltinLine(p.active, "Ligatures off")); + try std.testing.expectEqual(!exists, p.settings.ligatures); + var storage: [8192]u8 = undefined; + var out: std.Io.Writer = .fixed(&storage); + try config.Runtime.writeReport(&out, .{ + .startup_config_path = null, + .platform = @tagName(pardes.platform), + .theme_name = p.theme().name, + .compiled_default_shell = config.default_shell, + .hover_delay_frames = null, + .native_images = false, + .capabilities = capabilities, + .state = &p.settings, + }); + try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures off\n") != null); + try std.testing.expectEqual(exists, std.mem.indexOf(u8, storage[0..out.end], "Ligatures") != null); +} + comptime { for ([_][]const u8{ "Peek", "Poke", "Hexdump" }) |name| if (@hasField(registry.Builtin(), name) != Board.enabled) @compileError( diff --git a/src/config.zig b/src/config.zig index 4e46b1d8..4b47b188 100644 --- a/src/config.zig +++ b/src/config.zig @@ -111,6 +111,7 @@ pub const leader_path = paths: { table.set(.DumpDir, null); if (builtins.capabilities.window_opacity) table.set(.WindowOpacity, null); if (builtins.capabilities.window_blur) table.set(.WindowBlur, null); + if (builtins.capabilities.ligatures) table.set(.Ligatures, null); if (builtins.capabilities.panel_transitions) { table.set(.PanelSlide, "as"); table.set(.PanelZoom, "az"); @@ -573,6 +574,9 @@ pub const Runtime = struct { workspace_tag: bool = true, column_tags: bool = true, syntax_bold: bool = false, + /// A font's programming ligatures (`->`, `!=`) draw across their cells. + /// Only a shell that shapes text has the setting (`Capabilities.ligatures`). + ligatures: bool = true, /// Builtins announce themselves on the message row. verbose: bool = true, /// A message falls into its row and dissolves out of it. @@ -679,6 +683,8 @@ pub const Runtime = struct { tagline_font_size: bool, window_opacity: bool = false, window_blur: bool = false, + /// The shell shapes text, so it can draw ligatures and stop drawing them. + ligatures: bool = false, }; pub const Capability = std.meta.FieldEnum(Capabilities); @@ -686,7 +692,7 @@ pub const Runtime = struct { /// The message timings a setting word sets, each in milliseconds. pub const MessageTime = enum { linger, fall, dissolve }; - pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation }; + pub const Toggle = enum { colors, wrap, tree_context_tag_style, tag_bottom, debug, focus_tint, column_tags, syntax_bold, verbose, message_animation, ligatures }; pub const Scene = std.meta.FieldEnum(layout.SceneEffect); pub const Action = union(enum) { @@ -737,6 +743,7 @@ pub const Runtime = struct { .{ .word = "SyntaxBold", .action = .{ .toggle = .syntax_bold } }, .{ .word = "Verbose", .action = .{ .toggle = .verbose } }, .{ .word = "MessageAnimation", .action = .{ .toggle = .message_animation } }, + .{ .word = "Ligatures", .action = .{ .toggle = .ligatures }, .availability = .ligatures }, .{ .word = "MessageLinger", .action = .{ .message_ms = .linger } }, .{ .word = "MessageFall", .action = .{ .message_ms = .fall } }, .{ .word = "MessageDissolve", .action = .{ .message_ms = .dissolve } }, @@ -796,6 +803,13 @@ pub const Runtime = struct { @compileError("panel effects must use the panel-transition capability"), .scene => if (setting.availability != .scene_shaders) @compileError("scene effects must use the scene-shader capability"), + // A toggle is plain state any shell can hold, except one for + // what only some shells draw: that one exists where they do. + .toggle => |field| if (field == .ligatures) { + if (setting.availability != .ligatures) + @compileError("the ligatures toggle must use the ligatures capability"); + } else if (setting.availability != null) + @compileError("unconditional toggles cannot carry a backend capability"), else => if (setting.availability != null) @compileError("unconditional settings cannot carry a backend capability"), } @@ -925,6 +939,10 @@ pub const Runtime = struct { var wrote_transition = false; for (settings) |setting| switch (setting.action) { .toggle => |field| { + // A toggle for what this shell does not draw (Ligatures in a + // terminal, whose text its emulator draws) is no setting here: + // it is not a builtin, so the report does not list it either. + if (!setting.enabled(context.capabilities)) continue; const value = switch (field) { inline else => |f| @field(state, @tagName(f)), }; @@ -1197,6 +1215,7 @@ pub const Runtime = struct { .tagline_font_size = true, .window_opacity = true, .window_blur = true, + .ligatures = true, }, .state = &state, }; @@ -1232,6 +1251,7 @@ pub const Runtime = struct { .syntax_bold => "SyntaxBold on\n", .verbose => "Verbose off\n", .message_animation => "MessageAnimation off\n", + .ligatures => "Ligatures off\n", }, .shell => "Shell requested (new panes) fish\n", .tagline_size => "TaglineSize 73\n", @@ -1334,6 +1354,8 @@ pub const Runtime = struct { try std.testing.expect(std.mem.indexOf(u8, defaults_report, "TaglineSize unsupported\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Look hover delay: off\n") != null); try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Native images: off\n") != null); + // A shell that does not shape text has no Ligatures setting to report. + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ligatures") == null); defaults_context.platform = "web"; defaults_context.capabilities.panel_transitions = false; 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.*; |
