diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-16 15:49:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:44:42 -0300 |
| commit | 1551e409c31992437cb2fa864f576d45c8433801 (patch) | |
| tree | e2fae8451f87b735a1360c7c2e383fdc40165789 /src/runtime_config.zig | |
| parent | be2a9957708cbf0c478ca861c4a1f0f227bbfe10 (diff) | |
| download | pardes-1551e409c31992437cb2fa864f576d45c8433801.tar.gz pardes-1551e409c31992437cb2fa864f576d45c8433801.zip | |
big slow change: prebuilt shaders (SPIR-V/Metal), core gui reflow, docs, web + snapshot refresh
Diffstat (limited to 'src/runtime_config.zig')
| -rw-r--r-- | src/runtime_config.zig | 568 |
1 files changed, 568 insertions, 0 deletions
diff --git a/src/runtime_config.zig b/src/runtime_config.zig new file mode 100644 index 00000000..806cb766 --- /dev/null +++ b/src/runtime_config.zig @@ -0,0 +1,568 @@ +//! Runtime choices in one plain, owned record. +//! +//! `Setting` is compile-time metadata for the builtin registry; `State` is the +//! data it mutates and the Config report reads. Neither contains callbacks. +const std = @import("std"); +const panel_animation = @import("panel_animation.zig"); + +/// Tagline glyphs retain body-cell geometry, so allowing a face larger than +/// the body would clip into neighbouring cells. Zero would make the role +/// invisible. Keep the runtime command on the same 1...100 contract as the +/// build-time default in config.zig. +pub const tagline_percent_min: u8 = 1; +pub const tagline_percent_max: u8 = 100; + +pub fn Text(comptime capacity: usize) type { + return struct { + bytes: [capacity]u8 = @splat(0), + len: std.math.IntFittingRange(0, capacity) = 0, + + pub fn get(value: *const @This()) []const u8 { + return value.bytes[0..value.len]; + } + + pub fn set(value: *@This(), text: []const u8) bool { + if (text.len > capacity) return false; + @memcpy(value.bytes[0..text.len], text); + value.len = @intCast(text.len); + return true; + } + + pub fn clear(value: *@This()) void { + value.len = 0; + } + }; +} + +pub const State = struct { + theme: usize = 0, + colors: bool = true, + wrap: bool = true, + tag_bottom: bool = false, + debug: bool = false, + + /// Empty requested text means config.default_shell. Resolution belongs to + /// the native host (the core deliberately has no filesystem), so retain + /// the executable it actually chose separately and mark a changed request + /// pending until the next terminal spawn acknowledges it. + shell: struct { + requested: Text(255) = .{}, + // Fixed across native and freestanding builds: runtime State is one + // data schema, and web has no std.fs PATH_MAX to derive this from. + effective: Text(4095) = .{}, + pending: bool = true, + } = .{}, + + /// The shell acknowledges a font before `effective` changes. A rejected + /// request therefore remains queryable without claiming it is on screen. + font: struct { + // 4095 leaves room for the NUL in the macOS C bridge without a + // second, subtly different limit at that boundary. + requested_path: Text(4095) = .{}, + requested_name: Text(255) = .{}, + effective_name: Text(255) = .{}, + pending: bool = false, + effective_size_hundredths: u16 = 0, + effective_size_unit: FontSizeUnit = .unknown, + // Pardes.init copies config.gui_tagline_font_percent here. Keeping + // the compiled choice in the live record makes Config truthful while + // avoiding the config -> builtins -> runtime_config import cycle. + tagline_percent: u8 = 100, + } = .{}, + + panel_transition: panel_animation.Transition = .off, + scene_effects: panel_animation.SceneEffect = .{}, + + pub fn toggleTransition(state: *State, effect: panel_animation.Transition) void { + std.debug.assert(effect != .off); + state.panel_transition = if (state.panel_transition == effect) .off else effect; + } +}; + +pub const FontSizeUnit = enum { unknown, pixels, points }; + +/// Replace the requested font tuple atomically. Both fixed strings are +/// preflighted before either changes, so a rejected long name cannot leave a +/// new path paired with stale metadata. +pub fn requestFont(state: *State, path: []const u8, name: []const u8) bool { + if (path.len > state.font.requested_path.bytes.len or + name.len > state.font.requested_name.bytes.len) return false; + std.debug.assert(state.font.requested_path.set(path)); + std.debug.assert(state.font.requested_name.set(name)); + state.font.pending = true; + return true; +} + +/// Backend facilities are plain data supplied once by the platform-facing +/// registry. The same value gates command generation, leader paths, source +/// queries, and the Config report, so "unsupported" cannot mean four subtly +/// different things at those four call sites. +pub const Capabilities = struct { + font_picker: bool, + panel_transitions: bool, + scene_shaders: bool, + /// A smaller tagline face is also supported by the browser, which does + /// not own a native font picker. Keep this fact deliberately independent. + tagline_font_size: bool, +}; + +pub const Capability = std.meta.FieldEnum(Capabilities); + +pub const Toggle = enum { colors, wrap, tag_bottom, debug }; +pub const Scene = std.meta.FieldEnum(panel_animation.SceneEffect); + +pub const Action = union(enum) { + toggle: Toggle, + shell, + theme, + font, + tagline_size, + transition: panel_animation.Transition, + scene: Scene, +}; + +pub const Setting = struct { + word: []const u8, + action: Action, + availability: ?Capability = null, + + pub fn takesArg(setting: Setting) bool { + return switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }; + } + + pub fn enabled(setting: Setting, capabilities: Capabilities) bool { + const capability = setting.availability orelse return true; + return switch (capability) { + inline else => |field| @field(capabilities, @tagName(field)), + }; + } +}; + +/// This table is both the generated-setting builtin input and the Config +/// report order. Adding mutable config without adding a query row is therefore +/// impossible unless it is deliberately kept out of this user-facing state. +pub const settings = [_]Setting{ + .{ .word = "Colors", .action = .{ .toggle = .colors } }, + .{ .word = "Wrap", .action = .{ .toggle = .wrap } }, + .{ .word = "Tagbottom", .action = .{ .toggle = .tag_bottom } }, + .{ .word = "Debug", .action = .{ .toggle = .debug } }, + .{ .word = "Theme", .action = .theme }, + .{ .word = "Shell", .action = .shell }, + .{ .word = "Font", .action = .font, .availability = .font_picker }, + .{ .word = "TaglineSize", .action = .tagline_size, .availability = .font_picker }, + .{ .word = "PanelSlide", .action = .{ .transition = .slide }, .availability = .panel_transitions }, + .{ .word = "PanelZoom", .action = .{ .transition = .zoom }, .availability = .panel_transitions }, + .{ .word = "PanelDissolve", .action = .{ .transition = .dissolve }, .availability = .panel_transitions }, + .{ .word = "PanelAscii", .action = .{ .transition = .ascii }, .availability = .panel_transitions }, + .{ .word = "PanelVertical", .action = .{ .transition = .vertical }, .availability = .panel_transitions }, + .{ .word = "Crt", .action = .{ .scene = .crt }, .availability = .scene_shaders }, + .{ .word = "Ripple", .action = .{ .scene = .ripple }, .availability = .scene_shaders }, + .{ .word = "Glitch", .action = .{ .scene = .glitch }, .availability = .scene_shaders }, +}; + +pub fn find(name: []const u8) ?Setting { + for (settings) |setting| if (std.mem.eql(u8, setting.word, name)) return setting; + return null; +} + +/// The table is also the sole action-to-command vocabulary. Compile-time +/// callers use this for picker rows; the Config report uses it for the active +/// transition. No parallel enum or transition-name switch can drift. +pub fn findAction(action: Action) ?Setting { + for (settings) |setting| if (std.meta.eql(setting.action, action)) return setting; + return null; +} + +fn actionCount(comptime action: Action) comptime_int { + var count = 0; + for (settings) |setting| count += @intFromBool(std.meta.eql(setting.action, action)); + return count; +} + +comptime { + @setEvalBranchQuota(20_000); + for (settings, 0..) |setting, i| { + if (setting.word.len == 0) @compileError("runtime setting has an empty command word"); + for (settings[i + 1 ..]) |later| if (std.mem.eql(u8, setting.word, later.word)) + @compileError("duplicate runtime setting command word: " ++ setting.word); + switch (setting.action) { + .font, .tagline_size => if (setting.availability != .font_picker) + @compileError("native font settings must use the font-picker capability"), + .transition => if (setting.availability != .panel_transitions) + @compileError("panel effects must use the panel-transition capability"), + .scene => if (setting.availability != .scene_shaders) + @compileError("scene effects must use the scene-shader capability"), + else => if (setting.availability != null) + @compileError("unconditional settings cannot carry a backend capability"), + } + } + for (std.enums.values(Toggle)) |field| if (actionCount(.{ .toggle = field }) != 1) + @compileError("runtime toggle must occur exactly once: " ++ @tagName(field)); + if (actionCount(.shell) != 1 or actionCount(.theme) != 1 or actionCount(.font) != 1 or + actionCount(.tagline_size) != 1) + @compileError("Shell, Theme, Font, and TaglineSize actions must each occur exactly once"); + for (std.enums.values(panel_animation.Transition)) |effect| { + const expected: comptime_int = @intFromBool(effect != .off); + if (actionCount(.{ .transition = effect }) != expected) + @compileError("non-off panel transition must occur exactly once: " ++ @tagName(effect)); + } + for (std.enums.values(Scene)) |effect| { + if (actionCount(.{ .scene = effect }) != 1) + @compileError("scene effect must occur exactly once: " ++ @tagName(effect)); + if (@FieldType(panel_animation.SceneEffect, @tagName(effect)) != bool) + @compileError("scene effect fields must be booleans: " ++ @tagName(effect)); + } +} + +/// Apply settings whose operation is independent of themes, font discovery, +/// or a shell. Those three remain explicit at the generated builtin's edge. +pub fn applySimple(state: *State, setting: Setting, argument: ?[]const u8) bool { + switch (setting.action) { + .toggle => |field| switch (field) { + .colors => state.colors = !state.colors, + .wrap => state.wrap = !state.wrap, + .tag_bottom => state.tag_bottom = !state.tag_bottom, + .debug => state.debug = !state.debug, + }, + .shell => { + const value = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + if (value.len == 0 or !state.shell.requested.set(value)) return false; + state.shell.pending = true; + }, + .tagline_size => { + const text = std.mem.trim(u8, argument orelse return false, " \t\r\n"); + const percent = std.fmt.parseInt(u16, text, 10) catch return false; + if (percent < tagline_percent_min or percent > tagline_percent_max) return false; + // Parse and validate before the sole write: malformed commands + // cannot partially alter the live configuration. + state.font.tagline_percent = @intCast(percent); + }, + .transition => |effect| state.toggleTransition(effect), + .scene => |effect| switch (effect) { + inline else => |field| { + const value = &@field(state.scene_effects, @tagName(field)); + value.* = !value.*; + }, + }, + .theme, .font => return false, + } + return true; +} + +/// The runtime facts which do not belong to mutable `State`, supplied by the +/// core when it materialises +Config. Slices are borrowed for this one write. +pub const ReportContext = struct { + startup_config_path: ?[]const u8, + platform: []const u8, + theme_name: []const u8, + compiled_default_shell: []const u8, + /// Null outside the SDL GUI. The borrowed label comes from effect_sources, + /// which knows whether its embedded GLSL import is live or paired prebuilt. + gui_shader_source_mode: ?[]const u8 = null, + hover_delay_frames: ?u16, + native_images: bool, + capabilities: Capabilities, + state: *const State, +}; + +fn onOff(value: bool) []const u8 { + return if (value) "on" else "off"; +} + +fn shown(text: []const u8) []const u8 { + return if (text.len == 0) "(none)" else text; +} + +/// Name the setting which selected the one active transition. This keeps the +/// report vocabulary identical to the generated builtin registry. +fn transitionSettingName(transition: panel_animation.Transition) []const u8 { + if (transition == .off) return "off"; + return findAction(.{ .transition = transition }).?.word; +} + +/// Write the complete live Config report without allocation. Setting-backed +/// rows follow `settings` order; host facts follow them as a compact footer. +pub fn writeReport(out: *std.Io.Writer, context: ReportContext) !void { + const state = context.state; + var wrote_transition = false; + for (settings) |setting| switch (setting.action) { + .toggle => |field| { + const value = switch (field) { + .colors => state.colors, + .wrap => state.wrap, + .tag_bottom => state.tag_bottom, + .debug => state.debug, + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(value) }); + }, + .theme => try out.print("{s}: {s}\n", .{ setting.word, context.theme_name }), + .shell => { + const chosen = state.shell.requested.get(); + try out.print( + "{s} requested (new panes): {s}{s}\n" ++ + "{s} effective (last spawn): {s}\n" ++ + "{s} pending: {s}\n", + .{ + setting.word, + if (chosen.len == 0) context.compiled_default_shell else chosen, + if (chosen.len == 0) " (default)" else "", + setting.word, + shown(state.shell.effective.get()), + setting.word, + onOff(state.shell.pending), + }, + ); + }, + .font => if (!setting.enabled(context.capabilities)) + try out.print("{s}: unsupported\n", .{setting.word}) + else + try out.print( + "{s} requested: {s}\n" ++ + "{s} requested path: {s}\n" ++ + "{s} effective: {s}\n" ++ + "{s} pending: {s}\n" ++ + "{s} effective size: {d}.{d:0>2} {s}\n", + .{ + setting.word, + shown(state.font.requested_name.get()), + setting.word, + shown(state.font.requested_path.get()), + setting.word, + shown(state.font.effective_name.get()), + setting.word, + onOff(state.font.pending), + setting.word, + state.font.effective_size_hundredths / 100, + state.font.effective_size_hundredths % 100, + @tagName(state.font.effective_size_unit), + }, + ), + .tagline_size => if (!context.capabilities.tagline_font_size) + try out.print("{s}: unsupported\n", .{setting.word}) + else if (!setting.enabled(context.capabilities)) + try out.print("{s}: {d}% (build-time only)\n", .{ setting.word, state.font.tagline_percent }) + else + try out.print("{s}: {d}%\n", .{ setting.word, state.font.tagline_percent }), + .transition => { + if (wrote_transition) continue; + wrote_transition = true; + if (!setting.enabled(context.capabilities)) + try out.writeAll("Panel transition: unsupported\n") + else + try out.print("Panel transition: {s}\n", .{transitionSettingName(state.panel_transition)}); + }, + .scene => |effect| { + if (!setting.enabled(context.capabilities)) { + try out.print("{s}: unsupported\n", .{setting.word}); + continue; + } + const enabled = switch (effect) { + inline else => |field| @field(state.scene_effects, @tagName(field)), + }; + try out.print("{s}: {s}\n", .{ setting.word, onOff(enabled) }); + }, + }; + + if (context.startup_config_path) |path| + try out.print("Startup config: {s}\n", .{path}) + else + try out.writeAll("Startup config: no per-user config path\n"); + try out.print( + "Platform: {s}\n" ++ + "Compiled default shell: {s}\n", + .{ context.platform, context.compiled_default_shell }, + ); + if (context.gui_shader_source_mode) |mode| + try out.print("GUI shader source: {s}\n", .{mode}); + if (context.hover_delay_frames) |frames| + try out.print("Look hover delay: {d} frames\n", .{frames}) + else + try out.writeAll("Look hover delay: off\n"); + try out.print("Native images: {s}\n", .{onOff(context.native_images)}); +} + +test "setting names are unique and argument metadata follows actions" { + for (settings, 0..) |setting, i| { + try std.testing.expect(setting.word.len > 0); + for (settings[i + 1 ..]) |later| + try std.testing.expect(!std.mem.eql(u8, setting.word, later.word)); + try std.testing.expectEqual(switch (setting.action) { + .shell, .theme, .font, .tagline_size => true, + else => false, + }, setting.takesArg()); + } +} + +test "simple setting application mutates only its plain field" { + var state: State = .{}; + try std.testing.expect(applySimple(&state, find("Colors").?, null)); + try std.testing.expect(!state.colors); + try std.testing.expect(applySimple(&state, find("Shell").?, " fish\n")); + try std.testing.expectEqualStrings("fish", state.shell.requested.get()); + try std.testing.expect(state.shell.pending); + try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(panel_animation.Transition.ascii, state.panel_transition); + try std.testing.expect(applySimple(&state, find("PanelAscii").?, null)); + try std.testing.expectEqual(panel_animation.Transition.off, state.panel_transition); + try std.testing.expect(applySimple(&state, find("Crt").?, null)); + try std.testing.expect(state.scene_effects.crt); +} + +test "tagline size validates before mutating live state" { + const setting = find("TaglineSize").?; + var state: State = .{}; + + for ([_][]const u8{ "1", " 82\n", "100" }) |argument| { + try std.testing.expect(applySimple(&state, setting, argument)); + try std.testing.expectEqual(try std.fmt.parseInt(u8, std.mem.trim(u8, argument, " \t\r\n"), 10), state.font.tagline_percent); + } + + state.font.tagline_percent = 67; + for ([_]?[]const u8{ null, "", "0", "101", "-1", "50%", "999999999999999999999" }) |argument| { + try std.testing.expect(!applySimple(&state, setting, argument)); + try std.testing.expectEqual(@as(u8, 67), state.font.tagline_percent); + } +} + +test "font request tuple rejects atomically" { + var state: State = .{}; + try std.testing.expect(requestFont(&state, "/fonts/old.ttf", "Old")); + var too_long: [256]u8 = @splat('x'); + try std.testing.expect(!requestFont(&state, "/fonts/new.ttf", &too_long)); + try std.testing.expectEqualStrings("/fonts/old.ttf", state.font.requested_path.get()); + try std.testing.expectEqualStrings("Old", state.font.requested_name.get()); +} + +test "Config report observes every simple setting and all live context" { + var state: State = .{}; + var storage: [4096]u8 = undefined; + const context: ReportContext = .{ + .startup_config_path = "/tmp/pardes/config", + .platform = "gui", + .theme_name = "acme", + .compiled_default_shell = "/bin/sh", + .gui_shader_source_mode = "live GLSL compiled during this build", + .hover_delay_frames = 18, + .native_images = true, + .capabilities = .{ + .font_picker = true, + .panel_transitions = true, + .scene_shaders = true, + .tagline_font_size = true, + }, + .state = &state, + }; + + for (settings) |setting| { + switch (setting.action) { + .theme, .font => continue, + else => {}, + } + const argument: ?[]const u8 = switch (setting.action) { + .shell => "fish", + .tagline_size => "73", + else => null, + }; + try std.testing.expect(applySimple(&state, setting, argument)); + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + const expected = switch (setting.action) { + .toggle => |field| switch (field) { + .colors => "Colors: off\n", + .wrap => "Wrap: off\n", + .tag_bottom => "Tagbottom: on\n", + .debug => "Debug: on\n", + }, + .shell => "Shell requested (new panes): fish\n", + .tagline_size => "TaglineSize: 73%\n", + .transition => |transition| switch (transition) { + .off => unreachable, + .slide => "Panel transition: PanelSlide\n", + .zoom => "Panel transition: PanelZoom\n", + .dissolve => "Panel transition: PanelDissolve\n", + .ascii => "Panel transition: PanelAscii\n", + .vertical => "Panel transition: PanelVertical\n", + }, + .scene => |effect| switch (effect) { + .crt => "Crt: on\n", + .ripple => "Ripple: on\n", + .glitch => "Glitch: on\n", + }, + .theme, .font => unreachable, + }; + try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + } + + try std.testing.expect(state.font.requested_name.set("Wanted Mono")); + try std.testing.expect(state.font.requested_path.set("/fonts/wanted.ttf")); + try std.testing.expect(state.font.effective_name.set("Effective Mono")); + state.font.pending = true; + state.font.effective_size_hundredths = 1375; + state.font.effective_size_unit = .points; + state.font.tagline_percent = 82; + + var out: std.Io.Writer = .fixed(&storage); + try writeReport(&out, context); + const report = storage[0..out.end]; + for ([_][]const u8{ + "Theme: acme\n", + "Font requested: Wanted Mono\n", + "Font requested path: /fonts/wanted.ttf\n", + "Font effective: Effective Mono\n", + "Font pending: on\n", + "Font effective size: 13.75 points\n", + "TaglineSize: 82%\n", + "Startup config: /tmp/pardes/config\n", + "Platform: gui\n", + "Compiled default shell: /bin/sh\n", + "GUI shader source: live GLSL compiled during this build\n", + "Look hover delay: 18 frames\n", + "Native images: on\n", + }) |expected| try std.testing.expect(std.mem.indexOf(u8, report, expected) != null); + + var defaults: State = .{}; + var defaults_context = context; + defaults_context.startup_config_path = null; + defaults_context.platform = "tty"; + defaults_context.gui_shader_source_mode = null; + defaults_context.hover_delay_frames = null; + defaults_context.native_images = false; + defaults_context.capabilities = .{ + .font_picker = false, + .panel_transitions = true, + .scene_shaders = false, + .tagline_font_size = false, + }; + defaults_context.state = &defaults; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const defaults_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell requested (new panes): /bin/sh (default)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell effective (last spawn): (none)\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Shell pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Startup config: no per-user config path\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "GUI shader source:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Font requested:") == null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Panel transition: off\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Crt: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Ripple: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, defaults_report, "Glitch: unsupported\n") != null); + 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); + + defaults_context.platform = "web"; + defaults_context.capabilities.panel_transitions = false; + defaults_context.capabilities.tagline_font_size = true; + out = .fixed(&storage); + try writeReport(&out, defaults_context); + const web_report = storage[0..out.end]; + try std.testing.expect(std.mem.indexOf(u8, web_report, "Panel transition: unsupported\n") != null); + try std.testing.expect(std.mem.indexOf(u8, web_report, "TaglineSize: 100% (build-time only)\n") != null); +} |
