Skip to main content

qframe/
env.rs

1//! The environment widgets draw in: active theme, icons, language, keymap and colour depth,
2//! together with everything a settings screen needs to list the alternatives.
3
4use std::collections::BTreeMap;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use crate::color::ColorDepth;
10use crate::diagnostics::{Diagnostic, Location};
11use crate::i18n::I18n;
12use crate::icons::{
13    GlyphMode, IconMode, IconSetRegistry, Icons, PILLAR, PillarStyle, default_font_dirs, detect_glyph_mode,
14};
15use crate::keymap::Keymap;
16use crate::theme::{Theme, ThemeRegistry};
17
18/// Where an application's own theme, icon, locale and keymap files live.
19///
20/// Every kind of file can be given as text instead of as a path, for files compiled into the
21/// binary with `include_str!`. An application that gives all of its files as text starts with
22/// nothing beside it on disk, and a path it also names is then optional: when the path cannot be
23/// read the text stands in for it and the reason becomes a [diagnostic](Env::diagnostics)
24/// instead of stopping the program.
25#[derive(Debug, Clone, Default)]
26pub struct AssetDirs {
27    /// Directory of `*.toml` theme files.
28    pub themes: Option<PathBuf>,
29    /// Theme files given as text, as `(file name, TOML text)`, loaded after `themes` so they
30    /// win. The file stem is the theme id, as it is in a directory.
31    pub theme_sources: Vec<(String, String)>,
32    /// Directory of `*.toml` icon set files.
33    pub icons: Option<PathBuf>,
34    /// Icon set files given as text, as `(file name, TOML text)`, loaded after `icons` so they
35    /// win. The file stem is the icon set id, as it is in a directory.
36    pub icon_sources: Vec<(String, String)>,
37    /// Directory of `*.toml` locale files.
38    pub locales: Option<PathBuf>,
39    /// Locale files given as text, as `(file name, TOML text)`, loaded after `locales` so they
40    /// win. For files compiled into the binary with `include_str!`, which an installed program
41    /// carries with it; the file name only labels diagnostics.
42    pub locale_sources: Vec<(String, String)>,
43    /// A keymap file layered over the built-in keymap.
44    pub keymap: Option<PathBuf>,
45    /// A keymap given as text, as `(file name, TOML text)`, layered over the built-in keymap and
46    /// over `keymap`, so it wins. The file name only labels diagnostics.
47    pub keymap_source: Option<(String, String)>,
48}
49
50/// Everything widgets need to know about how to draw and label themselves.
51#[derive(Debug, Clone)]
52pub struct Env {
53    themes: ThemeRegistry,
54    theme: Theme,
55    icon_sets: IconSetRegistry,
56    icon_mode: IconMode,
57    glyph_mode: GlyphMode,
58    icons: Icons,
59    i18n: Arc<I18n>,
60    keymap: Keymap,
61    depth: ColorDepth,
62    reduced_motion: bool,
63    /// Reduced motion as the `QUVYTA_REDUCED_MOTION` environment variable forces it, if set.
64    forced_reduced_motion: Option<bool>,
65    pillar: Option<PillarStyle>,
66    slide: Option<bool>,
67    diagnostics: Vec<Diagnostic>,
68}
69
70impl Env {
71    /// Built-in files only, the `monochrome` theme, Unicode glyphs, English and 24-bit colour.
72    /// Deterministic, which makes it the environment for tests.
73    #[must_use]
74    pub fn builtin() -> Self {
75        let themes = ThemeRegistry::builtin();
76        let (theme, _) = themes.resolve_or_default("monochrome");
77        let icon_sets = IconSetRegistry::builtin();
78        let icons = icon_sets.icons(theme.icon_set(), theme.icon_overrides(), GlyphMode::Unicode);
79        Self {
80            themes,
81            theme,
82            icon_sets,
83            icon_mode: IconMode::Unicode,
84            glyph_mode: GlyphMode::Unicode,
85            icons,
86            i18n: Arc::new(I18n::builtin()),
87            keymap: Keymap::builtin(),
88            depth: ColorDepth::TrueColor,
89            reduced_motion: false,
90            forced_reduced_motion: None,
91            pillar: None,
92            slide: None,
93            diagnostics: Vec::new(),
94        }
95    }
96
97    /// Loads the application's files over the built-ins and detects colour depth, glyphs and
98    /// language from the process environment.
99    ///
100    /// # Errors
101    ///
102    /// Returns an I/O error when a configured directory or file cannot be read and no text was
103    /// given for that kind of file; with text given, an unreadable path is a diagnostic and the
104    /// text stands in for it. Problems inside files are never errors; they are collected in
105    /// [`Env::diagnostics`].
106    pub fn load(dirs: &AssetDirs) -> io::Result<Self> {
107        let lookup = |name: &str| std::env::var(name).ok();
108        let mut env = Self::builtin();
109        if let Some(dir) = &dirs.themes {
110            let read = env.themes.load_dir(dir);
111            stand_in(read, dir, !dirs.theme_sources.is_empty(), &mut env.diagnostics)?;
112        }
113        for (file, text) in &dirs.theme_sources {
114            env.themes.add_source(&source_id(file), file, text);
115        }
116        if let Some(dir) = &dirs.icons {
117            let read = env.icon_sets.load_dir(dir);
118            stand_in(read, dir, !dirs.icon_sources.is_empty(), &mut env.diagnostics)?;
119        }
120        for (file, text) in &dirs.icon_sources {
121            env.icon_sets.add_source(&source_id(file), file, text);
122        }
123        let mut i18n = I18n::builtin();
124        if let Some(dir) = &dirs.locales {
125            let read = i18n.load_dir(dir);
126            stand_in(read, dir, !dirs.locale_sources.is_empty(), &mut env.diagnostics)?;
127        }
128        for (file, text) in &dirs.locale_sources {
129            i18n.add_source(file, text);
130        }
131        if let Some(code) = i18n.detect(lookup) {
132            i18n.set_active(&code);
133        }
134        if let Some(file) = &dirs.keymap {
135            let read = load_keymap(file, &mut env.diagnostics);
136            let has_source = dirs.keymap_source.is_some();
137            if let Some(keymap) = stand_in(read, file, has_source, &mut env.diagnostics)? {
138                env.keymap.overlay(&keymap);
139            }
140        }
141        if let Some((file, text)) = &dirs.keymap_source {
142            let keymap = Keymap::parse(file, text, &mut env.diagnostics);
143            env.keymap.overlay(&keymap);
144        }
145        env.diagnostics.extend(env.themes.diagnostics().iter().cloned());
146        env.diagnostics.extend(env.icon_sets.diagnostics().iter().cloned());
147        env.diagnostics.extend(i18n.diagnostics().iter().cloned());
148        env.diagnostics.extend(env.keymap.conflicts());
149        env.i18n = Arc::new(i18n);
150        env.depth = ColorDepth::detect(lookup);
151        env.force_reduced_motion(forced_reduced_motion(lookup));
152        env.icon_mode = IconMode::Auto;
153        env.glyph_mode = detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup));
154        env.rebuild_icons();
155        Ok(env)
156    }
157
158    /// The active theme.
159    #[must_use]
160    pub fn theme(&self) -> &Theme {
161        &self.theme
162    }
163
164    /// `(id, name)` of every theme.
165    #[must_use]
166    pub fn themes(&self) -> Vec<(String, String)> {
167        self.themes.list()
168    }
169
170    /// `(id, name)` of every icon set, the way [`Env::themes`] lists the themes. A theme names
171    /// the set it draws with, so this tells which sets a theme may name.
172    #[must_use]
173    pub fn icon_sets(&self) -> Vec<(String, String)> {
174        self.icon_sets.list()
175    }
176
177    /// The icons in the active glyph mode.
178    #[must_use]
179    pub fn icons(&self) -> &Icons {
180        &self.icons
181    }
182
183    /// The chosen icon mode.
184    #[must_use]
185    pub fn icon_mode(&self) -> IconMode {
186        self.icon_mode
187    }
188
189    /// The glyph column actually drawn.
190    #[must_use]
191    pub fn glyph_mode(&self) -> GlyphMode {
192        self.glyph_mode
193    }
194
195    /// The translator.
196    #[must_use]
197    pub fn i18n(&self) -> &I18n {
198        &self.i18n
199    }
200
201    /// The keymap.
202    #[must_use]
203    pub fn keymap(&self) -> &Keymap {
204        &self.keymap
205    }
206
207    /// The keymap, to bind actions in code, e.g. before handing the environment to a
208    /// [`Harness`](crate::runtime::Harness).
209    pub fn keymap_mut(&mut self) -> &mut Keymap {
210        &mut self.keymap
211    }
212
213    /// The terminal's colour depth.
214    #[must_use]
215    pub fn depth(&self) -> ColorDepth {
216        self.depth
217    }
218
219    /// Draws as a terminal of `depth` would, instead of the depth that was detected. Lets a test
220    /// see what a widget looks like where colours are scarce; see
221    /// [`Harness::set_depth`](crate::runtime::Harness::set_depth).
222    pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
223        self.depth = depth;
224    }
225
226    /// Whether animations are reduced: layers appear at once, nothing breathes or spins.
227    ///
228    /// The `QUVYTA_REDUCED_MOTION` environment variable, read by [`Env::load`], decides when it
229    /// is set: `0` keeps motion, any other non-empty value reduces it. It wins over a saved
230    /// `reduced-motion` setting and over `Command::set_reduced_motion`, because a choice made in
231    /// the user's shell is the stronger signal, the way accessibility overrides work. Unset or
232    /// empty, the saved setting and the application decide.
233    #[must_use]
234    pub fn reduced_motion(&self) -> bool {
235        self.reduced_motion
236    }
237
238    /// Whether the `QUVYTA_REDUCED_MOTION` environment variable decides reduced motion, so neither
239    /// a saved setting nor `Command::set_reduced_motion` can change it. A settings screen uses it to
240    /// show its reduced-motion switch as decided by the environment instead of letting the switch
241    /// snap back when pressed.
242    #[must_use]
243    pub fn reduced_motion_forced(&self) -> bool {
244        self.forced_reduced_motion.is_some()
245    }
246
247    /// Lets `forced` decide reduced motion from now on, whatever is set or saved later; `None`
248    /// leaves the decision to settings and commands.
249    fn force_reduced_motion(&mut self, forced: Option<bool>) {
250        self.forced_reduced_motion = forced;
251        if let Some(reduced) = forced {
252            self.reduced_motion = reduced;
253        }
254    }
255
256    /// Reduces motion or brings it back, unless the environment variable already decided.
257    pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
258        self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
259    }
260
261    /// The pillar the user chose over the theme's, if any.
262    #[must_use]
263    pub fn pillar_style(&self) -> Option<PillarStyle> {
264        self.pillar
265    }
266
267    pub(crate) fn set_pillar_style(&mut self, style: PillarStyle) {
268        self.pillar = Some(style);
269        self.rebuild_icons();
270    }
271
272    /// Whether list structures (lists, menus, trees, tables, tab strips and rails, dropdown options)
273    /// slide the leading text of hovered and selected rows one cell; buttons and fields never do.
274    /// The user's choice when made, otherwise the theme's `motion.slide`.
275    #[must_use]
276    pub fn slide(&self) -> bool {
277        self.slide.unwrap_or(self.theme.motion().slide)
278    }
279
280    pub(crate) fn set_slide(&mut self, slide: bool) {
281        self.slide = Some(slide);
282    }
283
284    /// Problems found in theme, icon, locale and keymap files, including theme switches that
285    /// fell back to the default.
286    #[must_use]
287    pub fn diagnostics(&self) -> &[Diagnostic] {
288        &self.diagnostics
289    }
290
291    pub(crate) fn i18n_arc(&self) -> Arc<I18n> {
292        Arc::clone(&self.i18n)
293    }
294
295    /// Activates theme `id`; falls back to the built-in default and records why when it
296    /// cannot be loaded.
297    pub(crate) fn set_theme(&mut self, id: &str) {
298        let (theme, diagnostics) = self.themes.resolve_or_default(id);
299        self.diagnostics.extend(diagnostics);
300        self.theme = theme;
301        self.rebuild_icons();
302    }
303
304    pub(crate) fn set_locale(&mut self, code: &str) {
305        let mut i18n = I18n::clone(&self.i18n);
306        if i18n.set_active(code) {
307            self.i18n = Arc::new(i18n);
308        } else {
309            self.diagnostics.push(Diagnostic::warning(None, format!("unknown locale `{code}`")));
310        }
311    }
312
313    pub(crate) fn set_icon_mode(&mut self, mode: IconMode) {
314        let lookup = |name: &str| std::env::var(name).ok();
315        self.icon_mode = mode;
316        self.glyph_mode = match mode {
317            IconMode::Nerd => GlyphMode::Nerd,
318            IconMode::Unicode => GlyphMode::Unicode,
319            IconMode::Ascii => GlyphMode::Ascii,
320            IconMode::Auto => detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup)),
321        };
322        self.rebuild_icons();
323    }
324
325    /// Sets glyph mode directly; used by tests to render every mode.
326    pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
327        self.glyph_mode = mode;
328        self.rebuild_icons();
329    }
330
331    /// Switches to the theme, language, icon mode, reduced motion, pillar and slide saved in
332    /// `settings`; `QUVYTA_REDUCED_MOTION`, when set, still decides reduced motion.
333    pub(crate) fn apply_settings(&mut self, settings: &crate::storage::Settings) {
334        if let Some(theme) = settings.theme() {
335            self.set_theme(&theme);
336        }
337        if let Some(language) = settings.language() {
338            self.set_locale(&language);
339        }
340        if let Some(mode) = settings.icon_mode() {
341            self.set_icon_mode(mode);
342        }
343        if let Some(reduced) = settings.reduced_motion() {
344            self.set_reduced_motion(reduced);
345        }
346        if let Some(style) = settings.pillar_style() {
347            self.set_pillar_style(style);
348        }
349        if let Some(slide) = settings.slide() {
350            self.set_slide(slide);
351        }
352    }
353
354    fn rebuild_icons(&mut self) {
355        let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
356        if let Some(style) = self.pillar {
357            overrides.insert(PILLAR.to_owned(), style.glyphs());
358        }
359        self.icons = self.icon_sets.icons_with_animations(
360            self.theme.icon_set(),
361            &overrides,
362            self.theme.animation_overrides(),
363            self.glyph_mode,
364        );
365    }
366}
367
368/// What `QUVYTA_REDUCED_MOTION` forces: nothing when unset or empty, motion for `0`, reduced
369/// motion for any other value.
370fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
371    lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
372}
373
374/// Lets text given for the same kind of file stand in for a path that cannot be read: with
375/// `has_source` the reason becomes a warning and loading carries on, without it the I/O error
376/// travels on, because then nothing would take the file's place.
377fn stand_in<T>(
378    read: io::Result<T>,
379    path: &Path,
380    has_source: bool,
381    diagnostics: &mut Vec<Diagnostic>,
382) -> io::Result<Option<T>> {
383    match read {
384        Ok(value) => Ok(Some(value)),
385        Err(error) if has_source => {
386            let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
387            diagnostics.push(Diagnostic::warning(
388                Some(Location::from_offset(name, "", 0)),
389                format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
390            ));
391            Ok(None)
392        }
393        Err(error) => Err(error),
394    }
395}
396
397/// The asset id of a file given as text: its stem, the way a directory names its files.
398fn source_id(file: &str) -> String {
399    Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
400}
401
402fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
403    let text = std::fs::read_to_string(file)?;
404    let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
405    Ok(Keymap::parse(name, &text, diagnostics))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
414        let mut env = Env::builtin();
415        assert_eq!(env.icons().glyph(PILLAR), "▌");
416        assert!(env.slide());
417        env.set_pillar_style(PillarStyle::Thin);
418        env.set_slide(false);
419        env.set_theme("amber");
420        assert_eq!(env.icons().glyph(PILLAR), "▎");
421        assert!(!env.slide());
422        let mut settings = crate::storage::Settings::in_memory();
423        settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
424        settings.set(crate::storage::Settings::SLIDE, true);
425        env.apply_settings(&settings);
426        assert_eq!(env.icons().glyph(PILLAR), "▌");
427        assert!(env.slide());
428    }
429
430    #[test]
431    fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
432        let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
433        let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
434        let dirs = AssetDirs {
435            locale_sources: vec![
436                ("app-en.toml".to_owned(), english.to_owned()),
437                ("app-tr.toml".to_owned(), turkish.to_owned()),
438                ("broken.toml".to_owned(), "[meta\n".to_owned()),
439            ],
440            ..AssetDirs::default()
441        };
442        let env = Env::load(&dirs).expect("nothing to read from disk");
443        let mut i18n = env.i18n().clone();
444        assert!(i18n.set_active("tr"));
445        assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
446        assert!(i18n.set_active("en"));
447        assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
448        assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
449        assert!(
450            env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
451            "{:?}",
452            env.diagnostics()
453        );
454    }
455
456    /// A theme, an icon set and a keymap an application would compile into its binary.
457    const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
458                               [colors]\naccent = \"#FF8800\"\n";
459    const BRAND_ICONS: &str =
460        "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
461    const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
462
463    /// Everything an application gives as text, and nothing on disk.
464    fn brand_sources() -> AssetDirs {
465        AssetDirs {
466            theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
467            icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
468            keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
469            ..AssetDirs::default()
470        }
471    }
472
473    fn chord(text: &str) -> crate::keymap::KeyChord {
474        text.parse().expect("a chord")
475    }
476
477    #[test]
478    fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
479        let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
480        assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
481        assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
482        env.set_theme("brand");
483        assert_eq!(env.theme().id(), "brand");
484        assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
485        env.set_glyph_mode(GlyphMode::Ascii);
486        assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
487        assert_eq!(
488            env.keymap().action_for(chord("ctrl+s")),
489            Some((crate::keymap::Scope::App, "save")),
490            "the keymap came from text"
491        );
492        assert_eq!(
493            env.keymap().action_for(chord("ctrl+q")),
494            Some((crate::keymap::Scope::Global, "quit")),
495            "the built-in keymap is still under it"
496        );
497    }
498
499    #[test]
500    fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
501        let missing = std::env::temp_dir().join("quvyta-not-installed");
502        let dirs = AssetDirs {
503            themes: Some(missing.join("themes")),
504            icons: Some(missing.join("icons")),
505            locales: Some(missing.join("locales")),
506            locale_sources: vec![(
507                "en.toml".to_owned(),
508                "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
509            )],
510            keymap: Some(missing.join("keymap.toml")),
511            ..brand_sources()
512        };
513        let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
514        env.set_theme("brand");
515        assert_eq!(env.theme().id(), "brand");
516        assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
517        assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
518        for file in ["themes", "icons", "locales", "keymap.toml"] {
519            assert!(
520                env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
521                "the unreadable {file} is reported: {:?}",
522                env.diagnostics()
523            );
524        }
525        let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
526        assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
527    }
528
529    #[test]
530    fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
531        let dirs = AssetDirs {
532            theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
533            icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
534            keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
535            ..AssetDirs::default()
536        };
537        let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
538        for file in ["brand.toml", "keymap.toml"] {
539            assert!(
540                env.diagnostics().iter().any(|problem| problem
541                    .location
542                    .as_ref()
543                    .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
544                "{file} is reported with file, line and column: {:?}",
545                env.diagnostics()
546            );
547        }
548        assert_eq!(env.theme().id(), "monochrome");
549        env.set_glyph_mode(GlyphMode::Unicode);
550        assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
551        assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
552        env.set_theme("brand");
553        assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
554    }
555
556    /// Looks names up in `vars` instead of the process environment.
557    fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
558        let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
559        move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
560    }
561
562    /// The built-in environment as `Env::load` leaves it for these variables.
563    fn env_with(variables: &[(&str, &str)]) -> Env {
564        let mut env = Env::builtin();
565        env.force_reduced_motion(forced_reduced_motion(vars(variables)));
566        env
567    }
568
569    fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
570        let mut settings = crate::storage::Settings::in_memory();
571        settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
572        settings
573    }
574
575    #[test]
576    fn reads_the_reduced_motion_variable() {
577        assert_eq!(forced_reduced_motion(vars(&[])), None);
578        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
579        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
580        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
581        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
582    }
583
584    #[test]
585    fn tells_whether_the_variable_decides() {
586        assert!(!Env::builtin().reduced_motion_forced());
587        assert!(!env_with(&[]).reduced_motion_forced());
588        assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
589        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
590        env.apply_settings(&saved_reduced_motion(false));
591        assert!(env.reduced_motion_forced() && env.reduced_motion());
592        let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
593        assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
594    }
595
596    #[test]
597    fn the_variable_wins_over_the_saved_setting_in_both_directions() {
598        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
599        env.apply_settings(&saved_reduced_motion(false));
600        assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
601        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
602        env.apply_settings(&saved_reduced_motion(true));
603        assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
604        let mut env = env_with(&[]);
605        env.apply_settings(&saved_reduced_motion(true));
606        assert!(env.reduced_motion(), "without the variable the saved setting decides");
607    }
608
609    #[test]
610    fn the_variable_wins_when_the_setting_was_applied_first() {
611        let mut env = Env::builtin();
612        env.apply_settings(&saved_reduced_motion(false));
613        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
614        assert!(env.reduced_motion());
615        let mut env = Env::builtin();
616        env.apply_settings(&saved_reduced_motion(true));
617        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
618        assert!(!env.reduced_motion());
619        let mut env = Env::builtin();
620        env.apply_settings(&saved_reduced_motion(true));
621        env.force_reduced_motion(forced_reduced_motion(vars(&[])));
622        assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
623    }
624
625    #[test]
626    fn the_variable_wins_over_settings_applied_as_commands_after_start() {
627        use crate::runtime::{App, Command, Harness};
628        use crate::widget::View;
629
630        struct Saved(crate::storage::Settings);
631        impl App for Saved {
632            type Msg = ();
633            fn update(&mut self, (): ()) -> Command<()> {
634                self.0.apply()
635            }
636            fn view(&self, _: &mut View<'_, ()>) {}
637        }
638
639        let mut h =
640            Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
641        h.send(());
642        assert!(h.env().reduced_motion());
643        let mut h =
644            Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
645        h.send(());
646        assert!(!h.env().reduced_motion());
647        let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
648        h.send(());
649        assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
650    }
651
652    #[test]
653    fn switches_theme_locale_and_icons() {
654        let mut env = Env::builtin();
655        assert_eq!(env.theme().id(), "monochrome");
656        env.set_theme("nordic");
657        assert_eq!(env.theme().id(), "nordic");
658        env.set_theme("missing");
659        assert_eq!(env.theme().id(), "monochrome");
660        assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
661        env.set_locale("tr");
662        assert_eq!(env.i18n().active(), "tr");
663        env.set_icon_mode(IconMode::Ascii);
664        assert_eq!(env.icons().glyph("check"), "v");
665        env.set_glyph_mode(GlyphMode::Unicode);
666        assert_eq!(env.icons().glyph("check"), "✓");
667    }
668}