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