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