Skip to main content

qframe/widgets/
appearance.rs

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