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