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    /// until [`Harness::set_remote`](crate::runtime::Harness::set_remote) says so.
267    ///
268    /// The runtime already uses it for the [`FrameLimit`](crate::runtime::FrameLimit) an
269    /// application does not set. An application reads it to spend less on a slow link: fewer
270    /// animations, smaller pictures, a plainer screen.
271    #[must_use]
272    pub fn remote(&self) -> bool {
273        self.remote
274    }
275
276    /// Whether this process runs in a remote session, by the rule [`Env::remote`] uses, without
277    /// loading an environment.
278    ///
279    /// It reads `SSH_CONNECTION` and `SSH_TTY` and nothing else, so it costs no file reads. An
280    /// application calls it before [`Runtime::run`](crate::runtime::Runtime::run), where no
281    /// `Env` is handed out yet, to choose what depends on the connection between frames, such as
282    /// the size a picture is decoded at. In a view, [`Env::remote`] gives the same answer, and
283    /// [`Harness::set_remote`](crate::runtime::Harness::set_remote) sets it in a test.
284    #[must_use]
285    pub fn remote_session() -> bool {
286        detect_remote(|name: &str| std::env::var(name).ok())
287    }
288
289    /// Draws as a remote session would, instead of what was detected; see
290    /// [`Harness::set_remote`](crate::runtime::Harness::set_remote).
291    pub(crate) fn set_remote(&mut self, remote: bool) {
292        self.remote = remote;
293    }
294
295    /// The way a picture can be drawn in this terminal.
296    ///
297    /// The runtime asks the terminal once, as it starts: a kitty graphics query and a request
298    /// for its device attributes, with a wait of 150 ms at most that the attributes end, so a
299    /// local terminal answers in milliseconds and starting never waits on the network. A kitty `OK` gives [`Graphics::Kitty`], attributes
300    /// that list sixel give [`Graphics::Sixel`], and anything else, silence included, gives
301    /// [`Graphics::HalfBlock`]. A kitty `OK` that arrives after the wait, over a very slow link,
302    /// still gives [`Graphics::Kitty`] from then on. The answers never reach the
303    /// application as keys. The terminal is not asked when its answer could not change the
304    /// result, and never when it is not a terminal.
305    ///
306    /// Then the environment has its say:
307    ///
308    /// - 16 colours or ASCII glyphs give [`Graphics::None`]: no picture is drawn.
309    /// - Inside tmux or GNU screen (`TMUX` or `STY` set and not empty) kitty and sixel become
310    ///   half blocks, because the multiplexer does not pass them through.
311    /// - The `QUVYTA_GRAPHICS` environment variable, set to `kitty`, `sixel`, `halfblock` or
312    ///   `none`, wins over all of it, for a terminal the probe misjudges or a person who wants
313    ///   something else. Any other value is ignored and becomes a [diagnostic](Env::diagnostics).
314    ///
315    /// [`Env::builtin`], the environment of tests, asks nothing and gives half blocks; see
316    /// [`Harness::set_graphics`](crate::runtime::Harness::set_graphics) for the others.
317    #[must_use]
318    pub fn graphics(&self) -> Graphics {
319        self.graphics.resolve(self.depth, self.glyph_mode)
320    }
321
322    /// Records what the terminal answered to the graphics probe; [`Env::graphics`] still applies
323    /// its rules to it.
324    pub(crate) fn set_terminal_graphics(&mut self, answer: Graphics) {
325        self.graphics.answer = answer;
326    }
327
328    /// Whether the terminal's answer could change [`Env::graphics`], so the probe is worth its
329    /// round trip.
330    pub(crate) fn graphics_worth_asking(&self) -> bool {
331        self.graphics.worth_asking(self.depth)
332    }
333
334    /// Draws as a terminal of `depth` would, instead of the depth that was detected. Lets a test
335    /// see what a widget looks like where colours are scarce; see
336    /// [`Harness::set_depth`](crate::runtime::Harness::set_depth).
337    pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
338        self.depth = depth;
339    }
340
341    /// Whether animations are reduced: layers appear at once, nothing breathes or spins.
342    ///
343    /// The `QUVYTA_REDUCED_MOTION` environment variable, read by [`Env::load`], decides when it
344    /// is set: `0` keeps motion, any other non-empty value reduces it. It wins over a saved
345    /// `reduced-motion` setting and over `Command::set_reduced_motion`, because a choice made in
346    /// the user's shell is the stronger signal, the way accessibility overrides work. Unset or
347    /// empty, the saved setting and the application decide.
348    #[must_use]
349    pub fn reduced_motion(&self) -> bool {
350        self.reduced_motion
351    }
352
353    /// Whether the `QUVYTA_REDUCED_MOTION` environment variable decides reduced motion, so neither
354    /// a saved setting nor `Command::set_reduced_motion` can change it. A settings screen uses it to
355    /// show its reduced-motion switch as decided by the environment instead of letting the switch
356    /// snap back when pressed.
357    #[must_use]
358    pub fn reduced_motion_forced(&self) -> bool {
359        self.forced_reduced_motion.is_some()
360    }
361
362    /// Lets `forced` decide reduced motion from now on, whatever is set or saved later; `None`
363    /// leaves the decision to settings and commands.
364    pub(crate) fn force_reduced_motion(&mut self, forced: Option<bool>) {
365        self.forced_reduced_motion = forced;
366        if let Some(reduced) = forced {
367            self.reduced_motion = reduced;
368        }
369    }
370
371    /// Reduces motion or brings it back, unless the environment variable already decided.
372    pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
373        self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
374    }
375
376    /// The pillar the user chose over the theme's, if any.
377    #[must_use]
378    pub fn pillar_style(&self) -> Option<PillarStyle> {
379        self.pillar
380    }
381
382    pub(crate) fn set_pillar_style(&mut self, style: PillarStyle) {
383        self.pillar = Some(style);
384        self.rebuild_icons();
385    }
386
387    /// Whether list structures (lists, menus, trees, tables, tab strips and rails, dropdown options)
388    /// slide the leading text of hovered and selected rows one cell; buttons and fields never do.
389    /// The user's choice when made, otherwise the theme's `motion.slide`.
390    #[must_use]
391    pub fn slide(&self) -> bool {
392        self.slide.unwrap_or(self.theme.motion().slide)
393    }
394
395    pub(crate) fn set_slide(&mut self, slide: bool) {
396        self.slide = Some(slide);
397    }
398
399    /// Problems found in theme, icon, locale and keymap files, including theme switches that
400    /// fell back to the default.
401    #[must_use]
402    pub fn diagnostics(&self) -> &[Diagnostic] {
403        &self.diagnostics
404    }
405
406    pub(crate) fn i18n_arc(&self) -> Arc<I18n> {
407        Arc::clone(&self.i18n)
408    }
409
410    /// Activates theme `id`; falls back to the built-in default and records why when it
411    /// cannot be loaded.
412    pub(crate) fn set_theme(&mut self, id: &str) {
413        let (theme, diagnostics) = self.themes.resolve_or_default(id);
414        self.diagnostics.extend(diagnostics);
415        self.theme = theme;
416        self.rebuild_icons();
417    }
418
419    pub(crate) fn set_locale(&mut self, code: &str) {
420        let mut i18n = I18n::clone(&self.i18n);
421        if i18n.select(code) {
422            self.i18n = Arc::new(i18n);
423        } else {
424            self.diagnostics.push(Diagnostic::warning(None, format!("unknown locale `{code}`")));
425        }
426    }
427
428    pub(crate) fn set_region(&mut self, region: Option<&str>) {
429        let mut i18n = I18n::clone(&self.i18n);
430        if i18n.set_region(region) {
431            self.i18n = Arc::new(i18n);
432        } else {
433            let region = region.unwrap_or_default();
434            self.diagnostics.push(Diagnostic::warning(None, format!("unknown region `{region}`")));
435        }
436    }
437
438    pub(crate) fn set_icon_mode(&mut self, mode: IconMode) {
439        let lookup = |name: &str| std::env::var(name).ok();
440        self.icon_mode = mode;
441        self.glyph_mode = match mode {
442            IconMode::Nerd => GlyphMode::Nerd,
443            IconMode::Unicode => GlyphMode::Unicode,
444            IconMode::Ascii => GlyphMode::Ascii,
445            IconMode::Auto => detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup)),
446        };
447        self.rebuild_icons();
448    }
449
450    /// Sets glyph mode directly; used by tests to render every mode.
451    pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
452        self.glyph_mode = mode;
453        self.rebuild_icons();
454    }
455
456    /// Switches to the theme, language, icon mode, reduced motion, pillar and slide saved in
457    /// `settings`; `QUVYTA_REDUCED_MOTION`, when set, still decides reduced motion.
458    pub(crate) fn apply_settings(&mut self, settings: &crate::storage::Settings) {
459        if let Some(theme) = settings.theme() {
460            self.set_theme(&theme);
461        }
462        if let Some(language) = settings.language() {
463            self.set_locale(&language);
464        }
465        if let Some(mode) = settings.icon_mode() {
466            self.set_icon_mode(mode);
467        }
468        if let Some(reduced) = settings.reduced_motion() {
469            self.set_reduced_motion(reduced);
470        }
471        if let Some(style) = settings.pillar_style() {
472            self.set_pillar_style(style);
473        }
474        if let Some(slide) = settings.slide() {
475            self.set_slide(slide);
476        }
477    }
478
479    /// Switches to the language, theme and icons the ecosystem's preferences resolved.
480    pub(crate) fn apply_preferences(&mut self, preferences: &crate::storage::Preferences) {
481        self.set_theme(&preferences.theme().value);
482        self.set_locale(&preferences.language().value);
483        self.set_icon_mode(preferences.icons().value);
484    }
485
486    fn rebuild_icons(&mut self) {
487        let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
488        if let Some(style) = self.pillar {
489            overrides.insert(PILLAR.to_owned(), style.glyphs());
490        }
491        self.icons = self.icon_sets.icons_with_animations(
492            self.theme.icon_set(),
493            &overrides,
494            self.theme.animation_overrides(),
495            self.glyph_mode,
496        );
497    }
498}
499
500/// What `QUVYTA_REDUCED_MOTION` forces: nothing when unset or empty, motion for `0`, reduced
501/// motion for any other value.
502/// Whether the variables an SSH server sets mark this session as remote: either of them set and
503/// not empty. `lookup` reads the process environment in an application and a table in tests.
504fn detect_remote(lookup: impl Fn(&str) -> Option<String>) -> bool {
505    ["SSH_CONNECTION", "SSH_TTY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
506}
507
508fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
509    lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
510}
511
512/// Lets text given for the same kind of file stand in for a path that cannot be read: with
513/// `has_source` the reason becomes a warning and loading carries on, without it the I/O error
514/// travels on, because then nothing would take the file's place.
515fn stand_in<T>(
516    read: io::Result<T>,
517    path: &Path,
518    has_source: bool,
519    diagnostics: &mut Vec<Diagnostic>,
520) -> io::Result<Option<T>> {
521    match read {
522        Ok(value) => Ok(Some(value)),
523        Err(error) if has_source => {
524            let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
525            diagnostics.push(Diagnostic::warning(
526                Some(Location::from_offset(name, "", 0)),
527                format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
528            ));
529            Ok(None)
530        }
531        Err(error) => Err(error),
532    }
533}
534
535/// The asset id of a file given as text: its stem, the way a directory names its files.
536fn source_id(file: &str) -> String {
537    Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
538}
539
540fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
541    let text = std::fs::read_to_string(file)?;
542    let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
543    Ok(Keymap::parse(name, &text, diagnostics))
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    #[test]
551    fn a_test_can_load_the_files_without_the_machines_language_and_region() {
552        let turkish = |name: &str| (name == "LANG").then(|| "tr_TR.UTF-8".to_owned());
553        let env = Env::load_with(&AssetDirs::default(), turkish).expect("the built-in files load");
554        assert_eq!(env.i18n().active(), "tr");
555        assert_eq!(env.i18n().first_weekday(), crate::date::Weekday::Monday, "Turkey starts the week on Monday");
556        let mut english = Env::load_with(&AssetDirs::default(), |_| None).expect("the built-in files load");
557        assert_eq!(english.i18n().active(), "en", "a machine with nothing set");
558        assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday, "English alone starts on Sunday");
559        english.set_locale("en");
560        assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday);
561    }
562
563    /// Every combination of the two variables an SSH server sets, with empty values among them.
564    /// The process environment itself is never changed: `detect_remote` is given a table, which
565    /// is what `Env::load` gives it in an application too.
566    #[test]
567    fn a_connection_is_remote_when_either_ssh_variable_carries_a_value() {
568        let cases = [
569            (None, None, false),
570            (Some(""), None, false),
571            (None, Some(""), false),
572            (Some(""), Some(""), false),
573            (Some("10.0.0.2 51150 10.0.0.9 22"), None, true),
574            (None, Some("/dev/pts/3"), true),
575            (Some("10.0.0.2 51150 10.0.0.9 22"), Some("/dev/pts/3"), true),
576            (Some(""), Some("/dev/pts/3"), true),
577            (Some("10.0.0.2 51150 10.0.0.9 22"), Some(""), true),
578        ];
579        for (connection, tty, remote) in cases {
580            let lookup = |name: &str| match name {
581                "SSH_CONNECTION" => connection.map(str::to_owned),
582                "SSH_TTY" => tty.map(str::to_owned),
583                _ => None,
584            };
585            assert_eq!(detect_remote(lookup), remote, "SSH_CONNECTION={connection:?} SSH_TTY={tty:?}");
586        }
587    }
588
589    /// `Env::load_with` reads the multiplexer and the override through the same lookup as the
590    /// rest, so a test decides them without touching the process environment.
591    #[test]
592    fn graphics_follow_the_multiplexer_and_the_override_the_lookup_gives() {
593        /// A 256-colour UTF-8 terminal with Unicode glyphs, where half blocks can be drawn, plus
594        /// `extra`.
595        fn terminal(extra: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
596            move |name: &str| {
597                let base = [("LANG", "en_US.UTF-8"), ("TERM", "xterm-256color"), ("QUVYTA_ICONS", "unicode")];
598                base.iter().chain(extra).find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned())
599            }
600        }
601        let mut env = Env::load_with(&AssetDirs::default(), terminal(&[])).expect("the built-in files load");
602        assert!(env.graphics_worth_asking());
603        env.set_terminal_graphics(Graphics::Kitty);
604        assert_eq!(env.graphics(), Graphics::Kitty, "outside a multiplexer the answer stands");
605
606        let in_tmux = terminal(&[("TMUX", "/tmp/tmux-1000/default,4242,0")]);
607        let mut env = Env::load_with(&AssetDirs::default(), in_tmux).expect("the built-in files load");
608        assert!(!env.graphics_worth_asking(), "tmux answers for the terminal, so it is not asked");
609        env.set_terminal_graphics(Graphics::Kitty);
610        assert_eq!(env.graphics(), Graphics::HalfBlock);
611
612        let forced = terminal(&[("QUVYTA_GRAPHICS", "sixel"), ("STY", "1234.pts-0.host")]);
613        let env = Env::load_with(&AssetDirs::default(), forced).expect("the built-in files load");
614        assert_eq!(env.graphics(), Graphics::Sixel, "the override wins over the multiplexer");
615
616        let unknown = terminal(&[("QUVYTA_GRAPHICS", "pixels")]);
617        let env = Env::load_with(&AssetDirs::default(), unknown).expect("the built-in files load");
618        assert_eq!(env.graphics(), Graphics::HalfBlock);
619        assert!(
620            env.diagnostics().iter().any(|problem| problem.to_string().contains("`pixels`")),
621            "{:?}",
622            env.diagnostics()
623        );
624    }
625
626    #[test]
627    fn the_session_is_asked_by_the_same_rule_the_environment_uses() {
628        let lookup = |name: &str| std::env::var(name).ok();
629        assert_eq!(Env::remote_session(), detect_remote(lookup), "no file is read, the variables alone decide");
630    }
631
632    #[test]
633    fn the_environment_of_tests_is_never_remote() {
634        assert!(!Env::builtin().remote(), "a test must draw the same wherever it runs");
635    }
636
637    #[test]
638    fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
639        let mut env = Env::builtin();
640        assert_eq!(env.icons().glyph(PILLAR), "▌");
641        assert!(env.slide());
642        env.set_pillar_style(PillarStyle::Thin);
643        env.set_slide(false);
644        env.set_theme("amber");
645        assert_eq!(env.icons().glyph(PILLAR), "▎");
646        assert!(!env.slide());
647        let mut settings = crate::storage::Settings::in_memory();
648        settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
649        settings.set(crate::storage::Settings::SLIDE, true);
650        env.apply_settings(&settings);
651        assert_eq!(env.icons().glyph(PILLAR), "▌");
652        assert!(env.slide());
653    }
654
655    #[test]
656    fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
657        let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
658        let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
659        let dirs = AssetDirs {
660            locale_sources: vec![
661                ("app-en.toml".to_owned(), english.to_owned()),
662                ("app-tr.toml".to_owned(), turkish.to_owned()),
663                ("broken.toml".to_owned(), "[meta\n".to_owned()),
664            ],
665            ..AssetDirs::default()
666        };
667        let env = Env::load(&dirs).expect("nothing to read from disk");
668        let mut i18n = env.i18n().clone();
669        assert!(i18n.set_active("tr"));
670        assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
671        assert!(i18n.set_active("en"));
672        assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
673        assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
674        assert!(
675            env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
676            "{:?}",
677            env.diagnostics()
678        );
679    }
680
681    /// A theme, an icon set and a keymap an application would compile into its binary.
682    const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
683                               [colors]\naccent = \"#FF8800\"\n";
684    const BRAND_ICONS: &str =
685        "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
686    const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
687
688    /// Everything an application gives as text, and nothing on disk.
689    fn brand_sources() -> AssetDirs {
690        AssetDirs {
691            theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
692            icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
693            keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
694            ..AssetDirs::default()
695        }
696    }
697
698    fn chord(text: &str) -> crate::keymap::KeyChord {
699        text.parse().expect("a chord")
700    }
701
702    #[test]
703    fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
704        let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
705        assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
706        assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
707        env.set_theme("brand");
708        assert_eq!(env.theme().id(), "brand");
709        assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
710        env.set_glyph_mode(GlyphMode::Ascii);
711        assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
712        assert_eq!(
713            env.keymap().action_for(chord("ctrl+s")),
714            Some((crate::keymap::Scope::App, "save")),
715            "the keymap came from text"
716        );
717        assert_eq!(
718            env.keymap().action_for(chord("ctrl+q")),
719            Some((crate::keymap::Scope::Global, "quit")),
720            "the built-in keymap is still under it"
721        );
722    }
723
724    #[test]
725    fn an_application_icon_is_found_in_every_theme_and_follows_the_icon_mode() {
726        let app = "[icons]\n\"category.internet\" = { nerd = \"I\", unicode = \"◎\", ascii = \"@\" }\n";
727        let mut dirs = brand_sources();
728        dirs.icon_sources.push(("app.toml".to_owned(), app.to_owned()));
729        let mut env = Env::load(&dirs).expect("nothing to read from disk");
730        env.set_icon_mode(IconMode::Nerd);
731        assert_eq!(env.icons().glyph("category.internet"), "I");
732        for theme in ["nordic", "amber", "brand"] {
733            env.set_theme(theme);
734            assert_eq!(env.theme().id(), theme);
735            env.set_icon_mode(IconMode::Unicode);
736            assert_eq!(env.icons().glyph("category.internet"), "◎", "{theme}");
737            env.set_icon_mode(IconMode::Ascii);
738            assert_eq!(env.icons().glyph("category.internet"), "@", "{theme}");
739        }
740        assert_eq!(env.icons().glyph("check"), "!", "the brand theme's own set still restyles what it names");
741    }
742
743    #[test]
744    fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
745        let missing = std::env::temp_dir().join("quvyta-not-installed");
746        let dirs = AssetDirs {
747            themes: Some(missing.join("themes")),
748            icons: Some(missing.join("icons")),
749            locales: Some(missing.join("locales")),
750            locale_sources: vec![(
751                "en.toml".to_owned(),
752                "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
753            )],
754            keymap: Some(missing.join("keymap.toml")),
755            ..brand_sources()
756        };
757        let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
758        env.set_theme("brand");
759        assert_eq!(env.theme().id(), "brand");
760        assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
761        assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
762        for file in ["themes", "icons", "locales", "keymap.toml"] {
763            assert!(
764                env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
765                "the unreadable {file} is reported: {:?}",
766                env.diagnostics()
767            );
768        }
769        let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
770        assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
771    }
772
773    #[test]
774    fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
775        let dirs = AssetDirs {
776            theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
777            icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
778            keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
779            ..AssetDirs::default()
780        };
781        let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
782        for file in ["brand.toml", "keymap.toml"] {
783            assert!(
784                env.diagnostics().iter().any(|problem| problem
785                    .location
786                    .as_ref()
787                    .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
788                "{file} is reported with file, line and column: {:?}",
789                env.diagnostics()
790            );
791        }
792        assert_eq!(env.theme().id(), "monochrome");
793        env.set_glyph_mode(GlyphMode::Unicode);
794        assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
795        assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
796        env.set_theme("brand");
797        assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
798    }
799
800    /// Looks names up in `vars` instead of the process environment.
801    fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
802        let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
803        move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
804    }
805
806    /// The built-in environment as `Env::load` leaves it for these variables.
807    fn env_with(variables: &[(&str, &str)]) -> Env {
808        let mut env = Env::builtin();
809        env.force_reduced_motion(forced_reduced_motion(vars(variables)));
810        env
811    }
812
813    fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
814        let mut settings = crate::storage::Settings::in_memory();
815        settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
816        settings
817    }
818
819    #[test]
820    fn reads_the_reduced_motion_variable() {
821        assert_eq!(forced_reduced_motion(vars(&[])), None);
822        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
823        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
824        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
825        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
826    }
827
828    #[test]
829    fn tells_whether_the_variable_decides() {
830        assert!(!Env::builtin().reduced_motion_forced());
831        assert!(!env_with(&[]).reduced_motion_forced());
832        assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
833        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
834        env.apply_settings(&saved_reduced_motion(false));
835        assert!(env.reduced_motion_forced() && env.reduced_motion());
836        let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
837        assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
838    }
839
840    #[test]
841    fn the_variable_wins_over_the_saved_setting_in_both_directions() {
842        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
843        env.apply_settings(&saved_reduced_motion(false));
844        assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
845        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
846        env.apply_settings(&saved_reduced_motion(true));
847        assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
848        let mut env = env_with(&[]);
849        env.apply_settings(&saved_reduced_motion(true));
850        assert!(env.reduced_motion(), "without the variable the saved setting decides");
851    }
852
853    #[test]
854    fn the_variable_wins_when_the_setting_was_applied_first() {
855        let mut env = Env::builtin();
856        env.apply_settings(&saved_reduced_motion(false));
857        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
858        assert!(env.reduced_motion());
859        let mut env = Env::builtin();
860        env.apply_settings(&saved_reduced_motion(true));
861        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
862        assert!(!env.reduced_motion());
863        let mut env = Env::builtin();
864        env.apply_settings(&saved_reduced_motion(true));
865        env.force_reduced_motion(forced_reduced_motion(vars(&[])));
866        assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
867    }
868
869    #[test]
870    fn the_variable_wins_over_settings_applied_as_commands_after_start() {
871        use crate::runtime::{App, Command, Harness};
872        use crate::widget::View;
873
874        struct Saved(crate::storage::Settings);
875        impl App for Saved {
876            type Msg = ();
877            fn update(&mut self, (): ()) -> Command<()> {
878                self.0.apply()
879            }
880            fn view(&self, _: &mut View<'_, ()>) {}
881        }
882
883        let mut h =
884            Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
885        h.send(());
886        assert!(h.env().reduced_motion());
887        let mut h =
888            Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
889        h.send(());
890        assert!(!h.env().reduced_motion());
891        let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
892        h.send(());
893        assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
894    }
895
896    #[test]
897    fn switches_theme_locale_and_icons() {
898        let mut env = Env::builtin();
899        assert_eq!(env.theme().id(), "monochrome");
900        env.set_theme("nordic");
901        assert_eq!(env.theme().id(), "nordic");
902        env.set_theme("missing");
903        assert_eq!(env.theme().id(), "monochrome");
904        assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
905        env.set_locale("tr");
906        assert_eq!(env.i18n().active(), "tr");
907        env.set_icon_mode(IconMode::Ascii);
908        assert_eq!(env.icons().glyph("check"), "v");
909        env.set_glyph_mode(GlyphMode::Unicode);
910        assert_eq!(env.icons().glyph("check"), "✓");
911    }
912}