Skip to main content

qframe/widgets/
appearance.rs

1//! The appearance rows every application of an ecosystem shows the same way: language, theme,
2//! icons and reduced motion, each with the choice of changing it everywhere or here only, then the
3//! pillar; and, for an application that asks for its updates, the ecosystem's update notice.
4
5use std::io;
6use std::path::PathBuf;
7
8use crate::icons::{IconMode, PillarStyle};
9use crate::runtime::Command;
10use crate::storage::{Ecosystem, Preferences, Scope, Setting, Settings, Shared, Source};
11use crate::widget::Length;
12
13use super::{Checkbox, Segmented, Select, SettingRow, SettingsRows, Switch};
14
15/// Narrowest a choice is drawn at, so the three rows keep one column even when every name in
16/// them is short.
17const CHOICE_MIN: u16 = 18;
18
19/// Widest a choice is drawn at, a little over half of the narrow width the catalogue promises: a
20/// name longer than this is cut rather than left to take the row from its label. No built-in
21/// language, theme or icon name is near it.
22const CHOICE_MAX: u16 = 28;
23
24/// Cells a choice needs to show the longest of `names` whole: the name itself, the three the
25/// chevron and the space before it take, and the ground a [`Select`] leaves at each side, which
26/// the theme decides and which is why it is asked for rather than assumed.
27fn choice_width(names: &[String], padding: u16) -> u16 {
28    let longest = names.iter().map(|name| crate::text::width(name)).max().unwrap_or(0);
29    crate::widgets::cells::sum([longest, 3, padding.saturating_mul(2)]).clamp(CHOICE_MIN, CHOICE_MAX)
30}
31
32/// A change made on the [`Appearance`] rows. The application hands it back to
33/// [`Appearance::update`], which saves it and returns the command that shows it.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum AppearanceChange {
36    /// A language was chosen, by locale code.
37    Language(String),
38    /// A theme was chosen, by id.
39    Theme(String),
40    /// An icon mode was chosen.
41    Icons(IconMode),
42    /// The "in every application of the ecosystem" box under a shared row was checked (`true`) or
43    /// cleared (`false`).
44    Everywhere(Shared, bool),
45    /// Reduced motion was switched.
46    ReducedMotion(bool),
47    /// A pillar style was chosen.
48    Pillar(PillarStyle),
49    /// The ecosystem's update notice was switched on (`true`) or off; see
50    /// [`Ecosystem::update_notice`].
51    UpdateNotice(bool),
52}
53
54/// Which row a failed save is shown under.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56enum Row {
57    Shared(Shared),
58    Pillar,
59    UpdateNotice,
60}
61
62/// The appearance section of a settings page or a setup wizard: language, theme, icons and reduced
63/// motion as the ecosystem shares them, and the pillar, as rows of a
64/// [`SettingsList`](super::SettingsList); and, where the application asks for its updates, the
65/// ecosystem's update notice with [`updates`](Self::updates).
66///
67/// Each shared row has a box under it, "In every Quvyta application", checked while the
68/// application follows the ecosystem: a change then goes to the ecosystem's shared file and every
69/// application that follows it changes too. Cleared, the change stays in the application's own
70/// file. The pillar is the application's own. The update notice is one switch
71/// for the whole ecosystem, kept in the shared file; see [`Ecosystem::update_notice`]. A change is applied at once
72/// and saved at once, each file read again right before it is written; see
73/// [`Ecosystem::set`]. When the `QUVYTA_REDUCED_MOTION` environment variable decides, the reduced
74/// motion row and its box are disabled and the row says why. Texts come from the framework's language files.
75///
76/// ```
77/// use qframe::i18n::I18n;
78/// use qframe::prelude::*;
79/// use qframe::storage::{Ecosystem, Settings};
80/// use qframe::widgets::{Appearance, AppearanceChange, SettingsList};
81///
82/// struct Code {
83///     settings: Settings,
84///     appearance: Appearance,
85/// }
86///
87/// #[derive(Debug, Clone)]
88/// enum Msg {
89///     Appearance(AppearanceChange),
90/// }
91///
92/// impl App for Code {
93///     type Msg = Msg;
94///     fn update(&mut self, msg: Msg) -> Command<Msg> {
95///         match msg {
96///             Msg::Appearance(change) => self.appearance.update(change, &mut self.settings),
97///         }
98///     }
99///     fn view(&self, ui: &mut View<'_, Msg>) {
100///         SettingsList::show(ui, |list| self.appearance.section(list, Msg::Appearance));
101///     }
102/// }
103///
104/// # let folder = std::env::temp_dir().join(format!("quvyta-appearance-doc-{}", std::process::id()));
105/// let ecosystem = Ecosystem::QUVYTA;
106/// // An application passes `ecosystem.preferences("code", &i18n)`; the example stays in a folder of its own.
107/// let preferences = ecosystem.preferences_in(&folder, "code", &I18n::builtin());
108/// let appearance = Appearance::new(ecosystem, "code", preferences).in_folder(&folder);
109/// let settings = Settings::open(folder.join("code.conf")).member_of(&ecosystem);
110/// let mut app = Harness::new(Code { settings, appearance }, 60, 20);
111/// assert!(app.screen().contains("In every Quvyta application"));
112/// # std::fs::remove_dir_all(&folder).ok();
113/// ```
114#[derive(Debug, Clone)]
115pub struct Appearance {
116    ecosystem: Ecosystem,
117    app: String,
118    folder: Option<PathBuf>,
119    preferences: Preferences,
120    /// Whether a change is written to the files; a setup wizard holds them back.
121    saving: bool,
122    failure: Option<(Row, String)>,
123}
124
125impl Appearance {
126    /// The appearance of application `app` of `ecosystem`, starting from the `preferences`
127    /// [`Ecosystem::preferences`] resolved for it. Changes are saved in the ecosystem's folder.
128    #[must_use]
129    pub fn new(ecosystem: Ecosystem, app: impl Into<String>, preferences: Preferences) -> Self {
130        Self { ecosystem, app: app.into(), folder: None, preferences, saving: true, failure: None }
131    }
132
133    /// Saves changes in `folder` as the ecosystem's folder instead of this platform's, for a test
134    /// or a demo that must leave the user's own files alone; see [`Ecosystem::set_in`].
135    #[must_use]
136    pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
137        self.folder = Some(folder.into());
138        self
139    }
140
141    /// Applies every change without writing a file: the [shared preferences](Self::preferences)
142    /// and the `settings` given to [`update`](Self::update) take it, the screen shows it, and the
143    /// files are left to whoever writes them later.
144    ///
145    /// For the first step of a [setup wizard](super::Setup), which writes both files only when the
146    /// wizard finishes, so a wizard closed half-way leaves nothing behind.
147    #[must_use]
148    pub fn without_saving(mut self) -> Self {
149        self.saving = false;
150        self
151    }
152
153    /// The shared preferences as they stand after the changes made so far.
154    #[must_use]
155    pub fn preferences(&self) -> &Preferences {
156        &self.preferences
157    }
158
159    /// Takes `preferences` resolved again after the files changed while the section is open, such
160    /// as the ones [`App::preferences`](crate::runtime::App::preferences) hears when another
161    /// application switches the theme for the whole ecosystem. The rows then show the new values
162    /// and the box under each shared row whether the application follows the ecosystem now, and
163    /// the next change is saved where that box says.
164    ///
165    /// Nothing is written and nothing is applied: the runtime has already switched the screen.
166    /// What the person is doing on the section stays as it is: an open list stays open, and a
167    /// reason a change could not be saved stays under its row until the next change.
168    pub fn refresh(&mut self, preferences: Preferences) {
169        self.preferences = preferences;
170    }
171
172    /// Adds an "Appearance" heading, the three [shared rows](Self::rows), reduced motion with its
173    /// box and the application's own pillar to `list`.
174    pub fn section<Msg: Clone + 'static>(
175        &self,
176        list: &mut SettingsRows<'_, Msg>,
177        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
178    ) {
179        list.heading(crate::t!("quvyta.appearance.heading"));
180        self.rows(list, message.clone());
181        self.motion_and_pillar(list, message);
182    }
183
184    /// Adds the ecosystem's update notice switch to `list`, with the text saying what it asks and
185    /// what it never sends: for an application that asks whether a newer version of itself is out
186    /// ([`Command::check_for_update`](crate::runtime::Command::check_for_update)), right after
187    /// [`section`](Self::section). The switch is the ecosystem's, one for every application, kept in
188    /// the shared file; see [`Ecosystem::update_notice`]. An application that never asks leaves the
189    /// row out, so its settings offer nothing that does nothing there.
190    pub fn updates<Msg: Clone + 'static>(
191        &self,
192        list: &mut SettingsRows<'_, Msg>,
193        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
194    ) {
195        let row = SettingRow::new(crate::t!("quvyta.appearance.updates"));
196        let row = match self.failed(Row::UpdateNotice) {
197            Some(failure) => row.description(failure),
198            None => row.description(crate::t!("quvyta.appearance.updates-text", family = self.ecosystem.title())),
199        };
200        let on = self.preferences.update_notice();
201        list.row(row, |ui| {
202            ui.add(Switch::new(on).on_toggle(move |on| message(AppearanceChange::UpdateNotice(on))));
203        });
204    }
205
206    /// Adds the three rows the ecosystem shares, language, theme and icons, each with its box, to
207    /// `list`, without a heading and without the application's own rows: what the first step of a
208    /// [setup wizard](super::Setup) asks, on a page that names the section itself. Every change is
209    /// sent as `message`.
210    pub fn rows<Msg: Clone + 'static>(
211        &self,
212        list: &mut SettingsRows<'_, Msg>,
213        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
214    ) {
215        let env = list.env();
216        let languages = env.i18n().list();
217        let active = env.i18n().active().to_owned();
218        let themes = env.themes();
219        let theme = env.theme().id().to_owned();
220        let icons = env.icon_mode();
221        let icon_names = IconMode::ALL.map(|mode| crate::t!(&format!("quvyta.appearance.icons-{}", mode.name())));
222
223        // One width for the three rows, from the longest name any of them offers: a language list
224        // whose longest name is `Português (Brasil)` needs more than the built-in themes do, and a
225        // column that changed width from row to row would read as three controls, not one group.
226        // The ground a select leaves at its sides is the theme's, so the width is asked of the
227        // theme rather than assumed; without it the name is cut by exactly that much.
228        let padding = env.theme().style("select", None, &[]).pair("padding").map_or(1, |(_, horizontal)| horizontal);
229        let width = choice_width(
230            &languages
231                .iter()
232                .map(|(_, name)| name.clone())
233                .chain(themes.iter().map(|(_, name)| name.clone()))
234                .chain(icon_names.iter().cloned())
235                .collect::<Vec<String>>(),
236            padding,
237        );
238
239        let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
240        let chosen = codes.iter().position(|code| *code == active);
241        let send = message.clone();
242        list.row(self.row(Row::Shared(Shared::Language), crate::t!("quvyta.appearance.language")), |ui| {
243            let names = languages.into_iter().map(|(_, name)| name);
244            let select = Select::new(names)
245                .selected(chosen)
246                .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
247            ui.add(select).width(Length::Cells(width));
248        });
249        self.everywhere(list, Shared::Language, false, &message);
250
251        let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
252        let chosen = ids.iter().position(|id| *id == theme);
253        let send = message.clone();
254        list.row(self.row(Row::Shared(Shared::Theme), crate::t!("quvyta.appearance.theme")), |ui| {
255            let names = themes.into_iter().map(|(_, name)| name);
256            let select = Select::new(names)
257                .selected(chosen)
258                .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
259            ui.add(select).width(Length::Cells(width));
260        });
261        self.everywhere(list, Shared::Theme, false, &message);
262
263        let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
264        let send = message.clone();
265        list.row(self.row(Row::Shared(Shared::Icons), crate::t!("quvyta.appearance.icons")), |ui| {
266            let select = Select::new(icon_names)
267                .selected(chosen)
268                .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
269            ui.add(select).width(Length::Cells(width));
270        });
271        self.everywhere(list, Shared::Icons, false, &message);
272    }
273
274    /// Adds reduced motion with its box, and the pillar, which is the application's own, to `list`.
275    fn motion_and_pillar<Msg: Clone + 'static>(
276        &self,
277        list: &mut SettingsRows<'_, Msg>,
278        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
279    ) {
280        let env = list.env();
281        let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
282        let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
283
284        let note = match (forced, reduced) {
285            (true, true) => crate::t!("quvyta.appearance.forced-on"),
286            (true, false) => crate::t!("quvyta.appearance.forced-off"),
287            (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
288        };
289        let row = SettingRow::new(crate::t!("quvyta.appearance.reduce-motion")).disabled(forced);
290        let row = match self.failed(Row::Shared(Shared::ReducedMotion)) {
291            Some(failure) => row.description(failure),
292            None => row.description(note),
293        };
294        let send = message.clone();
295        list.row(row, |ui| {
296            ui.add(
297                Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
298            );
299        });
300        self.everywhere(list, Shared::ReducedMotion, forced, &message);
301
302        let styles = PillarStyle::ALL.map(|style| crate::t!(&format!("quvyta.appearance.pillar-{}", style.name())));
303        let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
304        list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
305            let segmented = Segmented::new(styles)
306                .selected(chosen)
307                .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
308            ui.add(segmented);
309        });
310    }
311
312    /// A row labelled `label` that says why its last change could not be saved, if it could not.
313    fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
314        let setting = SettingRow::new(label);
315        match self.failed(row) {
316            Some(failure) => setting.description(failure),
317            None => setting,
318        }
319    }
320
321    /// Why the last change of `row` was not saved.
322    fn failed(&self, row: Row) -> Option<String> {
323        self.failure
324            .as_ref()
325            .filter(|(failed, _)| *failed == row)
326            .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
327    }
328
329    /// The box under shared row `key`: checked while the application follows the ecosystem, and
330    /// `disabled` with its row.
331    fn everywhere<Msg: Clone + 'static>(
332        &self,
333        list: &mut SettingsRows<'_, Msg>,
334        key: Shared,
335        disabled: bool,
336        message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
337    ) {
338        let checked = self.preferences.source(key) != Source::App;
339        let label = crate::t!("quvyta.appearance.everywhere", family = self.ecosystem.title());
340        let send = message.clone();
341        list.row(SettingRow::new(label).nested(true).disabled(disabled), |ui| {
342            ui.add(
343                Checkbox::new(checked)
344                    .disabled(disabled)
345                    .on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))),
346            );
347        });
348    }
349
350    /// Saves `change` and returns the command that shows it at once. `settings` are the
351    /// application's own settings as it holds them in memory; they take the change too, so a
352    /// later [`Settings::save`] writes what the file now says instead of what it said before.
353    ///
354    /// A change that cannot be saved is still applied, and the row it was made on says why it was
355    /// not saved until the next change.
356    pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
357        let (row, saved, command) = match change {
358            AppearanceChange::Language(code) => {
359                let saved = self.share(Shared::Language, &code, None, settings);
360                (Row::Shared(Shared::Language), saved, Command::set_locale(code))
361            }
362            AppearanceChange::Theme(id) => {
363                let saved = self.share(Shared::Theme, &id, None, settings);
364                (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
365            }
366            AppearanceChange::Icons(mode) => {
367                let saved = self.share(Shared::Icons, mode.name(), None, settings);
368                (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
369            }
370            AppearanceChange::Everywhere(key, on) => {
371                let scope = if on { Scope::Ecosystem } else { Scope::App };
372                let value = self.preferences.text(key);
373                (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
374            }
375            AppearanceChange::ReducedMotion(on) => {
376                let saved = self.share(Shared::ReducedMotion, &on.to_string(), None, settings);
377                (Row::Shared(Shared::ReducedMotion), saved, Command::set_reduced_motion(on))
378            }
379            AppearanceChange::Pillar(style) => {
380                let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
381                (Row::Pillar, saved, Command::set_pillar(style))
382            }
383            AppearanceChange::UpdateNotice(on) => (Row::UpdateNotice, self.update_notice(on), Command::none()),
384        };
385        self.failure = saved.err().map(|error| (row, error.to_string()));
386        command
387    }
388
389    /// Writes shared `key` as `value` in `scope`, or in the scope the application follows now when
390    /// `None`, and records it.
391    fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
392        let scope =
393            scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Ecosystem });
394        let written = match scope {
395            Scope::Ecosystem => self.ecosystem.id().to_owned(),
396            Scope::App => value.to_owned(),
397        };
398        settings.store(key.key(), key.setting(&written));
399        let source = if scope == Scope::Ecosystem { Source::Ecosystem } else { Source::App };
400        self.preferences.record(key, value, source);
401        if !self.saving {
402            return Ok(());
403        }
404        match &self.folder {
405            Some(folder) => self.ecosystem.set_in(folder, &self.app, key, value, scope),
406            None => self.ecosystem.set(&self.app, key, value, scope),
407        }
408    }
409
410    /// Switches the ecosystem's update notice and records it.
411    fn update_notice(&mut self, on: bool) -> io::Result<()> {
412        self.preferences.record_update_notice(on);
413        if !self.saving {
414            return Ok(());
415        }
416        match &self.folder {
417            Some(folder) => self.ecosystem.set_update_notice_in(folder, on),
418            None => self.ecosystem.set_update_notice(on),
419        }
420    }
421
422    /// Writes the application's own `key` as `value`.
423    fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
424        settings.set(key, value.clone());
425        if !self.saving {
426            return Ok(());
427        }
428        let folder = match &self.folder {
429            Some(folder) => folder.clone(),
430            None => self
431                .ecosystem
432                .config_dir()
433                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
434        };
435        self.ecosystem.set_own_in(&folder, &self.app, key, value.to_setting())
436    }
437}
438
439#[cfg(test)]
440#[path = "appearance_tests.rs"]
441mod tests;