Skip to main content

corundum_framework_gui/settings/
appearance.rs

1//! [`AppearancePage`]: the theme and the language, a tab per scope they can be kept in.
2
3use crate::i18n::{self, LanguageSettings};
4use crate::theme::{self, Swatch, ThemeSettings};
5use crate::tr;
6
7use super::scope::{ScopeTab, scope_tabs};
8
9/// One scope the page can show: its tab, and the theme and the language it shows there —
10/// the scope's own values over the defaults, or (on the scope that wins, a device over its
11/// workgroup) the values in effect.
12#[derive(Clone, PartialEq, Debug)]
13pub struct AppearanceScope<S> {
14    /// The tab.
15    pub tab: ScopeTab<S>,
16    /// The theme the tab shows.
17    pub theme: ThemeSettings,
18    /// The language the tab shows.
19    pub language: LanguageSettings,
20}
21
22/// What [`AppearancePage::ui`] reads each frame.
23#[derive(Clone, Debug)]
24pub struct AppearanceCtx<'a, S> {
25    /// The scopes, in tab order. With one, there is no row of tabs; with none, the
26    /// widgets are shown disabled.
27    pub scopes: &'a [AppearanceScope<S>],
28    /// Why nothing can be changed now, shown above the widgets, which are disabled.
29    pub unavailable: Option<String>,
30    /// Why the language cannot be changed now; the theme still can.
31    pub language_unavailable: Option<String>,
32    /// A change the page gave is still being stored: the widgets wait.
33    pub busy: bool,
34}
35
36impl<'a, S> AppearanceCtx<'a, S> {
37    /// Everything available, nothing being stored.
38    pub fn new(scopes: &'a [AppearanceScope<S>]) -> AppearanceCtx<'a, S> {
39        AppearanceCtx {
40            scopes,
41            unavailable: None,
42            language_unavailable: None,
43            busy: false,
44        }
45    }
46}
47
48/// What the person changed this frame, for the host to store in `scope`.
49#[derive(Clone, PartialEq, Debug)]
50pub struct AppearanceChange<S> {
51    /// The scope whose tab was open.
52    pub scope: S,
53    /// The new theme, when it changed.
54    pub theme: Option<ThemeSettings>,
55    /// The new language, when it changed.
56    pub language: Option<LanguageSettings>,
57}
58
59/// The Appearance page: a tab per scope, and the theme's settings and the language picker
60/// over the open tab's values. A change is given as it is made — a click, a choice, or the
61/// end of a drag — for the host to store; the page saves nothing.
62///
63/// The widgets install what they show, so a drag previews live. Once the host has stored a
64/// change, it installs the look in effect, which on a scope that does not win (a
65/// workgroup's, under the device's own) is not the one just chosen. Until the host calls
66/// [`reloaded`](Self::reloaded), the page keeps showing the change rather than the values
67/// the host gives, which are still the ones from before it; a host that stores at once
68/// calls it right after.
69#[derive(Clone, Debug)]
70pub struct AppearancePage<S> {
71    /// The open tab; `None` until there are scopes to choose from.
72    tab: Option<S>,
73    /// The tab whose values the widgets last showed.
74    shown: Option<S>,
75    /// What the theme's widgets edit.
76    working: ThemeSettings,
77    /// What the language picker edits.
78    language: LanguageSettings,
79    /// A drag is under way: keep `working` rather than the tab's values.
80    editing: bool,
81    /// A change was given and the host has not reloaded since.
82    held: bool,
83    /// Why the last change could not be stored.
84    error: Option<String>,
85}
86
87impl<S> Default for AppearancePage<S> {
88    fn default() -> AppearancePage<S> {
89        AppearancePage {
90            tab: None,
91            shown: None,
92            working: ThemeSettings::default(),
93            language: LanguageSettings::default(),
94            editing: false,
95            held: false,
96            error: None,
97        }
98    }
99}
100
101impl<S: Copy + PartialEq> AppearancePage<S> {
102    /// A page that opens on the first enabled tab.
103    pub fn new() -> AppearancePage<S> {
104        AppearancePage::default()
105    }
106
107    /// A page that opens on `scope`'s tab.
108    pub fn with_tab(mut self, scope: S) -> AppearancePage<S> {
109        self.tab = Some(scope);
110        self
111    }
112
113    /// The open tab.
114    pub fn tab(&self) -> Option<S> {
115        self.tab
116    }
117
118    /// Render; the change the person made, if any. `notes` draws under the tabs, for the
119    /// open scope: where its values come from, a way to drop its own.
120    pub fn ui(
121        &mut self,
122        ui: &mut egui::Ui,
123        cx: &AppearanceCtx<S>,
124        notes: impl FnOnce(&mut egui::Ui, S),
125    ) -> Option<AppearanceChange<S>> {
126        ui.horizontal(|ui| {
127            ui.heading(tr!(ui, "corundum-settings-appearance"));
128            if cx.busy {
129                ui.spinner();
130            }
131        });
132        ui.separator();
133
134        if cx.scopes.len() > 1 {
135            let tabs: Vec<ScopeTab<S>> = cx.scopes.iter().map(|s| s.tab.clone()).collect();
136            let mut open = self.tab.unwrap_or(tabs[0].scope);
137            scope_tabs(ui, &mut open, &tabs, None);
138            self.tab = Some(open);
139        } else if let Some(only) = cx.scopes.first() {
140            self.tab = Some(only.tab.scope);
141        }
142        let scope = self
143            .tab
144            .and_then(|tab| cx.scopes.iter().find(|s| s.tab.scope == tab))
145            .filter(|s| s.tab.enabled);
146        let (Some(scope), None) = (scope, cx.unavailable.as_ref()) else {
147            if let Some(why) = &cx.unavailable {
148                ui.weak(why);
149            }
150            // Shown as it is, not editable.
151            let mut theme = self.working.clone();
152            let mut language = self.language.clone();
153            ui.add_enabled_ui(false, |ui| {
154                theme::settings_ui(ui, &mut theme);
155                language_row(ui, &mut language);
156            });
157            return None;
158        };
159
160        if self.shown != Some(scope.tab.scope) {
161            // Another tab: what was edited or held was the old one's.
162            self.shown = Some(scope.tab.scope);
163            self.editing = false;
164            self.held = false;
165        }
166        notes(ui, scope.tab.scope);
167        error_line(ui, &mut self.error);
168
169        let mut change = AppearanceChange {
170            scope: scope.tab.scope,
171            theme: None,
172            language: None,
173        };
174        if !self.editing && !self.held {
175            self.working = scope.theme.clone();
176        }
177        let before = self.working.clone();
178        let done = ui
179            .add_enabled_ui(!cx.busy, |ui| theme::settings_ui(ui, &mut self.working))
180            .inner;
181        if done {
182            self.editing = false;
183            if self.working != scope.theme {
184                self.held = true;
185                change.theme = Some(self.working.clone());
186            }
187        } else if self.working != before {
188            self.editing = true;
189        } else if !ui.input(|i| i.pointer.any_down()) {
190            self.editing = false;
191        }
192
193        ui.add_space(8.0);
194        if !self.held {
195            self.language = scope.language.clone();
196        }
197        if let Some(why) = &cx.language_unavailable {
198            ui.weak(why);
199        }
200        let chosen = ui
201            .add_enabled_ui(!cx.busy && cx.language_unavailable.is_none(), |ui| {
202                language_row(ui, &mut self.language)
203            })
204            .inner;
205        if chosen && self.language != scope.language {
206            self.held = true;
207            change.language = Some(self.language.clone());
208        }
209
210        (change.theme.is_some() || change.language.is_some()).then_some(change)
211    }
212
213    /// The host has its values again, a change stored or not: show them.
214    pub fn reloaded(&mut self) {
215        self.held = false;
216    }
217
218    /// The last change could not be stored, for `why`: say so, and show the host's values
219    /// again.
220    pub fn failed(&mut self, why: impl Into<String>) {
221        self.editing = false;
222        self.held = false;
223        self.error = Some(why.into());
224    }
225}
226
227/// The language picker's row, aligned as the theme's rows; `true` on a choice.
228fn language_row(ui: &mut egui::Ui, language: &mut LanguageSettings) -> bool {
229    egui::Grid::new("corundum_language_row")
230        .num_columns(2)
231        .spacing([16.0, 8.0])
232        .show(ui, |ui| {
233            ui.label(tr!(ui, "corundum-language"));
234            let chosen = i18n::settings_ui(ui, language);
235            ui.end_row();
236            chosen
237        })
238        .inner
239}
240
241/// The red line saying why a change failed, with a way to dismiss it.
242fn error_line(ui: &mut egui::Ui, error: &mut Option<String>) {
243    let Some(text) = error.clone() else {
244        return;
245    };
246    // Wrapped: a phone is narrower than most messages.
247    ui.horizontal_wrapped(|ui| {
248        if ui.small_button("×").clicked() {
249            *error = None;
250        }
251        let colour = theme::current(ui.ctx()).text(Swatch::DANGER);
252        ui.add(egui::Label::new(egui::RichText::new(text).color(colour)).wrap());
253    });
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::i18n::Language;
260    use crate::test_support::{render, render_in};
261    use crate::theme::Contrast;
262
263    #[derive(Clone, Copy, PartialEq, Eq, Debug)]
264    enum Scope {
265        Client,
266        Server,
267    }
268
269    fn scope(scope: Scope, contrast: Contrast) -> AppearanceScope<Scope> {
270        AppearanceScope {
271            tab: ScopeTab::new(scope, format!("{scope:?}")),
272            theme: ThemeSettings {
273                contrast,
274                ..Default::default()
275            },
276            language: LanguageSettings::default(),
277        }
278    }
279
280    fn scopes() -> Vec<AppearanceScope<Scope>> {
281        vec![
282            scope(Scope::Client, Contrast::High),
283            scope(Scope::Server, Contrast::Low),
284        ]
285    }
286
287    #[test]
288    fn it_opens_on_the_first_tab_and_shows_its_values() {
289        let scopes = scopes();
290        let mut page = AppearancePage::new();
291        let mut noted = None;
292        render([600.0, 600.0], |ui| {
293            let change = page.ui(ui, &AppearanceCtx::new(&scopes), |_, s| noted = Some(s));
294            assert_eq!(change, None);
295        });
296        assert_eq!(page.tab(), Some(Scope::Client));
297        assert_eq!(noted, Some(Scope::Client));
298        assert_eq!(page.working.contrast, Contrast::High);
299    }
300
301    #[test]
302    fn with_tab_opens_on_that_tab() {
303        let scopes = scopes();
304        let mut page = AppearancePage::new().with_tab(Scope::Server);
305        render([600.0, 600.0], |ui| {
306            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
307        });
308        assert_eq!(page.working.contrast, Contrast::Low);
309    }
310
311    #[test]
312    fn a_disabled_tab_yields() {
313        let mut scopes = scopes();
314        scopes[1].tab.enabled = false;
315        let mut page = AppearancePage::new().with_tab(Scope::Server);
316        render([600.0, 600.0], |ui| {
317            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
318        });
319        assert_eq!(page.tab(), Some(Scope::Client));
320    }
321
322    #[test]
323    fn unavailable_shows_why_and_gives_nothing() {
324        let scopes = scopes();
325        let mut page = AppearancePage::new();
326        let cx = AppearanceCtx {
327            unavailable: Some("the server is down".into()),
328            ..AppearanceCtx::new(&scopes)
329        };
330        let mut noted = false;
331        render([600.0, 600.0], |ui| {
332            assert_eq!(page.ui(ui, &cx, |_, _| noted = true), None);
333        });
334        assert!(!noted, "no notes without values");
335        let none: Vec<AppearanceScope<Scope>> = Vec::new();
336        render([600.0, 600.0], |ui| {
337            assert_eq!(page.ui(ui, &AppearanceCtx::new(&none), |_, _| {}), None);
338        });
339    }
340
341    #[test]
342    fn a_held_change_stays_until_reloaded() {
343        let scopes = scopes();
344        let mut page = AppearancePage::new();
345        render([600.0, 600.0], |ui| {
346            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
347        });
348        // A change given, the host not yet reloaded: its values are the old ones.
349        page.working.contrast = Contrast::Medium;
350        page.held = true;
351        render([600.0, 600.0], |ui| {
352            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
353        });
354        assert_eq!(page.working.contrast, Contrast::Medium);
355        page.reloaded();
356        render([600.0, 600.0], |ui| {
357            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
358        });
359        assert_eq!(page.working.contrast, Contrast::High);
360    }
361
362    #[test]
363    fn a_failure_shows_the_hosts_values_again() {
364        let scopes = scopes();
365        let mut page = AppearancePage::new();
366        page.working.contrast = Contrast::Medium;
367        page.held = true;
368        page.failed("rejected");
369        render([600.0, 600.0], |ui| {
370            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
371        });
372        assert_eq!(page.working.contrast, Contrast::High);
373        assert_eq!(page.error.as_deref(), Some("rejected"));
374    }
375
376    #[test]
377    fn switching_tabs_drops_a_held_change() {
378        let scopes = scopes();
379        let mut page = AppearancePage::new();
380        render([600.0, 600.0], |ui| {
381            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
382        });
383        page.held = true;
384        page.tab = Some(Scope::Server);
385        render([600.0, 600.0], |ui| {
386            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
387        });
388        assert_eq!(page.working.contrast, Contrast::Low);
389    }
390
391    #[test]
392    fn the_page_follows_the_language() {
393        let ctx = egui::Context::default();
394        crate::i18n::install(
395            &ctx,
396            &LanguageSettings {
397                language: Language::Explicit("ja".into()),
398            },
399        );
400        let scopes = scopes();
401        let mut page = AppearancePage::new();
402        render_in(&ctx, [600.0, 600.0], |ui| {
403            page.ui(ui, &AppearanceCtx::new(&scopes), |_, _| {});
404        });
405        assert_eq!(
406            crate::i18n::tr(&ctx, "corundum-settings-appearance"),
407            "外観"
408        );
409    }
410}