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