Skip to main content

blockworx/
appearance.rs

1//! How the editor looks and what it is called: the user's theme, mode and
2//! typeface, the two dev editors that author the theme files, and the window
3//! title. Everything here is pushed *to* the toolkit — none of it is document
4//! state, and none of it survives except through the eframe storage DB.
5
6use blockworx_paint::{FontChoice, Scheme};
7
8use crate::preferences::Preferences;
9use crate::theme::Theme;
10
11/// Which of the dev editors a launch asked for — the windows that author
12/// `theme.json` and `font_sizes.json` in the source tree.
13#[derive(Clone, Copy, Default)]
14pub(crate) struct Editors {
15    pub theme: bool,
16    pub font: bool,
17}
18
19/// The eframe storage key the appearance blob lives under.
20const PREFERENCES: &str = "preferences";
21
22pub(crate) struct Appearance {
23    /// User appearance preferences (theme, mode, font), persisted in the
24    /// eframe storage DB and applied live via [`Self::apply`].
25    pub(crate) preferences: Preferences,
26    /// The (theme, resolved-dark, font) last pushed to the egui context, so
27    /// appearance is only re-applied (a relayout) when the effective look
28    /// changes.
29    applied: Option<(Scheme, bool, FontChoice)>,
30    /// The title last pushed to the viewport, so it is only re-sent when it
31    /// changes.
32    #[cfg(not(target_arch = "wasm32"))]
33    applied_title: String,
34    /// Whether the `--theme-editor` window is shown. Cleared when that window
35    /// is closed; gates whether `theme.json` is written on exit.
36    theme_editor: bool,
37    /// Whether the `--font-editor` window is shown. Cleared when that window
38    /// is closed; gates whether `font_sizes.json` is written on exit.
39    font_editor: bool,
40    /// How the editor looks: the roles, the palette the preferences pick
41    /// and the fonts. The shell's; the session is told its palette by
42    /// command, and the dev editors' tables directly.
43    pub(crate) theme: Theme,
44}
45
46impl Default for Appearance {
47    fn default() -> Self {
48        Self {
49            preferences: Preferences::default(),
50            applied: None,
51            #[cfg(not(target_arch = "wasm32"))]
52            applied_title: String::new(),
53            theme_editor: false,
54            font_editor: false,
55            theme: Theme::from_embedded(),
56        }
57    }
58}
59
60/// Whether a pass over the appearance changed the theme.
61#[derive(Clone, Copy, PartialEq, Eq, Debug)]
62pub(crate) enum Applied {
63    Changed,
64    Same,
65}
66
67impl Appearance {
68    pub(crate) fn new(editors: Editors) -> Self {
69        Self {
70            theme_editor: editors.theme,
71            font_editor: editors.font,
72            ..Self::default()
73        }
74    }
75
76    /// Push the appearance preferences — palette, egui visuals and fonts — to
77    /// `ctx`, but only when they actually change, since `set_fonts`/`set_visuals`
78    /// trigger a relayout. `System` mode resolves via the OS light/dark
79    /// preference, so a runtime OS theme flip re-resolves the theme variant here
80    /// too.
81    pub(crate) fn apply(&mut self, ctx: &egui::Context) -> Applied {
82        // Cmd+Plus/Minus/0 belong to the canvas (zoom the diagram, fit the
83        // document). egui consumes those chords at the end of every frame to
84        // scale the whole UI instead, and nothing in the editor scales the UI
85        // any more.
86        ctx.options_mut(|o| o.zoom_with_keyboard = false);
87        // One motion curve runs across the whole shell. egui has one knob for
88        // it, so this is the whole of the vocabulary — press states, hovers,
89        // tooltips and the navigator all move at the same rate.
90        ctx.all_styles_mut(|style| {
91            style.animation_time = crate::shell::glass::MOTION.as_secs_f32();
92        });
93        let system_dark = ctx.system_theme().map(|t| t == egui::Theme::Dark);
94        let dark = self.preferences.mode.is_dark(system_dark);
95        let scheme = self.preferences.theme;
96        let font = self.preferences.font;
97        if self.applied != Some((scheme, dark, font)) {
98            self.theme
99                .set_palette(self.preferences.palette(system_dark));
100            ctx.set_visuals(crate::canvas::convert::visuals(self.theme.palette()));
101            ctx.set_fonts(crate::canvas::build_fonts(font));
102            self.applied = Some((scheme, dark, font));
103            return Applied::Changed;
104        }
105        Applied::Same
106    }
107
108    /// The engine the screen lays out with, at the display's resolution: what
109    /// a frame is recorded through, so its marks land where the replay draws
110    /// them — and, since one call paints and executes, what an export
111    /// taken through the shell is laid out with (D15).
112    pub(crate) fn screen_layout(&self, ctx: &egui::Context) -> crate::canvas::ContextLayout {
113        crate::canvas::ContextLayout::new(ctx, self.preferences.font)
114    }
115
116    /// Restore the persisted appearance from the eframe storage DB. A
117    /// malformed blob is ignored, leaving the defaults in place.
118    pub(crate) fn restore(&mut self, storage: &dyn eframe::Storage) {
119        if let Some(s) = storage.get_string(PREFERENCES)
120            && let Ok(prefs) = serde_json::from_str(&s)
121        {
122            self.preferences = prefs;
123        }
124    }
125
126    pub(crate) fn save(&self, storage: &mut dyn eframe::Storage) {
127        match serde_json::to_string(&self.preferences) {
128            Ok(s) => storage.set_string(PREFERENCES, s),
129            Err(e) => tracing::error!("Failed to serialize preferences: {e}"),
130        }
131    }
132
133    /// Re-title the window when what it says has changed under it: eframe
134    /// sets the title once at startup.
135    #[cfg(not(target_arch = "wasm32"))]
136    pub(crate) fn apply_window_title(&mut self, ctx: &egui::Context, title: String) {
137        if title != self.applied_title {
138            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
139            self.applied_title = title;
140        }
141    }
142
143    /// `main` opens the window with the same title this seeds from, so the
144    /// first frame has nothing to re-send.
145    #[cfg(not(target_arch = "wasm32"))]
146    pub(crate) fn opens_titled(&mut self, title: String) {
147        self.applied_title = title;
148    }
149
150    /// The dev-only editor windows (`--theme-editor`, `--font-editor`). Both
151    /// edit the theme live, so a frame with one open tells the session the
152    /// role table and font sizes again; closing either writes its file.
153    pub(crate) fn show_editor_windows(&mut self, ctx: &egui::Context) -> Applied {
154        if !self.theme_editor && !self.font_editor {
155            return Applied::Same;
156        }
157        if self.theme_editor && crate::theme_editor::show(ctx, &mut self.theme) {
158            self.theme_editor = false;
159            save_theme(&self.theme);
160        }
161        if self.font_editor && crate::font_editor::show(ctx, &mut self.theme) {
162            self.font_editor = false;
163            save_font_sizes(&self.theme);
164        }
165        Applied::Changed
166    }
167
168    /// Persist theme tweaks if an editor was open when the app quit (closing
169    /// just the editor window already saved through the frame).
170    #[cfg(not(target_arch = "wasm32"))]
171    pub(crate) fn on_exit(&self) {
172        if self.theme_editor {
173            save_theme(&self.theme);
174        }
175        if self.font_editor {
176            save_font_sizes(&self.theme);
177        }
178    }
179}
180
181/// Write the current role → base mapping to the source-tree `theme.json` so
182/// the next build embeds it via `include_str!` (see [`Theme::from_embedded`]).
183/// Targeting `CARGO_MANIFEST_DIR` makes this independent of the working
184/// directory. Only called when the theme editor is active, so ordinary runs
185/// never touch the file.
186fn save_theme(theme: &Theme) {
187    // Dev-only editor feature that writes into the source tree; there is no
188    // source tree (or filesystem) on the web, so it is a no-op there.
189    #[cfg(not(target_arch = "wasm32"))]
190    write_source_file(
191        concat!(env!("CARGO_MANIFEST_DIR"), "/src/theme/theme.json"),
192        serde_json::to_string_pretty(&theme.overrides()),
193    );
194    #[cfg(target_arch = "wasm32")]
195    let _ = theme;
196}
197
198/// Write the current canvas font sizes to the source-tree `font_sizes.json`
199/// so the next build embeds them via `include_str!` (see
200/// [`Theme::from_embedded`]). Like [`save_theme`], this targets
201/// `CARGO_MANIFEST_DIR` and is only called when the font editor is active, so
202/// ordinary runs never touch the file.
203fn save_font_sizes(theme: &Theme) {
204    // See `save_theme`: dev-only source-tree write, a no-op on the web.
205    #[cfg(not(target_arch = "wasm32"))]
206    write_source_file(
207        concat!(env!("CARGO_MANIFEST_DIR"), "/src/theme/font_sizes.json"),
208        serde_json::to_string_pretty(&theme.font_sizes()),
209    );
210    #[cfg(target_arch = "wasm32")]
211    let _ = theme;
212}
213
214#[cfg(not(target_arch = "wasm32"))]
215fn write_source_file(path: &str, serialized: serde_json::Result<String>) {
216    match serialized {
217        Ok(s) => {
218            if let Err(e) =
219                blockworx_store::atomic::write_atomically(std::path::Path::new(path), s.as_bytes())
220            {
221                tracing::error!("Failed to write {path}: {e}");
222            }
223        }
224        Err(e) => tracing::error!("Failed to serialize {path}: {e}"),
225    }
226}