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/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 d315cbcee..73d869609 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; @@ -1737,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 @@ -2365,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, @@ -3627,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)) {