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