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, icons and reduced motion the ecosystem's preferences
480    /// resolved; `QUVYTA_REDUCED_MOTION`, when set, still decides reduced motion.
481    pub(crate) fn apply_preferences(&mut self, preferences: &crate::storage::Preferences) {
482        self.set_theme(&preferences.theme().value);
483        self.set_locale(&preferences.language().value);
484        self.set_icon_mode(preferences.icons().value);
485        self.set_reduced_motion(preferences.reduced_motion().value);
486    }
487
488    fn rebuild_icons(&mut self) {
489        let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
490        if let Some(style) = self.pillar {
491            overrides.insert(PILLAR.to_owned(), style.glyphs());
492        }
493        self.icons = self.icon_sets.icons_with_animations(
494            self.theme.icon_set(),
495            &overrides,
496            self.theme.animation_overrides(),
497            self.glyph_mode,
498        );
499    }
500}
501
502/// What `QUVYTA_REDUCED_MOTION` forces: nothing when unset or empty, motion for `0`, reduced
503/// motion for any other value.
504/// Whether the variables an SSH server sets mark this session as remote: either of them set and
505/// not empty. `lookup` reads the process environment in an application and a table in tests.
506fn detect_remote(lookup: impl Fn(&str) -> Option<String>) -> bool {
507    ["SSH_CONNECTION", "SSH_TTY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
508}
509
510fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
511    lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
512}
513
514/// Lets text given for the same kind of file stand in for a path that cannot be read: with
515/// `has_source` the reason becomes a warning and loading carries on, without it the I/O error
516/// travels on, because then nothing would take the file's place.
517fn stand_in<T>(
518    read: io::Result<T>,
519    path: &Path,
520    has_source: bool,
521    diagnostics: &mut Vec<Diagnostic>,
522) -> io::Result<Option<T>> {
523    match read {
524        Ok(value) => Ok(Some(value)),
525        Err(error) if has_source => {
526            let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
527            diagnostics.push(Diagnostic::warning(
528                Some(Location::from_offset(name, "", 0)),
529                format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
530            ));
531            Ok(None)
532        }
533        Err(error) => Err(error),
534    }
535}
536
537/// The asset id of a file given as text: its stem, the way a directory names its files.
538fn source_id(file: &str) -> String {
539    Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
540}
541
542fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
543    let text = std::fs::read_to_string(file)?;
544    let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
545    Ok(Keymap::parse(name, &text, diagnostics))
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    #[test]
553    fn a_test_can_load_the_files_without_the_machines_language_and_region() {
554        let turkish = |name: &str| (name == "LANG").then(|| "tr_TR.UTF-8".to_owned());
555        let env = Env::load_with(&AssetDirs::default(), turkish).expect("the built-in files load");
556        assert_eq!(env.i18n().active(), "tr");
557        assert_eq!(env.i18n().first_weekday(), crate::date::Weekday::Monday, "Turkey starts the week on Monday");
558        let mut english = Env::load_with(&AssetDirs::default(), |_| None).expect("the built-in files load");
559        assert_eq!(english.i18n().active(), "en", "a machine with nothing set");
560        assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday, "English alone starts on Sunday");
561        english.set_locale("en");
562        assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday);
563    }
564
565    /// Every combination of the two variables an SSH server sets, with empty values among them.
566    /// The process environment itself is never changed: `detect_remote` is given a table, which
567    /// is what `Env::load` gives it in an application too.
568    #[test]
569    fn a_connection_is_remote_when_either_ssh_variable_carries_a_value() {
570        let cases = [
571            (None, None, false),
572            (Some(""), None, false),
573            (None, Some(""), false),
574            (Some(""), Some(""), false),
575            (Some("10.0.0.2 51150 10.0.0.9 22"), None, true),
576            (None, Some("/dev/pts/3"), true),
577            (Some("10.0.0.2 51150 10.0.0.9 22"), Some("/dev/pts/3"), true),
578            (Some(""), Some("/dev/pts/3"), true),
579            (Some("10.0.0.2 51150 10.0.0.9 22"), Some(""), true),
580        ];
581        for (connection, tty, remote) in cases {
582            let lookup = |name: &str| match name {
583                "SSH_CONNECTION" => connection.map(str::to_owned),
584                "SSH_TTY" => tty.map(str::to_owned),
585                _ => None,
586            };
587            assert_eq!(detect_remote(lookup), remote, "SSH_CONNECTION={connection:?} SSH_TTY={tty:?}");
588        }
589    }
590
591    /// `Env::load_with` reads the multiplexer and the override through the same lookup as the
592    /// rest, so a test decides them without touching the process environment.
593    #[test]
594    fn graphics_follow_the_multiplexer_and_the_override_the_lookup_gives() {
595        /// A 256-colour UTF-8 terminal with Unicode glyphs, where half blocks can be drawn, plus
596        /// `extra`.
597        fn terminal(extra: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
598            move |name: &str| {
599                let base = [("LANG", "en_US.UTF-8"), ("TERM", "xterm-256color"), ("QUVYTA_ICONS", "unicode")];
600                base.iter().chain(extra).find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned())
601            }
602        }
603        let mut env = Env::load_with(&AssetDirs::default(), terminal(&[])).expect("the built-in files load");
604        assert!(env.graphics_worth_asking());
605        env.set_terminal_graphics(Graphics::Kitty);
606        assert_eq!(env.graphics(), Graphics::Kitty, "outside a multiplexer the answer stands");
607
608        let in_tmux = terminal(&[("TMUX", "/tmp/tmux-1000/default,4242,0")]);
609        let mut env = Env::load_with(&AssetDirs::default(), in_tmux).expect("the built-in files load");
610        assert!(!env.graphics_worth_asking(), "tmux answers for the terminal, so it is not asked");
611        env.set_terminal_graphics(Graphics::Kitty);
612        assert_eq!(env.graphics(), Graphics::HalfBlock);
613
614        let forced = terminal(&[("QUVYTA_GRAPHICS", "sixel"), ("STY", "1234.pts-0.host")]);
615        let env = Env::load_with(&AssetDirs::default(), forced).expect("the built-in files load");
616        assert_eq!(env.graphics(), Graphics::Sixel, "the override wins over the multiplexer");
617
618        let unknown = terminal(&[("QUVYTA_GRAPHICS", "pixels")]);
619        let env = Env::load_with(&AssetDirs::default(), unknown).expect("the built-in files load");
620        assert_eq!(env.graphics(), Graphics::HalfBlock);
621        assert!(
622            env.diagnostics().iter().any(|problem| problem.to_string().contains("`pixels`")),
623            "{:?}",
624            env.diagnostics()
625        );
626    }
627
628    #[test]
629    fn the_session_is_asked_by_the_same_rule_the_environment_uses() {
630        let lookup = |name: &str| std::env::var(name).ok();
631        assert_eq!(Env::remote_session(), detect_remote(lookup), "no file is read, the variables alone decide");
632    }
633
634    #[test]
635    fn the_environment_of_tests_is_never_remote() {
636        assert!(!Env::builtin().remote(), "a test must draw the same wherever it runs");
637    }
638
639    #[test]
640    fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
641        let mut env = Env::builtin();
642        assert_eq!(env.icons().glyph(PILLAR), "▌");
643        assert!(env.slide());
644        env.set_pillar_style(PillarStyle::Thin);
645        env.set_slide(false);
646        env.set_theme("amber");
647        assert_eq!(env.icons().glyph(PILLAR), "▎");
648        assert!(!env.slide());
649        let mut settings = crate::storage::Settings::in_memory();
650        settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
651        settings.set(crate::storage::Settings::SLIDE, true);
652        env.apply_settings(&settings);
653        assert_eq!(env.icons().glyph(PILLAR), "▌");
654        assert!(env.slide());
655    }
656
657    #[test]
658    fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
659        let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
660        let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
661        let dirs = AssetDirs {
662            locale_sources: vec![
663                ("app-en.toml".to_owned(), english.to_owned()),
664                ("app-tr.toml".to_owned(), turkish.to_owned()),
665                ("broken.toml".to_owned(), "[meta\n".to_owned()),
666            ],
667            ..AssetDirs::default()
668        };
669        let env = Env::load(&dirs).expect("nothing to read from disk");
670        let mut i18n = env.i18n().clone();
671        assert!(i18n.set_active("tr"));
672        assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
673        assert!(i18n.set_active("en"));
674        assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
675        assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
676        assert!(
677            env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
678            "{:?}",
679            env.diagnostics()
680        );
681    }
682
683    /// A theme, an icon set and a keymap an application would compile into its binary.
684    const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
685                               [colors]\naccent = \"#FF8800\"\n";
686    const BRAND_ICONS: &str =
687        "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
688    const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
689
690    /// Everything an application gives as text, and nothing on disk.
691    fn brand_sources() -> AssetDirs {
692        AssetDirs {
693            theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
694            icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
695            keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
696            ..AssetDirs::default()
697        }
698    }
699
700    fn chord(text: &str) -> crate::keymap::KeyChord {
701        text.parse().expect("a chord")
702    }
703
704    #[test]
705    fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
706        let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
707        assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
708        assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
709        env.set_theme("brand");
710        assert_eq!(env.theme().id(), "brand");
711        assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
712        env.set_glyph_mode(GlyphMode::Ascii);
713        assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
714        assert_eq!(
715            env.keymap().action_for(chord("ctrl+s")),
716            Some((crate::keymap::Scope::App, "save")),
717            "the keymap came from text"
718        );
719        assert_eq!(
720            env.keymap().action_for(chord("ctrl+q")),
721            Some((crate::keymap::Scope::Global, "quit")),
722            "the built-in keymap is still under it"
723        );
724    }
725
726    #[test]
727    fn an_application_icon_is_found_in_every_theme_and_follows_the_icon_mode() {
728        let app = "[icons]\n\"category.internet\" = { nerd = \"I\", unicode = \"◎\", ascii = \"@\" }\n";
729        let mut dirs = brand_sources();
730        dirs.icon_sources.push(("app.toml".to_owned(), app.to_owned()));
731        let mut env = Env::load(&dirs).expect("nothing to read from disk");
732        env.set_icon_mode(IconMode::Nerd);
733        assert_eq!(env.icons().glyph("category.internet"), "I");
734        for theme in ["nordic", "amber", "brand"] {
735            env.set_theme(theme);
736            assert_eq!(env.theme().id(), theme);
737            env.set_icon_mode(IconMode::Unicode);
738            assert_eq!(env.icons().glyph("category.internet"), "◎", "{theme}");
739            env.set_icon_mode(IconMode::Ascii);
740            assert_eq!(env.icons().glyph("category.internet"), "@", "{theme}");
741        }
742        assert_eq!(env.icons().glyph("check"), "!", "the brand theme's own set still restyles what it names");
743    }
744
745    #[test]
746    fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
747        let missing = std::env::temp_dir().join("quvyta-not-installed");
748        let dirs = AssetDirs {
749            themes: Some(missing.join("themes")),
750            icons: Some(missing.join("icons")),
751            locales: Some(missing.join("locales")),
752            locale_sources: vec![(
753                "en.toml".to_owned(),
754                "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
755            )],
756            keymap: Some(missing.join("keymap.toml")),
757            ..brand_sources()
758        };
759        let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
760        env.set_theme("brand");
761        assert_eq!(env.theme().id(), "brand");
762        assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
763        assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
764        for file in ["themes", "icons", "locales", "keymap.toml"] {
765            assert!(
766                env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
767                "the unreadable {file} is reported: {:?}",
768                env.diagnostics()
769            );
770        }
771        let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
772        assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
773    }
774
775    #[test]
776    fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
777        let dirs = AssetDirs {
778            theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
779            icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
780            keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
781            ..AssetDirs::default()
782        };
783        let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
784        for file in ["brand.toml", "keymap.toml"] {
785            assert!(
786                env.diagnostics().iter().any(|problem| problem
787                    .location
788                    .as_ref()
789                    .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
790                "{file} is reported with file, line and column: {:?}",
791                env.diagnostics()
792            );
793        }
794        assert_eq!(env.theme().id(), "monochrome");
795        env.set_glyph_mode(GlyphMode::Unicode);
796        assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
797        assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
798        env.set_theme("brand");
799        assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
800    }
801
802    /// Looks names up in `vars` instead of the process environment.
803    fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
804        let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
805        move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
806    }
807
808    /// The built-in environment as `Env::load` leaves it for these variables.
809    fn env_with(variables: &[(&str, &str)]) -> Env {
810        let mut env = Env::builtin();
811        env.force_reduced_motion(forced_reduced_motion(vars(variables)));
812        env
813    }
814
815    fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
816        let mut settings = crate::storage::Settings::in_memory();
817        settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
818        settings
819    }
820
821    #[test]
822    fn reads_the_reduced_motion_variable() {
823        assert_eq!(forced_reduced_motion(vars(&[])), None);
824        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
825        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
826        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
827        assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
828    }
829
830    #[test]
831    fn tells_whether_the_variable_decides() {
832        assert!(!Env::builtin().reduced_motion_forced());
833        assert!(!env_with(&[]).reduced_motion_forced());
834        assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
835        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
836        env.apply_settings(&saved_reduced_motion(false));
837        assert!(env.reduced_motion_forced() && env.reduced_motion());
838        let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
839        assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
840    }
841
842    #[test]
843    fn the_variable_wins_over_the_saved_setting_in_both_directions() {
844        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
845        env.apply_settings(&saved_reduced_motion(false));
846        assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
847        let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
848        env.apply_settings(&saved_reduced_motion(true));
849        assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
850        let mut env = env_with(&[]);
851        env.apply_settings(&saved_reduced_motion(true));
852        assert!(env.reduced_motion(), "without the variable the saved setting decides");
853    }
854
855    #[test]
856    fn the_variable_wins_when_the_setting_was_applied_first() {
857        let mut env = Env::builtin();
858        env.apply_settings(&saved_reduced_motion(false));
859        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
860        assert!(env.reduced_motion());
861        let mut env = Env::builtin();
862        env.apply_settings(&saved_reduced_motion(true));
863        env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
864        assert!(!env.reduced_motion());
865        let mut env = Env::builtin();
866        env.apply_settings(&saved_reduced_motion(true));
867        env.force_reduced_motion(forced_reduced_motion(vars(&[])));
868        assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
869    }
870
871    #[test]
872    fn the_variable_wins_over_settings_applied_as_commands_after_start() {
873        use crate::runtime::{App, Command, Harness};
874        use crate::widget::View;
875
876        struct Saved(crate::storage::Settings);
877        impl App for Saved {
878            type Msg = ();
879            fn update(&mut self, (): ()) -> Command<()> {
880                self.0.apply()
881            }
882            fn view(&self, _: &mut View<'_, ()>) {}
883        }
884
885        let mut h =
886            Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
887        h.send(());
888        assert!(h.env().reduced_motion());
889        let mut h =
890            Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
891        h.send(());
892        assert!(!h.env().reduced_motion());
893        let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
894        h.send(());
895        assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
896    }
897
898    #[test]
899    fn switches_theme_locale_and_icons() {
900        let mut env = Env::builtin();
901        assert_eq!(env.theme().id(), "monochrome");
902        env.set_theme("nordic");
903        assert_eq!(env.theme().id(), "nordic");
904        env.set_theme("missing");
905        assert_eq!(env.theme().id(), "monochrome");
906        assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
907        env.set_locale("tr");
908        assert_eq!(env.i18n().active(), "tr");
909        env.set_icon_mode(IconMode::Ascii);
910        assert_eq!(env.icons().glyph("check"), "v");
911        env.set_glyph_mode(GlyphMode::Unicode);
912        assert_eq!(env.icons().glyph("check"), "✓");
913    }
914}