From 345b8773c3130187c8427507ef9be1cf20737145 Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Fri, 13 Nov 2026 07:49:04 -0500 Subject: [PATCH 1/2] themes: an app can contribute its own and name the default Two options an app built on fizzy passes in its dependency invocation, following `-Dapp-layout`: `-Dapp-theme` is a LazyPath to a file with `pub fn add(gpa, themes: *std.ArrayList(dvui.Theme)) !void`; the editor appends its themes beside the user's, before fizzy's own, so an app's themes sit at the top of the picker. The module wires into the executable's root, the web build's, and the test module, each only when the build was given it (the editor's `has_app_theme` branch is comptime-off otherwise, so the import is never analyzed). `-Dapp-default-theme` names the theme a fresh settings store starts with (`Settings.default_theme`). It is a name, checked against what the editor registers; a name nothing registered logs and falls back to the first entry, as a stored theme that no longer exists does. --- app/settings/Settings.zig | 8 +++++++- build/app.zig | 27 +++++++++++++++++++++++++++ build/exe.zig | 4 ++++ build/sdk.zig | 22 ++++++++++++++++++++++ build/web.zig | 2 ++ src/editor/Editor.zig | 9 +++++++++ 6 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index 3aaa03ada..ddd7c9e12 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -8,7 +8,13 @@ const build_opts = @import("build_opts"); const Settings = @This(); -pub const default_theme = "Fizzy Dark"; +/// The theme a fresh settings store starts with: the app's (`-Dapp-default-theme`, +/// `build/app.zig`), or fizzy's. It names one of the themes the editor registers (fizzy's, the +/// user's, or the app's own at `-Dapp-theme`); a name nothing registered logs and falls back. +pub const default_theme = if (@hasDecl(build_opts, "app_default_theme") and build_opts.app_default_theme.len > 0) + build_opts.app_default_theme +else + "Fizzy Dark"; /// Duration after the last edit before autosave runs (during normal operation). pub const autosave_timeout_ns: i128 = 500 * 1_000_000; diff --git a/build/app.zig b/build/app.zig index e42e3ea2a..48987a76f 100644 --- a/build/app.zig +++ b/build/app.zig @@ -105,6 +105,7 @@ pub const Config = struct { app_repo_url_set: bool, app_issues_url: []const u8, app_layout_path: ?std.Build.LazyPath, + app_theme_path: ?std.Build.LazyPath, web_storage: []const u8, static_workbench: bool, static_text: bool, @@ -526,7 +527,23 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options "app-layout", "App-owned layout file (pub fn layout(?*anyopaque, *Layout))", ); + // A consumer that wants its own themes passes `-Dapp-theme=` (a LazyPath to + // `pub fn add(gpa, themes: *std.ArrayList(dvui.Theme)) !void`), which the editor appends + // beside the user's, and `-Dapp-default-theme=` names the theme a fresh settings store + // starts with. Fizzy itself ships its own three and starts on `Fizzy Dark`. + const app_theme_path = b.option( + std.Build.LazyPath, + "app-theme", + "App-owned themes file (pub fn add(gpa, *std.ArrayList(dvui.Theme)) !void)", + ); + const app_default_theme = b.option( + []const u8, + "app-default-theme", + "The theme a fresh settings store starts with (default: Fizzy Dark)", + ); build_opts.addOption(bool, "has_app_layout", app_layout_path != null); + build_opts.addOption(bool, "has_app_theme", app_theme_path != null); + build_opts.addOption([]const u8, "app_default_theme", app_default_theme orelse ""); // The frame of fizzy's own shape an app keeps (`src/editor/Chrome.zig`); the defaults are // fizzy's own. const chrome_sidebar_edge = b.option( @@ -631,6 +648,7 @@ pub fn readConfig(b: *std.Build, target: std.Build.ResolvedTarget, opts: Options .app_repo_url_set = app_repo_url_set, .app_issues_url = app_issues_url, .app_layout_path = app_layout_path, + .app_theme_path = app_theme_path, .web_storage = web_storage, .static_workbench = static_workbench, .static_text = static_text, @@ -696,6 +714,7 @@ pub fn construct( assets_module, app_icon_module, cfg.app_layout_path, + cfg.app_theme_path, try @import("sdk.zig").pluginsFor(b, opts.app_plugins, .web), opts.web_plugin_deps, opts.web_plugin_dirs, @@ -784,6 +803,7 @@ fn native( const app_repo_url = cfg.app_repo_url; const app_repo_url_fallback = cfg.app_repo_url_fallback; const app_layout_path = cfg.app_layout_path; + const app_theme_path = cfg.app_theme_path; const static_workbench = cfg.static_workbench; const static_text = cfg.static_text; const static_image = cfg.static_image; @@ -808,6 +828,7 @@ fn native( velopack_enabled, app_name, app_layout_path, + app_theme_path, app_plugins, dynamic_plugins, ); @@ -857,6 +878,7 @@ fn native( true, app_name, app_layout_path, + app_theme_path, app_plugins, dynamic_plugins, ); @@ -1535,6 +1557,11 @@ fn addTests( .optimize = optimize, .root_source_file = b.path("src/fizzy.zig"), }); + // The app's themes module, when the build was given one (`-Dapp-theme`): the editor's + // comptime `has_app_theme` branch imports it. + if (cfg.app_theme_path) |path| { + fizzy_test_module.addAnonymousImport("app_theme", .{ .root_source_file = path }); + } fizzy_test_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); fizzy_test_module.addImport("backend", dvui_testing_dep.module("testing")); // The testing backend has no viewports (`backend_web.viewports`). diff --git a/build/exe.zig b/build/exe.zig index 438b337a7..57e76099b 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -290,6 +290,9 @@ pub fn addFizzyExecutableForTarget( /// `Host.layout_ctx`. The file imports `dvui`, `app`, `core`, and `fizzy_sdk` — not /// `fizzy` / `Editor` — so the module graph does not cycle. app_layout: ?std.Build.LazyPath, + /// Consumer-owned themes file (`-Dapp-theme`). When set, Editor appends its themes beside + /// the user's. The file imports `dvui`. + app_theme: ?std.Build.LazyPath, /// Every plugin the application links, in registration order: fizzy's own /// (`fizzy.plugins`, built here) and the application's, each a static module from a plugin /// package (`plugin_dep.module("plugin")`). Listed in the generated `bundled_plugins` module @@ -458,6 +461,7 @@ pub fn addFizzyExecutableForTarget( sdk_module, exe.root_module, ); + sdk.wireAppThemes(b, resolved_target, optimize, app_theme, dvui_mod, exe.root_module); var dylibs: std.ArrayList(Dylib) = .empty; const workbench_dylib: ?*std.Build.Step.Compile = if (dynamic_plugins and diff --git a/build/sdk.zig b/build/sdk.zig index f5d25fbae..eebc22865 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -279,6 +279,28 @@ pub fn wireAppLayout( } } +/// Wire an app-owned themes module against this target's framework modules: the editor appends +/// its themes beside the user's when the build was given one (`-Dapp-theme`, see +/// `build/app.zig`). +pub fn wireAppThemes( + b: *std.Build, + target: std.Build.ResolvedTarget, + optimize: std.lang.OptimizeMode, + app_theme: ?std.Build.LazyPath, + dvui: *std.Build.Module, + consumer: *std.Build.Module, +) void { + if (app_theme) |path| { + const mod = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = path, + }); + mod.addImport("dvui", dvui); + consumer.addImport("app_theme", mod); + } +} + /// The whole of a `.zon` shape's generated module: import the file as a `Shape` — which is where /// it is type-checked, at comptime, against the field names and enums `Shape` declares — /// and hand it to the same walk a hand-written shape's calls would have performed itself. diff --git a/build/web.zig b/build/web.zig index 5d397e0dc..d18753184 100644 --- a/build/web.zig +++ b/build/web.zig @@ -129,6 +129,7 @@ pub fn build( /// The window's icon (`app.Branding.png`); `src/Entry.zig` imports it. app_icon_module: *std.Build.Module, app_layout: ?std.Build.LazyPath, + app_theme: ?std.Build.LazyPath, app_plugins: []const sdk.BundledPlugin, web_plugin_deps: []const []const u8, web_plugin_dirs: []const @import("app.zig").WebPluginDir, @@ -352,6 +353,7 @@ pub fn build( sdk_module_web, web_exe.root_module, ); + sdk.wireAppThemes(b, web_target, optimize, app_theme, dvui_web, web_exe.root_module); // Cache-buster: stamps a 64-char hash into the index.html / fizzy.js placeholders so // the browser picks up new wasm builds without manual hard-reloads. Re-implements diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d315cbcee..fe4c33d34 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -642,6 +642,15 @@ pub fn init( }; } + // The app's own themes, contributed at `-Dapp-theme` (see `build/app.zig`). Before + // fizzy's own, so an app's themes sit beside the user's at the top of the picker. + if (comptime build_opts.has_app_theme) { + @import("app_theme").add(app.allocator, &editor.themes) catch |err| { + dvui.log.err("Failed to append the app's themes: {t}", .{err}); + return error.FailedToAppendAppThemes; + }; + } + editor.themes.append(app.allocator, fizzy_dark) catch { dvui.log.err("Failed to append theme", .{}); return error.FailedToAppendTheme; From 14d5dbf6a373b4b4ad91368ed8aaad09e1a0ebdd Mon Sep 17 00:00:00 2001 From: Michael Belousov Date: Fri, 13 Nov 2026 17:23:52 -0500 Subject: [PATCH 2/2] themes: plugins can register themes at startup `Host.registerTheme(owner, theme)`, called while a plugin registers: the editor appends the theme beside the user's and the app's, replacing an entry of the same name in place (so a reloaded plugin updates its theme instead of duplicating it). The Host keeps its own copy of the name and tracks the plugin, the way demos do, so the themes go with the plugin when it retires. The editor pulls them in `postInit` (its own theme apply ran in `init`, before any plugin existed) and again after a web plugin loads, then re-applies the settings theme, so a registered theme lands shaped and sized like the built-ins. --- sdk/src/Host.zig | 75 +++++++++++++++++++++++++++++++++++++++++++ sdk/src/version.zig | 2 +- src/editor/Editor.zig | 31 ++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 8faacf0d9..699efe2c0 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -35,6 +35,14 @@ pub const OpenAction = @import("menus.zig").OpenAction; pub const accounts = @import("accounts.zig"); pub const Command = @import("Command.zig"); pub const Demo = @import("Demo.zig"); + +/// A theme a plugin registered (`Host.registerTheme`): the theme by value, its name the host's +/// own copy, and who registered it, so the themes go with the plugin when it retires. +pub const HostTheme = struct { + owner: ?*Plugin = null, + name: []u8, + theme: dvui.Theme, +}; pub const Claim = @import("Claim.zig"); pub const Step = @import("Step.zig"); pub const Author = @import("Author.zig"); @@ -265,6 +273,8 @@ open_actions: std.ArrayList(OpenAction) = .empty, demos: std.ArrayList(Demo) = .empty, /// Moves whenever `demos` changes, so the app lists them again only then. demos_generation: u32 = 0, +/// Plugins' themes (`registerTheme`): the theme by value, and its name the host's own copy. +themes: std.ArrayList(HostTheme) = .empty, /// Who the user is signed in as, per service (`accounts.Provider`). The host draws the disc. account_providers: std.ArrayList(accounts.Provider) = .empty, /// Pure-data menu leaf items the native (macOS NSMenu) menu builder consumes; see @@ -390,6 +400,8 @@ pub fn deinit(self: *Host) void { self.open_actions.deinit(self.allocator); for (self.demos.items) |d| self.freeDemo(d); self.demos.deinit(self.allocator); + for (self.themes.items) |t| self.allocator.free(t.name); + self.themes.deinit(self.allocator); self.account_providers.deinit(self.allocator); self.native_menu_items.deinit(self.allocator); self.commands.deinit(self.allocator); @@ -1078,6 +1090,7 @@ fn dropRegistrations(self: *Host, plugin: *Plugin) void { removeOwned(MenuSectionContribution, &self.menu_sections, plugin); removeOwned(OpenAction, &self.open_actions, plugin); removeOwned(RailItemContribution, &self.rail_items, plugin); + self.removeThemesOf(plugin); self.removeDemosOf(plugin); removeOwned(accounts.Provider, &self.account_providers, plugin); removeOwned(NativeMenuItem, &self.native_menu_items, plugin); @@ -1648,6 +1661,36 @@ fn freeDemo(self: *Host, d: Demo) void { self.allocator.free(d.tape); } +/// Register a theme the editor appends beside the user's and the app's — see +/// `Editor.themes`. Copied: only the theme's `name` is kept; its font families must outlive +/// the registration (literals, as `Font.find` takes). A theme of the same name replaces the +/// old entry in place, so a reloaded plugin updates its theme instead of duplicating it. +pub fn registerTheme(self: *Host, owner: ?*Plugin, theme: dvui.Theme) !void { + const name = try self.allocator.dupe(u8, theme.name); + errdefer self.allocator.free(name); + for (self.themes.items) |*t| { + if (std.mem.eql(u8, t.name, theme.name)) { + self.allocator.free(t.name); + t.* = .{ .owner = owner, .name = name, .theme = theme }; + return; + } + } + try self.themes.append(self.allocator, .{ .owner = owner, .name = name, .theme = theme }); +} + +fn removeThemesOf(self: *Host, plugin: *Plugin) void { + var w: usize = 0; + for (self.themes.items) |t| { + if (t.owner == plugin) { + self.allocator.free(t.name); + continue; + } + self.themes.items[w] = t; + w += 1; + } + self.themes.items.len = w; +} + fn removeDemosOf(self: *Host, plugin: *Plugin) void { var w: usize = 0; for (self.demos.items) |d| { @@ -3160,6 +3203,38 @@ test "pluginForExtension honors a user assignment and ignores a stale one" { Fake.override = null; } +test "registerTheme keeps its own copy, replaces by name, and a plugin's themes go with it" { + var host = Host.init(testing.allocator); + defer host.deinit(); + const vtable = Plugin.VTable{}; + var plugin = Plugin{ .state = undefined, .vtable = &vtable, .id = "guide", .display_name = "Guide" }; + try host.registerPlugin(&plugin); + + var name_buf = "Graphl Dark".*; + var theme = dvui.Theme.builtin.adwaita_dark; + theme.name = &name_buf; + try host.registerTheme(&plugin, theme); + // Nothing points into what the plugin handed over. + name_buf[0] = 'x'; + try testing.expectEqualStrings("Graphl Dark", host.themes.items[0].name); + try testing.expect(host.themes.items[0].theme.dark); + try testing.expectEqual(@as(usize, 1), host.themes.items.len); + + // Same name replaces in place rather than duplicating. + theme = dvui.Theme.builtin.adwaita_light; + theme.name = "Graphl Dark"; + try host.registerTheme(&plugin, theme); + try testing.expectEqual(@as(usize, 1), host.themes.items.len); + try testing.expect(!host.themes.items[0].theme.dark); + + theme.name = "Graphl Light"; + try host.registerTheme(&plugin, theme); + try testing.expectEqual(@as(usize, 2), host.themes.items.len); + + host.unregisterPlugin(&plugin); + try testing.expectEqual(@as(usize, 0), host.themes.items.len); +} + test "registerDemo keeps its own copy, refuses a name twice, and a plugin's demos go with it" { var host = Host.init(testing.allocator); defer host.deinit(); diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 3ec330ffc..1a7a09640 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0x2d68c9a0cdaef363; +pub const recorded_sdk_shape_fingerprint: u64 = 0x271ebd51f39b07ed; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index fe4c33d34..73d869609 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -1746,6 +1746,8 @@ const WebPluginRequest = struct { } rebuildKeybinds(editor); editor.rebuildExtensionOwnerCache(); + editor.pullPluginContributions() catch |err| + dvui.log.err("web plugin '{s}': themes: {s}", .{ req.id, @errorName(err) }); if (takeWebFileTypePrompt(gpa, req.id)) editor.maybeShowFileTypeDialog(req.id) catch |err| dvui.log.err("web plugin '{s}': file-type prompt: {s}", .{ req.id, @errorName(err) }); // Remembered here, not by the page when it linked the module: the page cannot know @@ -2374,6 +2376,8 @@ pub fn postInit(editor: *Editor) !void { // Settings so its icon sits directly above the cog in the sidebar rail. try PluginStore.register(editor.pluginManager()); + try editor.pullPluginContributions(); + // Fizzy built-in: Settings (owner = null; not a plugin). try editor.app.host.registerSurface(.{ .id = view_settings, @@ -3636,6 +3640,33 @@ fn shapedTheme(editor: *const Editor, base: dvui.Theme) dvui.Theme { return theme; } +/// What plugins registered in `initPlugin` that the editor holds itself, pulled once they have +/// initialized (at startup, and when a web plugin loads), since the editor's own apply ran in +/// `init`, before any plugin existed: their themes. Re-applies the settings theme when any came. +fn pullPluginContributions(editor: *Editor) !void { + if ((try editor.importHostThemes()) > 0) try editor.applySettingsTheme(); +} + +/// Pull the themes plugins registered (`Host.registerTheme`) into `editor.themes`: an entry +/// of the same name is replaced in place, new ones appended. Returns how many it pulled, so the +/// caller knows whether to re-apply the settings theme. +pub fn importHostThemes(editor: *Editor) !usize { + var changed: usize = 0; + for (editor.app.host.themes.items) |ht| { + var replaced = false; + for (editor.themes.items) |*t| { + if (std.mem.eql(u8, t.name, ht.name)) { + t.* = ht.theme; + replaced = true; + break; + } + } + if (!replaced) try editor.themes.append(editor.app.gpa, ht.theme); + changed += 1; + } + return changed; +} + pub fn applySettingsTheme(editor: *Editor) !void { const t = resolveSettingsTheme(editor); if (!std.mem.eql(u8, editor.app.settings.theme, t.name)) {