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::i18n::I18n;
9use crate::icons::{IconMode, PillarStyle};
10use crate::runtime::Command;
11use crate::storage::{Ecosystem, Preferences, Scope, Setting, Settings, Shared, Source};
12use crate::widget::Length;
13
14use super::{Checkbox, Segmented, Select, SettingRow, SettingsRows, Switch};
15
16/// Narrowest a choice is drawn at, so the three rows keep one column even when every name in
17/// them is short.
18const CHOICE_MIN: u16 = 18;
19
20/// Widest a choice is drawn at, a little over half of the narrow width the catalogue promises: a
21/// name longer than this is cut rather than left to take the row from its label. No built-in
22/// language, theme or icon name is near it.
23const CHOICE_MAX: u16 = 28;
24
25/// Cells a choice needs to show the longest of `names` whole: the name itself, the three the
26/// chevron and the space before it take, and the ground a [`Select`] leaves at each side, which
27/// the theme decides and which is why it is asked for rather than assumed.
28fn choice_width(names: &[String], padding: u16) -> u16 {
29    let longest = names.iter().map(|name| crate::text::width(name)).max().unwrap_or(0);
30    crate::widgets::cells::sum([longest, 3, padding.saturating_mul(2)]).clamp(CHOICE_MIN, CHOICE_MAX)
31}
32
33/// A change made on the [`Appearance`] rows. The application hands it back to
34/// [`Appearance::update`], which saves it and returns the command that shows it.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum AppearanceChange {
37    /// A language was chosen, by locale code.
38    Language(String),
39    /// A theme was chosen, by id.
40    Theme(String),
41    /// An icon mode was chosen.
42    Icons(IconMode),
43    /// The "in every application of the ecosystem" box under a shared row was checked (`true`) or
44    /// cleared (`false`).
45    Everywhere(Shared, bool),
46    /// Reduced motion was switched.
47    ReducedMotion(bool),
48    /// A pillar style was chosen.
49    Pillar(PillarStyle),
50    /// The ecosystem's update notice was switched on (`true`) or off; see
51    /// [`Ecosystem::update_notice`].
52    UpdateNotice(bool),
53}
54
55/// What became of a change the [`Appearance`] rows saved in the background; see
56/// [`Appearance::updates_in_background`].
57///
58/// The application builds its own message out of it, so a write that failed can be shown as a
59/// toast, and hands it back to [`Appearance::saved`], which puts the row back where the person
60/// left it.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum AppearanceSave {
63    /// The file says the new value now.
64    Saved,
65    /// It could not be written, with the reason. The file still says what it said, and the row
66    /// is back to that.
67    Failed(String),
68}
69
70/// Which row a failed save is shown under.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72enum Row {
73    Shared(Shared),
74    Pillar,
75    UpdateNotice,
76}
77
78/// The appearance section of a settings page or a setup wizard: language, theme, icons and reduced
79/// motion as the ecosystem shares them, and the pillar, as rows of a
80/// [`SettingsList`](super::SettingsList); and, where the application asks for its updates, the
81/// ecosystem's update notice with [`updates`](Self::updates).
82///
83/// Each shared row has a box under it, "In every Quvyta application", checked while the
84/// application follows the ecosystem: a change then goes to the ecosystem's shared file and every
85/// application that follows it changes too. Cleared, the change stays in the application's own
86/// file. The pillar is the application's own. The update notice is one switch
87/// for the whole ecosystem, kept in the shared file; see [`Ecosystem::update_notice`]. Its switch
88/// can be written in the background, so a settings page never waits for the disk; see
89/// [`updates_in_background`](Self::updates_in_background). A change is applied at once
90/// and saved at once, each file read again right before it is written; see
91/// [`Ecosystem::set`]. When the `QUVYTA_REDUCED_MOTION` environment variable decides, the reduced
92/// motion row and its box are disabled and the row says why. Texts come from the framework's language files.
93///
94/// ```
95/// use qframe::i18n::I18n;
96/// use qframe::prelude::*;
97/// use qframe::storage::{Ecosystem, Settings};
98/// use qframe::widgets::{Appearance, AppearanceChange, SettingsList};
99///
100/// struct Code {
101///     settings: Settings,
102///     appearance: Appearance,
103/// }
104///
105/// #[derive(Debug, Clone)]
106/// enum Msg {
107///     Appearance(AppearanceChange),
108/// }
109///
110/// impl App for Code {
111///     type Msg = Msg;
112///     fn update(&mut self, msg: Msg) -> Command<Msg> {
113///         match msg {
114///             Msg::Appearance(change) => self.appearance.update(change, &mut self.settings),
115///         }
116///     }
117///     fn view(&self, ui: &mut View<'_, Msg>) {
118///         SettingsList::show(ui, |list| self.appearance.section(list, Msg::Appearance));
119///     }
120/// }
121///
122/// # let folder = std::env::temp_dir().join(format!("quvyta-appearance-doc-{}", std::process::id()));
123/// let ecosystem = Ecosystem::QUVYTA;
124/// // An application passes `ecosystem.preferences("code", &i18n)`; the example stays in a folder of its own.
125/// let preferences = ecosystem.preferences_in(&folder, "code", &I18n::builtin());
126/// let appearance = Appearance::new(ecosystem, "code", preferences).in_folder(&folder);
127/// let settings = Settings::open(folder.join("code.conf")).member_of(&ecosystem);
128/// let mut app = Harness::new(Code { settings, appearance }, 60, 20);
129/// assert!(app.screen().contains("In every Quvyta application"));
130/// # std::fs::remove_dir_all(&folder).ok();
131/// ```
132#[derive(Debug, Clone)]
133pub struct Appearance {
134    ecosystem: Ecosystem,
135    app: String,
136    folder: Option<PathBuf>,
137    preferences: Preferences,
138    /// Whether a change is written to the files; a setup wizard holds them back.
139    saving: bool,
140    /// Whether the update notice row writes the shared file on a thread of its own.
141    background: bool,
142    failure: Option<(Row, String)>,
143}
144
145impl Appearance {
146    /// The title the Appearance box gives the row of `key`, in the language of `i18n`, so an
147    /// application that shows the same preference elsewhere, such as in a table of what follows
148    /// the ecosystem, calls it by the same name.
149    #[must_use]
150    pub fn label(i18n: &I18n, key: Shared) -> String {
151        i18n.translate(shared_label_key(key), &[])
152    }
153
154    /// The appearance of application `app` of `ecosystem`, starting from the `preferences`
155    /// [`Ecosystem::preferences`] resolved for it. Changes are saved in the ecosystem's folder.
156    #[must_use]
157    pub fn new(ecosystem: Ecosystem, app: impl Into<String>, preferences: Preferences) -> Self {
158        Self { ecosystem, app: app.into(), folder: None, preferences, saving: true, background: false, failure: None }
159    }
160
161    /// Saves changes in `folder` as the ecosystem's folder instead of this platform's, for a test
162    /// or a demo that must leave the user's own files alone; see [`Ecosystem::set_in`].
163    #[must_use]
164    pub fn in_folder(mut self, folder: impl Into<PathBuf>) -> Self {
165        self.folder = Some(folder.into());
166        self
167    }
168
169    /// Applies every change without writing a file: the [shared preferences](Self::preferences)
170    /// and the `settings` given to [`update`](Self::update) take it, the screen shows it, and the
171    /// files are left to whoever writes them later.
172    ///
173    /// For the first step of a [setup wizard](super::Setup), which writes both files only when the
174    /// wizard finishes, so a wizard closed half-way leaves nothing behind.
175    #[must_use]
176    pub fn without_saving(mut self) -> Self {
177        self.saving = false;
178        self
179    }
180
181    /// Has the [update notice](Self::updates) row write the shared file on a thread of its own,
182    /// so a settings page never waits for the disk to turn the switch over: the switch shows the
183    /// new value at once and the file takes it behind the screen. A file that cannot be written
184    /// puts the switch back where the person left it and says so as an
185    /// [`AppearanceSave`], which the application shows as a toast.
186    ///
187    /// The application hands every change to [`update_saving`](Self::update_saving) instead of
188    /// [`update`](Self::update), and the outcome back to [`saved`](Self::saved). Every other row
189    /// is written as `update` writes it, on the thread that draws.
190    #[must_use]
191    pub fn updates_in_background(mut self) -> Self {
192        self.background = true;
193        self
194    }
195
196    /// The shared preferences as they stand after the changes made so far.
197    #[must_use]
198    pub fn preferences(&self) -> &Preferences {
199        &self.preferences
200    }
201
202    /// Takes `preferences` resolved again after the files changed while the section is open, such
203    /// as the ones [`App::preferences`](crate::runtime::App::preferences) hears when another
204    /// application switches the theme for the whole ecosystem. The rows then show the new values
205    /// and the box under each shared row whether the application follows the ecosystem now, and
206    /// the next change is saved where that box says.
207    ///
208    /// Nothing is written and nothing is applied: the runtime has already switched the screen.
209    /// What the person is doing on the section stays as it is: an open list stays open, and a
210    /// reason a change could not be saved stays under its row until the next change.
211    pub fn refresh(&mut self, preferences: Preferences) {
212        self.preferences = preferences;
213    }
214
215    /// Adds an "Appearance" heading, the three [shared rows](Self::rows), reduced motion with its
216    /// box and the application's own pillar to `list`.
217    pub fn section<Msg: Clone + 'static>(
218        &self,
219        list: &mut SettingsRows<'_, Msg>,
220        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
221    ) {
222        list.heading(crate::t!("quvyta.appearance.heading"));
223        self.rows(list, message.clone());
224        self.motion_and_pillar(list, message);
225    }
226
227    /// Adds the ecosystem's update notice switch to `list`, with the text saying what it asks and
228    /// what it never sends: for an application that asks whether a newer version of itself is out
229    /// ([`Command::check_for_update`](crate::runtime::Command::check_for_update)), right after
230    /// [`section`](Self::section). The switch is the ecosystem's, one for every application, kept in
231    /// the shared file; see [`Ecosystem::update_notice`]. An application that never asks leaves the
232    /// row out, so its settings offer nothing that does nothing there.
233    ///
234    /// The change is written where the section is drawn, unless the application asked for
235    /// [`updates_in_background`](Self::updates_in_background).
236    pub fn updates<Msg: Clone + 'static>(
237        &self,
238        list: &mut SettingsRows<'_, Msg>,
239        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
240    ) {
241        let row = SettingRow::new(crate::t!("quvyta.appearance.updates"));
242        let row = match self.failed(Row::UpdateNotice) {
243            Some(failure) => row.description(failure),
244            None => row.description(crate::t!("quvyta.appearance.updates-text", family = self.ecosystem.title())),
245        };
246        let on = self.preferences.update_notice();
247        list.row(row, |ui| {
248            ui.add(Switch::new(on).on_toggle(move |on| message(AppearanceChange::UpdateNotice(on))));
249        });
250    }
251
252    /// Adds the three rows the ecosystem shares, language, theme and icons, each with its box, to
253    /// `list`, without a heading and without the application's own rows: what the first step of a
254    /// [setup wizard](super::Setup) asks, on a page that names the section itself. Every change is
255    /// sent as `message`.
256    pub fn rows<Msg: Clone + 'static>(
257        &self,
258        list: &mut SettingsRows<'_, Msg>,
259        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
260    ) {
261        let env = list.env();
262        let languages = env.i18n().list();
263        let active = env.i18n().active().to_owned();
264        let themes = env.themes();
265        let theme = env.theme().id().to_owned();
266        let icons = env.icon_mode();
267        let icon_names = IconMode::ALL.map(|mode| crate::t!(&mode.label_key()));
268
269        // One width for the three rows, from the longest name any of them offers: a language list
270        // whose longest name is `Português (Brasil)` needs more than the built-in themes do, and a
271        // column that changed width from row to row would read as three controls, not one group.
272        // The ground a select leaves at its sides is the theme's, so the width is asked of the
273        // theme rather than assumed; without it the name is cut by exactly that much.
274        let padding = env.theme().style("select", None, &[]).pair("padding").map_or(1, |(_, horizontal)| horizontal);
275        let width = choice_width(
276            &languages
277                .iter()
278                .map(|(_, name)| name.clone())
279                .chain(themes.iter().map(|(_, name)| name.clone()))
280                .chain(icon_names.iter().cloned())
281                .collect::<Vec<String>>(),
282            padding,
283        );
284
285        let codes: Vec<String> = languages.iter().map(|(code, _)| code.clone()).collect();
286        let chosen = codes.iter().position(|code| *code == active);
287        let send = message.clone();
288        list.row(self.row(Row::Shared(Shared::Language), crate::t!(shared_label_key(Shared::Language))), |ui| {
289            let names = languages.into_iter().map(|(_, name)| name);
290            let select = Select::new(names)
291                .selected(chosen)
292                .on_select(move |index| send(AppearanceChange::Language(codes[index].clone())));
293            ui.add(select).width(Length::Cells(width));
294        });
295        self.everywhere(list, Shared::Language, false, &message);
296
297        let ids: Vec<String> = themes.iter().map(|(id, _)| id.clone()).collect();
298        let chosen = ids.iter().position(|id| *id == theme);
299        let send = message.clone();
300        list.row(self.row(Row::Shared(Shared::Theme), crate::t!(shared_label_key(Shared::Theme))), |ui| {
301            let names = themes.into_iter().map(|(_, name)| name);
302            let select = Select::new(names)
303                .selected(chosen)
304                .on_select(move |index| send(AppearanceChange::Theme(ids[index].clone())));
305            ui.add(select).width(Length::Cells(width));
306        });
307        self.everywhere(list, Shared::Theme, false, &message);
308
309        let chosen = IconMode::ALL.iter().position(|mode| *mode == icons);
310        let send = message.clone();
311        list.row(self.row(Row::Shared(Shared::Icons), crate::t!(shared_label_key(Shared::Icons))), |ui| {
312            let select = Select::new(icon_names)
313                .selected(chosen)
314                .on_select(move |index| send(AppearanceChange::Icons(IconMode::ALL[index])));
315            ui.add(select).width(Length::Cells(width));
316        });
317        self.everywhere(list, Shared::Icons, false, &message);
318    }
319
320    /// Adds reduced motion with its box, and the pillar, which is the application's own, to `list`.
321    fn motion_and_pillar<Msg: Clone + 'static>(
322        &self,
323        list: &mut SettingsRows<'_, Msg>,
324        message: impl Fn(AppearanceChange) -> Msg + Clone + 'static,
325    ) {
326        let env = list.env();
327        let (reduced, forced) = (env.reduced_motion(), env.reduced_motion_forced());
328        let pillar = env.pillar_style().unwrap_or(PillarStyle::Thick);
329
330        let note = match (forced, reduced) {
331            (true, true) => crate::t!("quvyta.appearance.forced-on"),
332            (true, false) => crate::t!("quvyta.appearance.forced-off"),
333            (false, _) => crate::t!("quvyta.appearance.reduce-motion-text"),
334        };
335        let row = SettingRow::new(crate::t!(shared_label_key(Shared::ReducedMotion))).disabled(forced);
336        let row = match self.failed(Row::Shared(Shared::ReducedMotion)) {
337            Some(failure) => row.description(failure),
338            None => row.description(note),
339        };
340        let send = message.clone();
341        list.row(row, |ui| {
342            ui.add(
343                Switch::new(reduced).disabled(forced).on_toggle(move |on| send(AppearanceChange::ReducedMotion(on))),
344            );
345        });
346        self.everywhere(list, Shared::ReducedMotion, forced, &message);
347
348        let styles = PillarStyle::ALL.map(|style| crate::t!(&style.label_key()));
349        let chosen = PillarStyle::ALL.iter().position(|style| *style == pillar).unwrap_or(0);
350        list.row(self.row(Row::Pillar, crate::t!("quvyta.appearance.pillar")), |ui| {
351            let segmented = Segmented::new(styles)
352                .selected(chosen)
353                .on_select(move |index| message(AppearanceChange::Pillar(PillarStyle::ALL[index])));
354            ui.add(segmented);
355        });
356    }
357
358    /// A row labelled `label` that says why its last change could not be saved, if it could not.
359    fn row<Msg>(&self, row: Row, label: String) -> SettingRow<Msg> {
360        let setting = SettingRow::new(label);
361        match self.failed(row) {
362            Some(failure) => setting.description(failure),
363            None => setting,
364        }
365    }
366
367    /// Why the last change of `row` was not saved.
368    fn failed(&self, row: Row) -> Option<String> {
369        self.failure
370            .as_ref()
371            .filter(|(failed, _)| *failed == row)
372            .map(|(_, reason)| crate::t!("quvyta.appearance.not-saved", reason = reason.as_str()))
373    }
374
375    /// The box under shared row `key`: checked while the application follows the ecosystem, and
376    /// `disabled` with its row.
377    fn everywhere<Msg: Clone + 'static>(
378        &self,
379        list: &mut SettingsRows<'_, Msg>,
380        key: Shared,
381        disabled: bool,
382        message: &(impl Fn(AppearanceChange) -> Msg + Clone + 'static),
383    ) {
384        let checked = self.preferences.source(key) != Source::App;
385        let label = crate::t!("quvyta.appearance.everywhere", family = self.ecosystem.title());
386        let send = message.clone();
387        list.row(SettingRow::new(label).nested(true).disabled(disabled), |ui| {
388            ui.add(
389                Checkbox::new(checked)
390                    .disabled(disabled)
391                    .on_toggle(move |on| send(AppearanceChange::Everywhere(key, on))),
392            );
393        });
394    }
395
396    /// Saves `change` and returns the command that shows it at once. `settings` are the
397    /// application's own settings as it holds them in memory; they take the change too, so a
398    /// later [`Settings::save`] writes what the file now says instead of what it said before.
399    ///
400    /// A change that cannot be saved is still applied, and the row it was made on says why it was
401    /// not saved until the next change.
402    pub fn update<Msg: Send + 'static>(&mut self, change: AppearanceChange, settings: &mut Settings) -> Command<Msg> {
403        let (row, saved, command) = match change {
404            AppearanceChange::Language(code) => {
405                let saved = self.share(Shared::Language, &code, None, settings);
406                (Row::Shared(Shared::Language), saved, Command::set_locale(code))
407            }
408            AppearanceChange::Theme(id) => {
409                let saved = self.share(Shared::Theme, &id, None, settings);
410                (Row::Shared(Shared::Theme), saved, Command::set_theme(id))
411            }
412            AppearanceChange::Icons(mode) => {
413                let saved = self.share(Shared::Icons, mode.name(), None, settings);
414                (Row::Shared(Shared::Icons), saved, Command::set_icon_mode(mode))
415            }
416            AppearanceChange::Everywhere(key, on) => {
417                let scope = if on { Scope::Ecosystem } else { Scope::App };
418                let value = self.preferences.text(key);
419                (Row::Shared(key), self.share(key, &value, Some(scope), settings), Command::none())
420            }
421            AppearanceChange::ReducedMotion(on) => {
422                let saved = self.share(Shared::ReducedMotion, &on.to_string(), None, settings);
423                (Row::Shared(Shared::ReducedMotion), saved, Command::set_reduced_motion(on))
424            }
425            AppearanceChange::Pillar(style) => {
426                let saved = self.own(Settings::PILLAR, style.name().to_owned(), settings);
427                (Row::Pillar, saved, Command::set_pillar(style))
428            }
429            AppearanceChange::UpdateNotice(on) => (Row::UpdateNotice, self.update_notice(on), Command::none()),
430        };
431        self.failure = saved.err().map(|error| (row, error.to_string()));
432        command
433    }
434
435    /// [`update`](Self::update), and the command that writes the ecosystem's update notice in the
436    /// background when [`updates_in_background`](Self::updates_in_background) is on: the switch
437    /// shows the new value at once and `saved` is called with what became of the write once it is
438    /// done, so the application can show it as a toast and hand it to [`saved`](Self::saved).
439    ///
440    /// Without the option every change is written as [`update`](Self::update) writes it, on the
441    /// thread that draws, and `saved` is never called; an application can then turn the option on
442    /// without touching this line.
443    ///
444    /// ```
445    /// use qframe::prelude::*;
446    /// use qframe::storage::{Ecosystem, Settings};
447    /// use qframe::widgets::{Appearance, AppearanceChange, AppearanceSave, SettingsList};
448    ///
449    /// struct Code {
450    ///     settings: Settings,
451    ///     appearance: Appearance,
452    ///     /// What the last background write left.
453    ///     saved: Option<AppearanceSave>,
454    /// }
455    ///
456    /// #[derive(Debug, Clone)]
457    /// enum Msg {
458    ///     Appearance(AppearanceChange),
459    ///     Saved(AppearanceSave),
460    /// }
461    ///
462    /// impl App for Code {
463    ///     type Msg = Msg;
464    ///     fn update(&mut self, msg: Msg) -> Command<Msg> {
465    ///         match msg {
466    ///             Msg::Appearance(change) => {
467    ///                 self.appearance.update_saving(change, &mut self.settings, Msg::Saved)
468    ///             }
469    ///             Msg::Saved(save) => {
470    ///                 self.saved = Some(save.clone());
471    ///                 self.appearance.saved(&save);
472    ///                 Command::none()
473    ///             }
474    ///         }
475    ///     }
476    ///     fn view(&self, ui: &mut View<'_, Msg>) {
477    ///         SettingsList::show(ui, |list| {
478    ///             self.appearance.section(list, Msg::Appearance);
479    ///             self.appearance.updates(list, Msg::Appearance);
480    ///         });
481    ///     }
482    /// }
483    ///
484    /// # let folder = std::env::temp_dir().join(format!("quvyta-appearance-saving-doc-{}", std::process::id()));
485    /// # let ecosystem = Ecosystem::QUVYTA;
486    /// // An application passes `ecosystem.preferences("code", &i18n)`; the example stays in a folder of its own.
487    /// # let preferences = ecosystem.preferences_in(&folder, "code", &qframe::i18n::I18n::builtin());
488    /// let appearance = Appearance::new(ecosystem, "code", preferences).in_folder(&folder).updates_in_background();
489    /// let settings = Settings::open(folder.join("code.conf")).member_of(&ecosystem);
490    /// let mut app = Harness::new(Code { settings, appearance, saved: None }, 60, 20);
491    /// // The row's change is written off the drawing thread and its outcome comes back as a message.
492    /// app.send(Msg::Appearance(AppearanceChange::UpdateNotice(false)));
493    /// assert_eq!(app.app().saved, Some(AppearanceSave::Saved), "the file took the new value");
494    /// let shared = std::fs::read_to_string(folder.join("quvyta.conf")).expect("the shared file");
495    /// assert!(shared.contains("update-notice = false"), "{shared}");
496    /// # std::fs::remove_dir_all(&folder).ok();
497    /// ```
498    pub fn update_saving<Msg: Send + 'static>(
499        &mut self,
500        change: AppearanceChange,
501        settings: &mut Settings,
502        saved: impl FnOnce(AppearanceSave) -> Msg + Send + 'static,
503    ) -> Command<Msg> {
504        match change {
505            AppearanceChange::UpdateNotice(on) if self.background => self.write_notice_in_background(on, saved),
506            change => self.update(change, settings),
507        }
508    }
509
510    /// Takes what became of a change the rows saved in the background, as
511    /// [`update_saving`](Self::update_saving) and [`updates_in_background`](Self::updates_in_background)
512    /// say. A value the file could not take leaves the row showing what the file still says, so
513    /// the switch is where the person left it. Nothing is written under the row: the application
514    /// has the outcome and shows it itself, as a toast.
515    pub fn saved(&mut self, save: &AppearanceSave) {
516        if let AppearanceSave::Failed(_) = save {
517            self.preferences.record_update_notice(self.notice_in_the_file());
518        }
519    }
520
521    /// The update notice's value as `on`, written on a thread of its own, and the command that
522    /// tells the application how it went.
523    fn write_notice_in_background<Msg: Send + 'static>(
524        &mut self,
525        on: bool,
526        saved: impl FnOnce(AppearanceSave) -> Msg + Send + 'static,
527    ) -> Command<Msg> {
528        // The switch shows the new value at once, as it does for a write on this thread; only the
529        // file waits, and a file that refuses the value takes the switch back in `saved`.
530        self.preferences.record_update_notice(on);
531        if !self.saving {
532            return Command::none();
533        }
534        let ecosystem = self.ecosystem;
535        let folder = self.folder.clone();
536        Command::perform(move || {
537            let written = match &folder {
538                Some(folder) => ecosystem.set_update_notice_in(folder, on),
539                None => ecosystem.set_update_notice(on),
540            };
541            saved(match written {
542                Ok(()) => AppearanceSave::Saved,
543                Err(error) => AppearanceSave::Failed(error.to_string()),
544            })
545        })
546    }
547
548    /// What the ecosystem's file says the update notice is, read now: the truth a failed write
549    /// left behind, since the file was not touched.
550    fn notice_in_the_file(&self) -> bool {
551        match &self.folder {
552            Some(folder) => self.ecosystem.update_notice_in(folder),
553            None => self.ecosystem.update_notice(),
554        }
555    }
556
557    /// Writes shared `key` as `value` in `scope`, or in the scope the application follows now when
558    /// `None`, and records it.
559    fn share(&mut self, key: Shared, value: &str, scope: Option<Scope>, settings: &mut Settings) -> io::Result<()> {
560        let scope =
561            scope.unwrap_or(if self.preferences.source(key) == Source::App { Scope::App } else { Scope::Ecosystem });
562        let written = match scope {
563            Scope::Ecosystem => self.ecosystem.id().to_owned(),
564            Scope::App => value.to_owned(),
565        };
566        settings.store(key.key(), key.setting(&written));
567        let source = if scope == Scope::Ecosystem { Source::Ecosystem } else { Source::App };
568        self.preferences.record(key, value, source);
569        if !self.saving {
570            return Ok(());
571        }
572        match &self.folder {
573            Some(folder) => self.ecosystem.set_in(folder, &self.app, key, value, scope),
574            None => self.ecosystem.set(&self.app, key, value, scope),
575        }
576    }
577
578    /// Switches the ecosystem's update notice and records it.
579    fn update_notice(&mut self, on: bool) -> io::Result<()> {
580        self.preferences.record_update_notice(on);
581        if !self.saving {
582            return Ok(());
583        }
584        match &self.folder {
585            Some(folder) => self.ecosystem.set_update_notice_in(folder, on),
586            None => self.ecosystem.set_update_notice(on),
587        }
588    }
589
590    /// Writes the application's own `key` as `value`.
591    fn own<T: Setting + Clone>(&self, key: &str, value: T, settings: &mut Settings) -> io::Result<()> {
592        settings.set(key, value.clone());
593        if !self.saving {
594            return Ok(());
595        }
596        let folder = match &self.folder {
597            Some(folder) => folder.clone(),
598            None => self
599                .ecosystem
600                .config_dir()
601                .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no config directory found"))?,
602        };
603        self.ecosystem.set_own_in(&folder, &self.app, key, value.to_setting())
604    }
605}
606
607/// The locale key of the row of `key`; see [`Appearance::label`].
608fn shared_label_key(key: Shared) -> &'static str {
609    match key {
610        Shared::Language => "quvyta.appearance.language",
611        Shared::Theme => "quvyta.appearance.theme",
612        Shared::Icons => "quvyta.appearance.icons",
613        Shared::ReducedMotion => "quvyta.appearance.reduce-motion",
614    }
615}
616
617#[cfg(test)]
618#[path = "appearance_tests.rs"]
619mod tests;