a73x

225863b6

refactor: give font settings one owner in the native core

a73x   2026-09-06 06:47

Commit message
refactor: give font settings one owner in the native core

CLAUDE.md
Old New
@@ -98,7 +98,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
98 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | 98 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
99 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 99 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
100 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 100 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
101 | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` · `native`(`native.zig`) — `config` `theme` `font` `atlas` `quads` `gl` `frame` `bench` | 101 | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` `font_options` `config` `theme` · `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` |
102 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 102 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
103 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 103 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
104 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 104 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
RETRO.md
Old New
@@ -1043,3 +1043,39 @@ forward stale-reply, independent-pane, high-DPI, passive-observation and private
1043 webpage/video requirements. Implementation has not started. Ligatures remain 1043 webpage/video requirements. Implementation has not started. Ligatures remain
1044 deferred. Retain the appearance worktree's live review pages and preserve main's 1044 deferred. Retain the appearance worktree's live review pages and preserve main's
1045 staged user retro file when fast-forwarding. 1045 staged user retro file when fast-forwarding.
1046
1047
1048 ## Wheel sprint opening — ownership and duplication cleanup, 2026-09-06
1049
1050 The user asked to review duplication, hexagonal architecture opportunities and
1051 refactoring, then clarified that font validation should belong to fonts rather
1052 than either input adapter. `font_options.zig` now owns the shared point-size
1053 rule. CLI/config callers retain their diagnostics; installed-face/monospace
1054 validation stays in `font.zig`. The first proposal shared the helper from config;
1055 that removed repetition but assigned the rule to the wrong owner. Removed a
1056 redundant empty-family check already enforced by the text parser.
1057
1058 Font settings, config and theme source now belong to the window-free native core.
1059 The native renderer consumes core theme values and preserves its existing public
1060 aliases. Luna implemented the bounded change; Terra reviewed ownership, error
1061 mapping and module/test reachability. Closing review removed a stale comment
1062 and retained the upper-limit rationale with its new policy owner. No extra
1063 transport or filesystem abstraction was introduced for a single caller.
1064
1065 Independent dependency probes hid GUI package metadata: all 29 native core tests
1066 passed, and the matching full GUI build failed on missing SDL3 as expected.
1067 Native build/units, full native integration and full CI passed.
1068 Evidence is in `dist/wheel-opening-cleanup/` in the new worktree. The first native
1069 integration launch overlapped the check stage's temporary source-ban mutation;
1070 it failed before building and passed after that stage finished. Both logs are
1071 retained. The native workflow now explicitly serializes other builds against
1072 that stage. Existing approved appearance recordings remain the visual baseline;
1073 this opening refactor adds no visible feature or performance claim.
1074
1075 Carry forward into wheel implementation: semantic wheel intent crosses the
1076 runtime/pump boundary; the pump consumes available mode frames before deciding
1077 history versus application input. Do not sample mutable pump modes in the GUI
1078 and queue pre-encoded bytes. Keep history/live snapshot ownership and reply
1079 correlation explicit, share actual semantic helpers instead of copying the TUI
1080 interaction core, and inject real SDL wheel events in the retained test adapter.
1081 These are implementation constraints, not completed wheel behavior.
docs/component-ownership.md
Old New
@@ -10,7 +10,7 @@ affected owner.
10 | Area | Owns | Focused gate | 10 | Area | Owns | Focused gate |
11 | --- | --- | --- | 11 | --- | --- | --- |
12 | Daemon | Authoritative terminal state and session lifecycle in `src/server/`, daemon engine files `src/engine/engine.zig` and `delta.zig`, and `src/os/server_os*` | `make daemon-test` | 12 | Daemon | Authoritative terminal state and session lifecycle in `src/server/`, daemon engine files `src/engine/engine.zig` and `delta.zig`, and `src/os/server_os*` | `make daemon-test` |
13 | Native GUI | `src/gui/native_core.zig` and its window free children (`workspace.zig`, `runtime.zig`, `picker.zig`, `persistence.zig`, `interaction.zig`), plus `src/gui/native.zig`, `frame.zig`, `bench.zig`, `atlas.zig`, `font.zig`, `quads.zig`, `gl.zig`, and `src/cli/muxg.zig` | `make native-core-test` for policy; `make native` builds `muxg` and runs `native-test` | 13 | Native GUI | `src/gui/native_core.zig` and its window free children (`workspace.zig`, `runtime.zig`, `picker.zig`, `persistence.zig`, `interaction.zig`, `font_options.zig`, `config.zig`, `theme.zig`), plus `src/gui/native.zig`, `frame.zig`, `bench.zig`, `atlas.zig`, `font.zig`, `quads.zig`, `gl.zig`, and `src/cli/muxg.zig` | `make native-core-test` for policy; `make native` builds `muxg` and runs `native-test` |
14 | Shared wire/client | The `term` component (`src/engine/term.zig`, `protocol.zig`, `grid.zig`, `replica.zig`), reusable `src/client/` services, and `src/dial.zig`, `src/link.zig`, `src/quic.zig` | `make client-test`; use `make check` before handoff | 14 | Shared wire/client | The `term` component (`src/engine/term.zig`, `protocol.zig`, `grid.zig`, `replica.zig`), reusable `src/client/` services, and `src/dial.zig`, `src/link.zig`, `src/quic.zig` | `make client-test`; use `make check` before handoff |
15 15
16 The daemon is the backend; terminal UI (`src/tui/`, `src/cli/mux_main.zig`), 16 The daemon is the backend; terminal UI (`src/tui/`, `src/cli/mux_main.zig`),
@@ -29,7 +29,12 @@ dependencies it compiles.
29 29
30 The native GUI is split into two build components. `native_core` owns pane 30 The native GUI is split into two build components. `native_core` owns pane
31 identity, attachment lifetime, persistence, picker state, native workspace 31 identity, attachment lifetime, persistence, picker state, native workspace
32 layout, and GUI interaction policy. It imports only `client` and `term`. 32 layout, GUI interaction policy, font settings, configuration parsing, and theme
33 values/merging.
34 It imports only `client` and `term`. Appearance policy tests therefore run without
35 window or font libraries; the native root retains aliases for its callers.
36 `font_options.zig` owns point-size policy. CLI/config parsers delegate to it;
37 `font.zig` owns installed-face validation and rasterization through font libraries.
33 `native` owns the window and painter; `frame.zig` is the only GUI file that 38 `native` owns the window and painter; `frame.zig` is the only GUI file that
34 spells the window library. The native root reaches core through 39 spells the window library. The native root reaches core through
35 `@import("native_core")`, so core files have 40 `@import("native_core")`, so core files have
docs/native-sprint-workflow.md
Old New
@@ -108,7 +108,10 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
108 make native-stress # Linux raw-output responsiveness 108 make native-stress # Linux raw-output responsiveness
109 ``` 109 ```
110 110
111 `make ci` excludes native tests. Use the pinned compiler. 111 `make ci` excludes native tests. Use the pinned compiler. Do not overlap
112 other builds in the same checkout with its `make check` stage: `test/bans.sh`
113 temporarily plants invalid source files to verify the source rules. Wait for
114 that stage to finish before starting native builds or integration tests.
112 115
113 The focused `make native-core-test` gate compiles workspace, runtime, picker, 116 The focused `make native-core-test` gate compiles workspace, runtime, picker,
114 persistence and interaction without window/font libraries. `native-test` 117 persistence and interaction without window/font libraries. `native-test`
docs/superpowers/plans/2026-09-06-native-wheel-scrolling.md
Old New
@@ -1,7 +1,7 @@
1 # Native parity — wheel scrolling 1 # Native parity — wheel scrolling
2 2
3 Status: scoped after the appearance branch review and merge; implementation has 3 Status: opening architecture cleanup implemented, reviewed and validated;
4 not started. The user authorized a fresh worktree to begin closing GUI/TUI gaps. 4 wheel behavior has not been implemented. The user authorized a fresh worktree to begin closing GUI/TUI gaps.
5 This is the first functional slice of git-collab issue `8b16e26b`. 5 This is the first functional slice of git-collab issue `8b16e26b`.
6 6
7 ## Sprint goal 7 ## Sprint goal
@@ -77,3 +77,67 @@ NVIDIA timing failures and the offscreen-growth limitation; neither is waived.
77 Finish with a private Tailscale webpage containing achievements, an actual GUI 77 Finish with a private Tailscale webpage containing achievements, an actual GUI
78 video, controls, evidence and limits. Record user demo approval separately from 78 video, controls, evidence and limits. Record user demo approval separately from
79 checks. Reuse the existing page workflow while preserving previous routes. 79 checks. Reuse the existing page workflow while preserving previous routes.
80
81
82 ## Architecture review before implementation
83
84 The user asked explicitly for duplication, hexagonal architecture opportunities
85 and refactoring. Review decisions:
86
87 - Give font settings one owner in `font_options.zig`; both CLI and config
88 parsing call its point-size policy. Entry adapters retain their diagnostic
89 mapping, and the font backend retains installed-face validation. Sharing a
90 helper from `config.zig` alone would remove duplication but put domain policy
91 in an input adapter.
92 - Move font settings and config/theme source ownership to `native_core`, with public aliases in
93 `native`. The renderer consumes theme values; window/font libraries are not
94 needed to test config parsing, precedence and theme derivation.
95 - Preserve the existing SDL → controller → runtime → session-pump boundary.
96 Add semantic wheel intent at the transport mailbox and interpret it using
97 the pump-owned terminal modes, after consuming available frames. Reading modes
98 in the GUI and queueing pre-encoded input could race an incoming mode change.
99 Verify the ordering with real mode frames and independently observed PTY bytes.
100 - Keep live/history snapshots behind the pump/runtime boundary. Wheel work should
101 narrow snapshot access rather than introduce new GUI reads of pump internals;
102 request correlation and view ownership belong in its implementation contract.
103 - Do not copy the TUI interaction core or mouse byte parser into the GUI. Reuse
104 protocol/replica behavior and share only pure semantic encoding when an actual
105 second caller needs it. The frozen wall keeps its behavior.
106 - Extend the existing test input adapter with a real SDL wheel event. A separate
107 test-only scroll path would leave actual event routing untested. Broader hook
108 extraction is a later package unless this slice exposes a concrete need.
109
110 Config and theme line readers look similar but intentionally differ: unknown
111 config keys are fatal, unknown theme keys warn, missing config is allowed, and a
112 missing selected theme is fatal. Their shared color/palette parsing is already
113 centralized. A generic parser framework or filesystem interface is not justified
114 by the current callers; retain the existing pure parse/merge functions and small
115 file-loading adapters.
116
117
118 ### Opening cleanup evidence
119
120 `font_options.zig` now owns the point-size parser and validity rule. Config and
121 CLI adapters preserve their existing errors; installed-face checks remain with
122 the font backend. The native core owns font settings, config and theme, and the
123 native rendering module retains public aliases. Removed the redundant empty
124 family check after the text parser had already rejected empty values.
125
126 Independent review found no behavior/API issue; it caught and removed a stale
127 point-size comment left above palette parsing. The module split is checked by
128 actual dependency probes: with `PKG_CONFIG_LIBDIR` set to an empty directory and
129 `PKG_CONFIG_PATH` empty, `native-core-test` passed all 29 tests, while the full
130 native build failed on missing SDL3 metadata as expected. This is a build
131 boundary check, not merely a directory rename.
132
133 Logs are in `dist/wheel-opening-cleanup/`. Native build, native unit/core tests
134 and formatting passed. Full CI (check, terminal integration, agent, throughput)
135 and final native integration passed with no gate changes. The first native integration launch overlapped CI's temporary
136 source-ban probe and stopped before compiling; the retry started after the
137 probe was removed. The original failed log is retained, and the workflow now
138 calls out this build-serialization requirement.
139
140 This refactor preserves appearance behavior and uses the approved appearance
141 recordings as its visual baseline; no new visible feature or performance claim
142 is made. The functional wheel sprint still requires its own real-input demo and
143 private review page.
src/cli/muxg.zig
Old New
@@ -13,9 +13,7 @@ const hosts = client.hosts;
13 const PointSize = struct { 13 const PointSize = struct {
14 value: f64, 14 value: f64,
15 pub fn parseCLI(text: []const u8) !PointSize { 15 pub fn parseCLI(text: []const u8) !PointSize {
16 const value = std.fmt.parseFloat(f64, text) catch return error.Invalid; 16 return .{ .value = native.font_options.parsePointSize(text) catch return error.Invalid };
17 if (!std.math.isFinite(value) or value < 1 or value > 192) return error.Invalid;
18 return .{ .value = value };
19 } 17 }
20 }; 18 };
21 const Color = struct { 19 const Color = struct {
src/gui/config.zig
Old New
@@ -1,5 +1,6 @@
1 //! Small, explicit native appearance and font configuration parser. 1 //! Small, explicit native appearance and font configuration parser.
2 const std = @import("std"); 2 const std = @import("std");
3 const font_options = @import("font_options.zig");
3 4
4 pub const Settings = struct { 5 pub const Settings = struct {
5 family: ?[:0]const u8 = null, 6 family: ?[:0]const u8 = null,
@@ -32,8 +33,6 @@ pub fn parsePalette(text: []const u8) Error!PalettePair {
32 return .{ .index = index, .color = parseColor(std.mem.trim(u8, text[eq + 1 ..], " \t")) catch return error.InvalidPalette }; 33 return .{ .index = index, .color = parseColor(std.mem.trim(u8, text[eq + 1 ..], " \t")) catch return error.InvalidPalette };
33 } 34 }
34 35
35 // 192pt is the largest accepted size: at Linux's 96 DPI it is 256px at 100%.
36
37 fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 { 36 fn valueText(raw: []const u8, alloc: std.mem.Allocator) Error![:0]const u8 {
38 var value = raw; 37 var value = raw;
39 if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1]; 38 if (value.len >= 2 and value[0] == '"' and value[value.len - 1] == '"') value = value[1 .. value.len - 1];
@@ -70,20 +69,11 @@ pub fn parse(alloc: std.mem.Allocator, bytes: []const u8, line_out: ?*usize) Err
70 return error.InvalidSyntax; 69 return error.InvalidSyntax;
71 } 70 }
72 out.family = try valueText(value, alloc); 71 out.family = try valueText(value, alloc);
73 if (out.family.?.len == 0) {
74 return error.MissingFamily;
75 }
76 } else if (std.mem.eql(u8, key, "font-size")) { 72 } else if (std.mem.eql(u8, key, "font-size")) {
77 if (out.size_points != null) { 73 if (out.size_points != null) {
78 return error.InvalidSyntax; 74 return error.InvalidSyntax;
79 } 75 }
80 const points = std.fmt.parseFloat(f64, value) catch { 76 out.size_points = font_options.parsePointSize(value) catch return error.InvalidValue;
81 return error.InvalidValue;
82 };
83 if (!std.math.isFinite(points) or points < 1 or points > 192) {
84 return error.InvalidValue;
85 }
86 out.size_points = points;
87 } else if (std.mem.eql(u8, key, "theme")) { 77 } else if (std.mem.eql(u8, key, "theme")) {
88 if (out.theme != null) return error.InvalidSyntax; 78 if (out.theme != null) return error.InvalidSyntax;
89 out.theme = valueText(value, alloc) catch |err| return if (err == error.MissingFamily) error.InvalidThemeName else err; 79 out.theme = valueText(value, alloc) catch |err| return if (err == error.MissingFamily) error.InvalidThemeName else err;
src/gui/font_options.zig
Old New
@@ -0,0 +1,19 @@
1 //! Shared font option validation for config files and native CLI flags.
2 const std = @import("std");
3
4 pub const Error = error{InvalidValue};
5
6 /// Ghostty-style point size: finite, inclusive range 1–192 points.
7 /// The upper limit is 256 pixels at Linux's 96 DPI and 100% display scale.
8 pub fn parsePointSize(text: []const u8) Error!f64 {
9 const value = std.fmt.parseFloat(f64, text) catch return error.InvalidValue;
10 if (!std.math.isFinite(value) or value < 1 or value > 192) return error.InvalidValue;
11 return value;
12 }
13
14 test "point size validation is shared and bounded" {
15 try std.testing.expectEqual(@as(f64, 12.5), try parsePointSize("12.5"));
16 try std.testing.expectError(error.InvalidValue, parsePointSize("nan"));
17 try std.testing.expectError(error.InvalidValue, parsePointSize("0"));
18 try std.testing.expectError(error.InvalidValue, parsePointSize("193"));
19 }
src/gui/frame.zig
Old New
@@ -12,7 +12,7 @@ const interaction = native_core.interaction;
12 const font = @import("font.zig"); 12 const font = @import("font.zig");
13 const atlas = @import("atlas.zig"); 13 const atlas = @import("atlas.zig");
14 const quads = @import("quads.zig"); 14 const quads = @import("quads.zig");
15 const theme_mod = @import("theme.zig"); 15 const theme_mod = native_core.theme;
16 const gl = @import("gl.zig"); 16 const gl = @import("gl.zig");
17 const bench = @import("bench.zig"); 17 const bench = @import("bench.zig");
18 18
src/gui/native.zig
Old New
@@ -16,9 +16,10 @@ pub const frame = @import("frame.zig");
16 pub const bench = @import("bench.zig"); 16 pub const bench = @import("bench.zig");
17 pub const atlas = @import("atlas.zig"); 17 pub const atlas = @import("atlas.zig");
18 pub const font = @import("font.zig"); 18 pub const font = @import("font.zig");
19 pub const config = @import("config.zig"); 19 pub const config = core.config;
20 pub const quads = @import("quads.zig"); 20 pub const quads = @import("quads.zig");
21 pub const theme = @import("theme.zig"); 21 pub const theme = core.theme;
22 pub const font_options = core.font_options;
22 pub const gl = @import("gl.zig"); 23 pub const gl = @import("gl.zig");
23 24
24 // Compatibility aliases for callers that previously reached these through 25 // Compatibility aliases for callers that previously reached these through
@@ -41,8 +42,6 @@ test {
41 _ = bench; 42 _ = bench;
42 _ = atlas; 43 _ = atlas;
43 _ = font; 44 _ = font;
44 _ = config;
45 _ = quads; 45 _ = quads;
46 _ = theme;
47 _ = gl; 46 _ = gl;
48 } 47 }
src/gui/native_core.zig
Old New
@@ -1,6 +1,7 @@
1 //! Window-free native GUI model, transport runtime, picker, persistence, and 1 //! Window-free native GUI model, transport runtime, appearance/font policy,
2 //! interaction policy. The native window and painter consume this component; 2 //! picker, persistence, and interaction policy. The native window and painter
3 //! keeping it as its own module lets its tests run without window libraries. 3 //! consume this component; keeping it as its own module lets its tests run
4 //! without window libraries.
4 const client = @import("client"); 5 const client = @import("client");
5 const term = @import("term"); 6 const term = @import("term");
6 7
@@ -9,6 +10,9 @@ pub const runtime = @import("runtime.zig");
9 pub const picker = @import("picker.zig"); 10 pub const picker = @import("picker.zig");
10 pub const persistence = @import("persistence.zig"); 11 pub const persistence = @import("persistence.zig");
11 pub const interaction = @import("interaction.zig"); 12 pub const interaction = @import("interaction.zig");
13 pub const config = @import("config.zig");
14 pub const theme = @import("theme.zig");
15 pub const font_options = @import("font_options.zig");
12 16
13 test { 17 test {
14 _ = client; 18 _ = client;
@@ -18,4 +22,7 @@ test {
18 _ = picker; 22 _ = picker;
19 _ = persistence; 23 _ = persistence;
20 _ = interaction; 24 _ = interaction;
25 _ = config;
26 _ = theme;
27 _ = font_options;
21 } 28 }
src/gui/quads.zig
Old New
@@ -4,7 +4,7 @@ const term = @import("term");
4 const grid = term.grid; 4 const grid = term.grid;
5 const proto = term.protocol; 5 const proto = term.protocol;
6 const atlas = @import("atlas.zig"); 6 const atlas = @import("atlas.zig");
7 const theme_mod = @import("theme.zig"); 7 const theme_mod = @import("native_core").theme;
8 pub const Instance = extern struct { 8 pub const Instance = extern struct {
9 x: f32, 9 x: f32,
10 y: f32, 10 y: f32,