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}