1use std::collections::BTreeMap;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use crate::color::ColorDepth;
10use crate::diagnostics::{Diagnostic, Location};
11use crate::i18n::I18n;
12use crate::icons::{
13 GlyphMode, IconMode, IconSetRegistry, Icons, PILLAR, PillarStyle, default_font_dirs, detect_glyph_mode,
14};
15use crate::keymap::Keymap;
16use crate::theme::{Theme, ThemeRegistry};
17
18#[derive(Debug, Clone, Default)]
26pub struct AssetDirs {
27 pub themes: Option<PathBuf>,
29 pub theme_sources: Vec<(String, String)>,
32 pub icons: Option<PathBuf>,
34 pub icon_sources: Vec<(String, String)>,
38 pub locales: Option<PathBuf>,
40 pub locale_sources: Vec<(String, String)>,
44 pub keymap: Option<PathBuf>,
46 pub keymap_source: Option<(String, String)>,
49}
50
51#[derive(Debug, Clone)]
53pub struct Env {
54 themes: ThemeRegistry,
55 theme: Theme,
56 icon_sets: IconSetRegistry,
57 icon_mode: IconMode,
58 glyph_mode: GlyphMode,
59 icons: Icons,
60 i18n: Arc<I18n>,
61 keymap: Keymap,
62 depth: ColorDepth,
63 reduced_motion: bool,
64 forced_reduced_motion: Option<bool>,
66 pillar: Option<PillarStyle>,
67 slide: Option<bool>,
68 remote: bool,
69 diagnostics: Vec<Diagnostic>,
70}
71
72impl Env {
73 #[must_use]
76 pub fn builtin() -> Self {
77 let themes = ThemeRegistry::builtin();
78 let (theme, _) = themes.resolve_or_default("monochrome");
79 let icon_sets = IconSetRegistry::builtin();
80 let icons = icon_sets.icons(theme.icon_set(), theme.icon_overrides(), GlyphMode::Unicode);
81 Self {
82 themes,
83 theme,
84 icon_sets,
85 icon_mode: IconMode::Unicode,
86 glyph_mode: GlyphMode::Unicode,
87 icons,
88 i18n: Arc::new(I18n::builtin()),
89 keymap: Keymap::builtin(),
90 depth: ColorDepth::TrueColor,
91 reduced_motion: false,
92 forced_reduced_motion: None,
93 pillar: None,
94 slide: None,
95 remote: false,
96 diagnostics: Vec::new(),
97 }
98 }
99
100 pub fn load(dirs: &AssetDirs) -> io::Result<Self> {
110 let lookup = |name: &str| std::env::var(name).ok();
111 let mut env = Self::builtin();
112 if let Some(dir) = &dirs.themes {
113 let read = env.themes.load_dir(dir);
114 stand_in(read, dir, !dirs.theme_sources.is_empty(), &mut env.diagnostics)?;
115 }
116 for (file, text) in &dirs.theme_sources {
117 env.themes.add_source(&source_id(file), file, text);
118 }
119 if let Some(dir) = &dirs.icons {
120 let read = env.icon_sets.load_dir(dir);
121 stand_in(read, dir, !dirs.icon_sources.is_empty(), &mut env.diagnostics)?;
122 }
123 for (file, text) in &dirs.icon_sources {
124 env.icon_sets.add_source(&source_id(file), file, text);
125 }
126 let mut i18n = I18n::builtin();
127 if let Some(dir) = &dirs.locales {
128 let read = i18n.load_dir(dir);
129 stand_in(read, dir, !dirs.locale_sources.is_empty(), &mut env.diagnostics)?;
130 }
131 for (file, text) in &dirs.locale_sources {
132 i18n.add_source(file, text);
133 }
134 if let Some(code) = i18n.detect(lookup) {
135 i18n.set_active(&code);
136 }
137 i18n.set_region(i18n.detect_region(lookup).as_deref());
138 if let Some(file) = &dirs.keymap {
139 let read = load_keymap(file, &mut env.diagnostics);
140 let has_source = dirs.keymap_source.is_some();
141 if let Some(keymap) = stand_in(read, file, has_source, &mut env.diagnostics)? {
142 env.keymap.overlay(&keymap);
143 }
144 }
145 if let Some((file, text)) = &dirs.keymap_source {
146 let keymap = Keymap::parse(file, text, &mut env.diagnostics);
147 env.keymap.overlay(&keymap);
148 }
149 env.diagnostics.extend(env.themes.diagnostics().iter().cloned());
150 env.diagnostics.extend(env.icon_sets.diagnostics().iter().cloned());
151 env.diagnostics.extend(i18n.diagnostics().iter().cloned());
152 env.diagnostics.extend(env.keymap.conflicts());
153 env.i18n = Arc::new(i18n);
154 env.depth = ColorDepth::detect(lookup);
155 env.force_reduced_motion(forced_reduced_motion(lookup));
156 env.icon_mode = IconMode::Auto;
157 env.remote = detect_remote(lookup);
158 env.glyph_mode = detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup));
159 env.rebuild_icons();
160 Ok(env)
161 }
162
163 #[must_use]
165 pub fn theme(&self) -> &Theme {
166 &self.theme
167 }
168
169 #[must_use]
171 pub fn themes(&self) -> Vec<(String, String)> {
172 self.themes.list()
173 }
174
175 #[must_use]
178 pub fn icon_sets(&self) -> Vec<(String, String)> {
179 self.icon_sets.list()
180 }
181
182 #[must_use]
184 pub fn icons(&self) -> &Icons {
185 &self.icons
186 }
187
188 #[must_use]
190 pub fn icon_mode(&self) -> IconMode {
191 self.icon_mode
192 }
193
194 #[must_use]
196 pub fn glyph_mode(&self) -> GlyphMode {
197 self.glyph_mode
198 }
199
200 #[must_use]
202 pub fn i18n(&self) -> &I18n {
203 &self.i18n
204 }
205
206 #[must_use]
208 pub fn keymap(&self) -> &Keymap {
209 &self.keymap
210 }
211
212 pub fn keymap_mut(&mut self) -> &mut Keymap {
215 &mut self.keymap
216 }
217
218 #[must_use]
220 pub fn depth(&self) -> ColorDepth {
221 self.depth
222 }
223
224 #[must_use]
236 pub fn remote(&self) -> bool {
237 self.remote
238 }
239
240 pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
244 self.depth = depth;
245 }
246
247 #[must_use]
255 pub fn reduced_motion(&self) -> bool {
256 self.reduced_motion
257 }
258
259 #[must_use]
264 pub fn reduced_motion_forced(&self) -> bool {
265 self.forced_reduced_motion.is_some()
266 }
267
268 pub(crate) fn force_reduced_motion(&mut self, forced: Option<bool>) {
271 self.forced_reduced_motion = forced;
272 if let Some(reduced) = forced {
273 self.reduced_motion = reduced;
274 }
275 }
276
277 pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
279 self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
280 }
281
282 #[must_use]
284 pub fn pillar_style(&self) -> Option<PillarStyle> {
285 self.pillar
286 }
287
288 pub(crate) fn set_pillar_style(&mut self, style: PillarStyle) {
289 self.pillar = Some(style);
290 self.rebuild_icons();
291 }
292
293 #[must_use]
297 pub fn slide(&self) -> bool {
298 self.slide.unwrap_or(self.theme.motion().slide)
299 }
300
301 pub(crate) fn set_slide(&mut self, slide: bool) {
302 self.slide = Some(slide);
303 }
304
305 #[must_use]
308 pub fn diagnostics(&self) -> &[Diagnostic] {
309 &self.diagnostics
310 }
311
312 pub(crate) fn i18n_arc(&self) -> Arc<I18n> {
313 Arc::clone(&self.i18n)
314 }
315
316 pub(crate) fn set_theme(&mut self, id: &str) {
319 let (theme, diagnostics) = self.themes.resolve_or_default(id);
320 self.diagnostics.extend(diagnostics);
321 self.theme = theme;
322 self.rebuild_icons();
323 }
324
325 pub(crate) fn set_locale(&mut self, code: &str) {
326 let mut i18n = I18n::clone(&self.i18n);
327 if i18n.select(code) {
328 self.i18n = Arc::new(i18n);
329 } else {
330 self.diagnostics.push(Diagnostic::warning(None, format!("unknown locale `{code}`")));
331 }
332 }
333
334 pub(crate) fn set_region(&mut self, region: Option<&str>) {
335 let mut i18n = I18n::clone(&self.i18n);
336 if i18n.set_region(region) {
337 self.i18n = Arc::new(i18n);
338 } else {
339 let region = region.unwrap_or_default();
340 self.diagnostics.push(Diagnostic::warning(None, format!("unknown region `{region}`")));
341 }
342 }
343
344 pub(crate) fn set_icon_mode(&mut self, mode: IconMode) {
345 let lookup = |name: &str| std::env::var(name).ok();
346 self.icon_mode = mode;
347 self.glyph_mode = match mode {
348 IconMode::Nerd => GlyphMode::Nerd,
349 IconMode::Unicode => GlyphMode::Unicode,
350 IconMode::Ascii => GlyphMode::Ascii,
351 IconMode::Auto => detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup)),
352 };
353 self.rebuild_icons();
354 }
355
356 pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
358 self.glyph_mode = mode;
359 self.rebuild_icons();
360 }
361
362 pub(crate) fn apply_settings(&mut self, settings: &crate::storage::Settings) {
365 if let Some(theme) = settings.theme() {
366 self.set_theme(&theme);
367 }
368 if let Some(language) = settings.language() {
369 self.set_locale(&language);
370 }
371 if let Some(mode) = settings.icon_mode() {
372 self.set_icon_mode(mode);
373 }
374 if let Some(reduced) = settings.reduced_motion() {
375 self.set_reduced_motion(reduced);
376 }
377 if let Some(style) = settings.pillar_style() {
378 self.set_pillar_style(style);
379 }
380 if let Some(slide) = settings.slide() {
381 self.set_slide(slide);
382 }
383 }
384
385 pub(crate) fn apply_preferences(&mut self, preferences: &crate::storage::Preferences) {
387 self.set_theme(&preferences.theme().value);
388 self.set_locale(&preferences.language().value);
389 self.set_icon_mode(preferences.icons().value);
390 }
391
392 fn rebuild_icons(&mut self) {
393 let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
394 if let Some(style) = self.pillar {
395 overrides.insert(PILLAR.to_owned(), style.glyphs());
396 }
397 self.icons = self.icon_sets.icons_with_animations(
398 self.theme.icon_set(),
399 &overrides,
400 self.theme.animation_overrides(),
401 self.glyph_mode,
402 );
403 }
404}
405
406fn detect_remote(lookup: impl Fn(&str) -> Option<String>) -> bool {
411 ["SSH_CONNECTION", "SSH_TTY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
412}
413
414fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
415 lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
416}
417
418fn stand_in<T>(
422 read: io::Result<T>,
423 path: &Path,
424 has_source: bool,
425 diagnostics: &mut Vec<Diagnostic>,
426) -> io::Result<Option<T>> {
427 match read {
428 Ok(value) => Ok(Some(value)),
429 Err(error) if has_source => {
430 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
431 diagnostics.push(Diagnostic::warning(
432 Some(Location::from_offset(name, "", 0)),
433 format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
434 ));
435 Ok(None)
436 }
437 Err(error) => Err(error),
438 }
439}
440
441fn source_id(file: &str) -> String {
443 Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
444}
445
446fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
447 let text = std::fs::read_to_string(file)?;
448 let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
449 Ok(Keymap::parse(name, &text, diagnostics))
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455
456 #[test]
460 fn a_connection_is_remote_when_either_ssh_variable_carries_a_value() {
461 let cases = [
462 (None, None, false),
463 (Some(""), None, false),
464 (None, Some(""), false),
465 (Some(""), Some(""), false),
466 (Some("10.0.0.2 51150 10.0.0.9 22"), None, true),
467 (None, Some("/dev/pts/3"), true),
468 (Some("10.0.0.2 51150 10.0.0.9 22"), Some("/dev/pts/3"), true),
469 (Some(""), Some("/dev/pts/3"), true),
470 (Some("10.0.0.2 51150 10.0.0.9 22"), Some(""), true),
471 ];
472 for (connection, tty, remote) in cases {
473 let lookup = |name: &str| match name {
474 "SSH_CONNECTION" => connection.map(str::to_owned),
475 "SSH_TTY" => tty.map(str::to_owned),
476 _ => None,
477 };
478 assert_eq!(detect_remote(lookup), remote, "SSH_CONNECTION={connection:?} SSH_TTY={tty:?}");
479 }
480 }
481
482 #[test]
483 fn the_environment_of_tests_is_never_remote() {
484 assert!(!Env::builtin().remote(), "a test must draw the same wherever it runs");
485 }
486
487 #[test]
488 fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
489 let mut env = Env::builtin();
490 assert_eq!(env.icons().glyph(PILLAR), "▌");
491 assert!(env.slide());
492 env.set_pillar_style(PillarStyle::Thin);
493 env.set_slide(false);
494 env.set_theme("amber");
495 assert_eq!(env.icons().glyph(PILLAR), "▎");
496 assert!(!env.slide());
497 let mut settings = crate::storage::Settings::in_memory();
498 settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
499 settings.set(crate::storage::Settings::SLIDE, true);
500 env.apply_settings(&settings);
501 assert_eq!(env.icons().glyph(PILLAR), "▌");
502 assert!(env.slide());
503 }
504
505 #[test]
506 fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
507 let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
508 let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
509 let dirs = AssetDirs {
510 locale_sources: vec![
511 ("app-en.toml".to_owned(), english.to_owned()),
512 ("app-tr.toml".to_owned(), turkish.to_owned()),
513 ("broken.toml".to_owned(), "[meta\n".to_owned()),
514 ],
515 ..AssetDirs::default()
516 };
517 let env = Env::load(&dirs).expect("nothing to read from disk");
518 let mut i18n = env.i18n().clone();
519 assert!(i18n.set_active("tr"));
520 assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
521 assert!(i18n.set_active("en"));
522 assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
523 assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
524 assert!(
525 env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
526 "{:?}",
527 env.diagnostics()
528 );
529 }
530
531 const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
533 [colors]\naccent = \"#FF8800\"\n";
534 const BRAND_ICONS: &str =
535 "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
536 const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
537
538 fn brand_sources() -> AssetDirs {
540 AssetDirs {
541 theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
542 icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
543 keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
544 ..AssetDirs::default()
545 }
546 }
547
548 fn chord(text: &str) -> crate::keymap::KeyChord {
549 text.parse().expect("a chord")
550 }
551
552 #[test]
553 fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
554 let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
555 assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
556 assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
557 env.set_theme("brand");
558 assert_eq!(env.theme().id(), "brand");
559 assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
560 env.set_glyph_mode(GlyphMode::Ascii);
561 assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
562 assert_eq!(
563 env.keymap().action_for(chord("ctrl+s")),
564 Some((crate::keymap::Scope::App, "save")),
565 "the keymap came from text"
566 );
567 assert_eq!(
568 env.keymap().action_for(chord("ctrl+q")),
569 Some((crate::keymap::Scope::Global, "quit")),
570 "the built-in keymap is still under it"
571 );
572 }
573
574 #[test]
575 fn an_application_icon_is_found_in_every_theme_and_follows_the_icon_mode() {
576 let app = "[icons]\n\"category.internet\" = { nerd = \"I\", unicode = \"◎\", ascii = \"@\" }\n";
577 let mut dirs = brand_sources();
578 dirs.icon_sources.push(("app.toml".to_owned(), app.to_owned()));
579 let mut env = Env::load(&dirs).expect("nothing to read from disk");
580 env.set_icon_mode(IconMode::Nerd);
581 assert_eq!(env.icons().glyph("category.internet"), "I");
582 for theme in ["nordic", "amber", "brand"] {
583 env.set_theme(theme);
584 assert_eq!(env.theme().id(), theme);
585 env.set_icon_mode(IconMode::Unicode);
586 assert_eq!(env.icons().glyph("category.internet"), "◎", "{theme}");
587 env.set_icon_mode(IconMode::Ascii);
588 assert_eq!(env.icons().glyph("category.internet"), "@", "{theme}");
589 }
590 assert_eq!(env.icons().glyph("check"), "!", "the brand theme's own set still restyles what it names");
591 }
592
593 #[test]
594 fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
595 let missing = std::env::temp_dir().join("quvyta-not-installed");
596 let dirs = AssetDirs {
597 themes: Some(missing.join("themes")),
598 icons: Some(missing.join("icons")),
599 locales: Some(missing.join("locales")),
600 locale_sources: vec![(
601 "en.toml".to_owned(),
602 "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
603 )],
604 keymap: Some(missing.join("keymap.toml")),
605 ..brand_sources()
606 };
607 let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
608 env.set_theme("brand");
609 assert_eq!(env.theme().id(), "brand");
610 assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
611 assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
612 for file in ["themes", "icons", "locales", "keymap.toml"] {
613 assert!(
614 env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
615 "the unreadable {file} is reported: {:?}",
616 env.diagnostics()
617 );
618 }
619 let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
620 assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
621 }
622
623 #[test]
624 fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
625 let dirs = AssetDirs {
626 theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
627 icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
628 keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
629 ..AssetDirs::default()
630 };
631 let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
632 for file in ["brand.toml", "keymap.toml"] {
633 assert!(
634 env.diagnostics().iter().any(|problem| problem
635 .location
636 .as_ref()
637 .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
638 "{file} is reported with file, line and column: {:?}",
639 env.diagnostics()
640 );
641 }
642 assert_eq!(env.theme().id(), "monochrome");
643 env.set_glyph_mode(GlyphMode::Unicode);
644 assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
645 assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
646 env.set_theme("brand");
647 assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
648 }
649
650 fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
652 let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
653 move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
654 }
655
656 fn env_with(variables: &[(&str, &str)]) -> Env {
658 let mut env = Env::builtin();
659 env.force_reduced_motion(forced_reduced_motion(vars(variables)));
660 env
661 }
662
663 fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
664 let mut settings = crate::storage::Settings::in_memory();
665 settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
666 settings
667 }
668
669 #[test]
670 fn reads_the_reduced_motion_variable() {
671 assert_eq!(forced_reduced_motion(vars(&[])), None);
672 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
673 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
674 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
675 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
676 }
677
678 #[test]
679 fn tells_whether_the_variable_decides() {
680 assert!(!Env::builtin().reduced_motion_forced());
681 assert!(!env_with(&[]).reduced_motion_forced());
682 assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
683 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
684 env.apply_settings(&saved_reduced_motion(false));
685 assert!(env.reduced_motion_forced() && env.reduced_motion());
686 let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
687 assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
688 }
689
690 #[test]
691 fn the_variable_wins_over_the_saved_setting_in_both_directions() {
692 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
693 env.apply_settings(&saved_reduced_motion(false));
694 assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
695 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
696 env.apply_settings(&saved_reduced_motion(true));
697 assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
698 let mut env = env_with(&[]);
699 env.apply_settings(&saved_reduced_motion(true));
700 assert!(env.reduced_motion(), "without the variable the saved setting decides");
701 }
702
703 #[test]
704 fn the_variable_wins_when_the_setting_was_applied_first() {
705 let mut env = Env::builtin();
706 env.apply_settings(&saved_reduced_motion(false));
707 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
708 assert!(env.reduced_motion());
709 let mut env = Env::builtin();
710 env.apply_settings(&saved_reduced_motion(true));
711 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
712 assert!(!env.reduced_motion());
713 let mut env = Env::builtin();
714 env.apply_settings(&saved_reduced_motion(true));
715 env.force_reduced_motion(forced_reduced_motion(vars(&[])));
716 assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
717 }
718
719 #[test]
720 fn the_variable_wins_over_settings_applied_as_commands_after_start() {
721 use crate::runtime::{App, Command, Harness};
722 use crate::widget::View;
723
724 struct Saved(crate::storage::Settings);
725 impl App for Saved {
726 type Msg = ();
727 fn update(&mut self, (): ()) -> Command<()> {
728 self.0.apply()
729 }
730 fn view(&self, _: &mut View<'_, ()>) {}
731 }
732
733 let mut h =
734 Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
735 h.send(());
736 assert!(h.env().reduced_motion());
737 let mut h =
738 Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
739 h.send(());
740 assert!(!h.env().reduced_motion());
741 let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
742 h.send(());
743 assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
744 }
745
746 #[test]
747 fn switches_theme_locale_and_icons() {
748 let mut env = Env::builtin();
749 assert_eq!(env.theme().id(), "monochrome");
750 env.set_theme("nordic");
751 assert_eq!(env.theme().id(), "nordic");
752 env.set_theme("missing");
753 assert_eq!(env.theme().id(), "monochrome");
754 assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
755 env.set_locale("tr");
756 assert_eq!(env.i18n().active(), "tr");
757 env.set_icon_mode(IconMode::Ascii);
758 assert_eq!(env.icons().glyph("check"), "v");
759 env.set_glyph_mode(GlyphMode::Unicode);
760 assert_eq!(env.icons().glyph("check"), "✓");
761 }
762}