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;