//! 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/init", .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/init\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); }