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::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#[derive(Debug, Clone, Default)]
27pub struct AssetDirs {
28 pub themes: Option<PathBuf>,
30 pub theme_sources: Vec<(String, String)>,
33 pub icons: Option<PathBuf>,
35 pub icon_sources: Vec<(String, String)>,
39 pub locales: Option<PathBuf>,
41 pub locale_sources: Vec<(String, String)>,
45 pub keymap: Option<PathBuf>,
47 pub keymap_source: Option<(String, String)>,
50}
51
52#[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 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 #[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 pub fn load(dirs: &AssetDirs) -> io::Result<Self> {
113 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 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 #[must_use]
200 pub fn theme(&self) -> &Theme {
201 &self.theme
202 }
203
204 #[must_use]
206 pub fn themes(&self) -> Vec<(String, String)> {
207 self.themes.list()
208 }
209
210 #[must_use]
213 pub fn icon_sets(&self) -> Vec<(String, String)> {
214 self.icon_sets.list()
215 }
216
217 #[must_use]
219 pub fn icons(&self) -> &Icons {
220 &self.icons
221 }
222
223 #[must_use]
225 pub fn icon_mode(&self) -> IconMode {
226 self.icon_mode
227 }
228
229 #[must_use]
231 pub fn glyph_mode(&self) -> GlyphMode {
232 self.glyph_mode
233 }
234
235 #[must_use]
237 pub fn i18n(&self) -> &I18n {
238 &self.i18n
239 }
240
241 #[must_use]
243 pub fn keymap(&self) -> &Keymap {
244 &self.keymap
245 }
246
247 pub fn keymap_mut(&mut self) -> &mut Keymap {
250 &mut self.keymap
251 }
252
253 #[must_use]
255 pub fn depth(&self) -> ColorDepth {
256 self.depth
257 }
258
259 #[must_use]
271 pub fn remote(&self) -> bool {
272 self.remote
273 }
274
275 #[must_use]
296 pub fn graphics(&self) -> Graphics {
297 self.graphics.resolve(self.depth, self.glyph_mode)
298 }
299
300 pub(crate) fn set_terminal_graphics(&mut self, answer: Graphics) {
303 self.graphics.answer = answer;
304 }
305
306 pub(crate) fn graphics_worth_asking(&self) -> bool {
309 self.graphics.worth_asking(self.depth)
310 }
311
312 pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
316 self.depth = depth;
317 }
318
319 #[must_use]
327 pub fn reduced_motion(&self) -> bool {
328 self.reduced_motion
329 }
330
331 #[must_use]
336 pub fn reduced_motion_forced(&self) -> bool {
337 self.forced_reduced_motion.is_some()
338 }
339
340 pub(crate) fn force_reduced_motion(&mut self, forced: Option<bool>) {
343 self.forced_reduced_motion = forced;
344 if let Some(reduced) = forced {
345 self.reduced_motion = reduced;
346 }
347 }
348
349 pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
351 self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
352 }
353
354 #[must_use]
356 pub fn pillar_style(&self) -> Option<PillarStyle> {
357 self.pillar
358 }
359
360 pub(crate) fn set_pillar_style(&mut self, style: PillarStyle) {
361 self.pillar = Some(style);
362 self.rebuild_icons();
363 }
364
365 #[must_use]
369 pub fn slide(&self) -> bool {
370 self.slide.unwrap_or(self.theme.motion().slide)
371 }
372
373 pub(crate) fn set_slide(&mut self, slide: bool) {
374 self.slide = Some(slide);
375 }
376
377 #[must_use]
380 pub fn diagnostics(&self) -> &[Diagnostic] {
381 &self.diagnostics
382 }
383
384 pub(crate) fn i18n_arc(&self) -> Arc<I18n> {
385 Arc::clone(&self.i18n)
386 }
387
388 pub(crate) fn set_theme(&mut self, id: &str) {
391 let (theme, diagnostics) = self.themes.resolve_or_default(id);
392 self.diagnostics.extend(diagnostics);
393 self.theme = theme;
394 self.rebuild_icons();
395 }
396
397 pub(crate) fn set_locale(&mut self, code: &str) {
398 let mut i18n = I18n::clone(&self.i18n);
399 if i18n.select(code) {
400 self.i18n = Arc::new(i18n);
401 } else {
402 self.diagnostics.push(Diagnostic::warning(None, format!("unknown locale `{code}`")));
403 }
404 }
405
406 pub(crate) fn set_region(&mut self, region: Option<&str>) {
407 let mut i18n = I18n::clone(&self.i18n);
408 if i18n.set_region(region) {
409 self.i18n = Arc::new(i18n);
410 } else {
411 let region = region.unwrap_or_default();
412 self.diagnostics.push(Diagnostic::warning(None, format!("unknown region `{region}`")));
413 }
414 }
415
416 pub(crate) fn set_icon_mode(&mut self, mode: IconMode) {
417 let lookup = |name: &str| std::env::var(name).ok();
418 self.icon_mode = mode;
419 self.glyph_mode = match mode {
420 IconMode::Nerd => GlyphMode::Nerd,
421 IconMode::Unicode => GlyphMode::Unicode,
422 IconMode::Ascii => GlyphMode::Ascii,
423 IconMode::Auto => detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup)),
424 };
425 self.rebuild_icons();
426 }
427
428 pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
430 self.glyph_mode = mode;
431 self.rebuild_icons();
432 }
433
434 pub(crate) fn apply_settings(&mut self, settings: &crate::storage::Settings) {
437 if let Some(theme) = settings.theme() {
438 self.set_theme(&theme);
439 }
440 if let Some(language) = settings.language() {
441 self.set_locale(&language);
442 }
443 if let Some(mode) = settings.icon_mode() {
444 self.set_icon_mode(mode);
445 }
446 if let Some(reduced) = settings.reduced_motion() {
447 self.set_reduced_motion(reduced);
448 }
449 if let Some(style) = settings.pillar_style() {
450 self.set_pillar_style(style);
451 }
452 if let Some(slide) = settings.slide() {
453 self.set_slide(slide);
454 }
455 }
456
457 pub(crate) fn apply_preferences(&mut self, preferences: &crate::storage::Preferences) {
459 self.set_theme(&preferences.theme().value);
460 self.set_locale(&preferences.language().value);
461 self.set_icon_mode(preferences.icons().value);
462 }
463
464 fn rebuild_icons(&mut self) {
465 let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
466 if let Some(style) = self.pillar {
467 overrides.insert(PILLAR.to_owned(), style.glyphs());
468 }
469 self.icons = self.icon_sets.icons_with_animations(
470 self.theme.icon_set(),
471 &overrides,
472 self.theme.animation_overrides(),
473 self.glyph_mode,
474 );
475 }
476}
477
478fn detect_remote(lookup: impl Fn(&str) -> Option<String>) -> bool {
483 ["SSH_CONNECTION", "SSH_TTY"].iter().any(|name| lookup(name).is_some_and(|value| !value.is_empty()))
484}
485
486fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
487 lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
488}
489
490fn stand_in<T>(
494 read: io::Result<T>,
495 path: &Path,
496 has_source: bool,
497 diagnostics: &mut Vec<Diagnostic>,
498) -> io::Result<Option<T>> {
499 match read {
500 Ok(value) => Ok(Some(value)),
501 Err(error) if has_source => {
502 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
503 diagnostics.push(Diagnostic::warning(
504 Some(Location::from_offset(name, "", 0)),
505 format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
506 ));
507 Ok(None)
508 }
509 Err(error) => Err(error),
510 }
511}
512
513fn source_id(file: &str) -> String {
515 Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
516}
517
518fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
519 let text = std::fs::read_to_string(file)?;
520 let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
521 Ok(Keymap::parse(name, &text, diagnostics))
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn a_test_can_load_the_files_without_the_machines_language_and_region() {
530 let turkish = |name: &str| (name == "LANG").then(|| "tr_TR.UTF-8".to_owned());
531 let env = Env::load_with(&AssetDirs::default(), turkish).expect("the built-in files load");
532 assert_eq!(env.i18n().active(), "tr");
533 assert_eq!(env.i18n().first_weekday(), crate::date::Weekday::Monday, "Turkey starts the week on Monday");
534 let mut english = Env::load_with(&AssetDirs::default(), |_| None).expect("the built-in files load");
535 assert_eq!(english.i18n().active(), "en", "a machine with nothing set");
536 assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday, "English alone starts on Sunday");
537 english.set_locale("en");
538 assert_eq!(english.i18n().first_weekday(), crate::date::Weekday::Sunday);
539 }
540
541 #[test]
545 fn a_connection_is_remote_when_either_ssh_variable_carries_a_value() {
546 let cases = [
547 (None, None, false),
548 (Some(""), None, false),
549 (None, Some(""), false),
550 (Some(""), Some(""), false),
551 (Some("10.0.0.2 51150 10.0.0.9 22"), None, true),
552 (None, Some("/dev/pts/3"), true),
553 (Some("10.0.0.2 51150 10.0.0.9 22"), Some("/dev/pts/3"), true),
554 (Some(""), Some("/dev/pts/3"), true),
555 (Some("10.0.0.2 51150 10.0.0.9 22"), Some(""), true),
556 ];
557 for (connection, tty, remote) in cases {
558 let lookup = |name: &str| match name {
559 "SSH_CONNECTION" => connection.map(str::to_owned),
560 "SSH_TTY" => tty.map(str::to_owned),
561 _ => None,
562 };
563 assert_eq!(detect_remote(lookup), remote, "SSH_CONNECTION={connection:?} SSH_TTY={tty:?}");
564 }
565 }
566
567 #[test]
570 fn graphics_follow_the_multiplexer_and_the_override_the_lookup_gives() {
571 fn terminal(extra: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option<String> {
574 move |name: &str| {
575 let base = [("LANG", "en_US.UTF-8"), ("TERM", "xterm-256color"), ("QUVYTA_ICONS", "unicode")];
576 base.iter().chain(extra).find(|(key, _)| *key == name).map(|(_, value)| (*value).to_owned())
577 }
578 }
579 let mut env = Env::load_with(&AssetDirs::default(), terminal(&[])).expect("the built-in files load");
580 assert!(env.graphics_worth_asking());
581 env.set_terminal_graphics(Graphics::Kitty);
582 assert_eq!(env.graphics(), Graphics::Kitty, "outside a multiplexer the answer stands");
583
584 let in_tmux = terminal(&[("TMUX", "/tmp/tmux-1000/default,4242,0")]);
585 let mut env = Env::load_with(&AssetDirs::default(), in_tmux).expect("the built-in files load");
586 assert!(!env.graphics_worth_asking(), "tmux answers for the terminal, so it is not asked");
587 env.set_terminal_graphics(Graphics::Kitty);
588 assert_eq!(env.graphics(), Graphics::HalfBlock);
589
590 let forced = terminal(&[("QUVYTA_GRAPHICS", "sixel"), ("STY", "1234.pts-0.host")]);
591 let env = Env::load_with(&AssetDirs::default(), forced).expect("the built-in files load");
592 assert_eq!(env.graphics(), Graphics::Sixel, "the override wins over the multiplexer");
593
594 let unknown = terminal(&[("QUVYTA_GRAPHICS", "pixels")]);
595 let env = Env::load_with(&AssetDirs::default(), unknown).expect("the built-in files load");
596 assert_eq!(env.graphics(), Graphics::HalfBlock);
597 assert!(
598 env.diagnostics().iter().any(|problem| problem.to_string().contains("`pixels`")),
599 "{:?}",
600 env.diagnostics()
601 );
602 }
603
604 #[test]
605 fn the_environment_of_tests_is_never_remote() {
606 assert!(!Env::builtin().remote(), "a test must draw the same wherever it runs");
607 }
608
609 #[test]
610 fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
611 let mut env = Env::builtin();
612 assert_eq!(env.icons().glyph(PILLAR), "▌");
613 assert!(env.slide());
614 env.set_pillar_style(PillarStyle::Thin);
615 env.set_slide(false);
616 env.set_theme("amber");
617 assert_eq!(env.icons().glyph(PILLAR), "▎");
618 assert!(!env.slide());
619 let mut settings = crate::storage::Settings::in_memory();
620 settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
621 settings.set(crate::storage::Settings::SLIDE, true);
622 env.apply_settings(&settings);
623 assert_eq!(env.icons().glyph(PILLAR), "▌");
624 assert!(env.slide());
625 }
626
627 #[test]
628 fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
629 let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
630 let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
631 let dirs = AssetDirs {
632 locale_sources: vec![
633 ("app-en.toml".to_owned(), english.to_owned()),
634 ("app-tr.toml".to_owned(), turkish.to_owned()),
635 ("broken.toml".to_owned(), "[meta\n".to_owned()),
636 ],
637 ..AssetDirs::default()
638 };
639 let env = Env::load(&dirs).expect("nothing to read from disk");
640 let mut i18n = env.i18n().clone();
641 assert!(i18n.set_active("tr"));
642 assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
643 assert!(i18n.set_active("en"));
644 assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
645 assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
646 assert!(
647 env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
648 "{:?}",
649 env.diagnostics()
650 );
651 }
652
653 const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
655 [colors]\naccent = \"#FF8800\"\n";
656 const BRAND_ICONS: &str =
657 "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
658 const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
659
660 fn brand_sources() -> AssetDirs {
662 AssetDirs {
663 theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
664 icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
665 keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
666 ..AssetDirs::default()
667 }
668 }
669
670 fn chord(text: &str) -> crate::keymap::KeyChord {
671 text.parse().expect("a chord")
672 }
673
674 #[test]
675 fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
676 let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
677 assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
678 assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
679 env.set_theme("brand");
680 assert_eq!(env.theme().id(), "brand");
681 assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
682 env.set_glyph_mode(GlyphMode::Ascii);
683 assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
684 assert_eq!(
685 env.keymap().action_for(chord("ctrl+s")),
686 Some((crate::keymap::Scope::App, "save")),
687 "the keymap came from text"
688 );
689 assert_eq!(
690 env.keymap().action_for(chord("ctrl+q")),
691 Some((crate::keymap::Scope::Global, "quit")),
692 "the built-in keymap is still under it"
693 );
694 }
695
696 #[test]
697 fn an_application_icon_is_found_in_every_theme_and_follows_the_icon_mode() {
698 let app = "[icons]\n\"category.internet\" = { nerd = \"I\", unicode = \"◎\", ascii = \"@\" }\n";
699 let mut dirs = brand_sources();
700 dirs.icon_sources.push(("app.toml".to_owned(), app.to_owned()));
701 let mut env = Env::load(&dirs).expect("nothing to read from disk");
702 env.set_icon_mode(IconMode::Nerd);
703 assert_eq!(env.icons().glyph("category.internet"), "I");
704 for theme in ["nordic", "amber", "brand"] {
705 env.set_theme(theme);
706 assert_eq!(env.theme().id(), theme);
707 env.set_icon_mode(IconMode::Unicode);
708 assert_eq!(env.icons().glyph("category.internet"), "◎", "{theme}");
709 env.set_icon_mode(IconMode::Ascii);
710 assert_eq!(env.icons().glyph("category.internet"), "@", "{theme}");
711 }
712 assert_eq!(env.icons().glyph("check"), "!", "the brand theme's own set still restyles what it names");
713 }
714
715 #[test]
716 fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
717 let missing = std::env::temp_dir().join("quvyta-not-installed");
718 let dirs = AssetDirs {
719 themes: Some(missing.join("themes")),
720 icons: Some(missing.join("icons")),
721 locales: Some(missing.join("locales")),
722 locale_sources: vec![(
723 "en.toml".to_owned(),
724 "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
725 )],
726 keymap: Some(missing.join("keymap.toml")),
727 ..brand_sources()
728 };
729 let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
730 env.set_theme("brand");
731 assert_eq!(env.theme().id(), "brand");
732 assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
733 assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
734 for file in ["themes", "icons", "locales", "keymap.toml"] {
735 assert!(
736 env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
737 "the unreadable {file} is reported: {:?}",
738 env.diagnostics()
739 );
740 }
741 let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
742 assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
743 }
744
745 #[test]
746 fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
747 let dirs = AssetDirs {
748 theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
749 icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
750 keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
751 ..AssetDirs::default()
752 };
753 let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
754 for file in ["brand.toml", "keymap.toml"] {
755 assert!(
756 env.diagnostics().iter().any(|problem| problem
757 .location
758 .as_ref()
759 .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
760 "{file} is reported with file, line and column: {:?}",
761 env.diagnostics()
762 );
763 }
764 assert_eq!(env.theme().id(), "monochrome");
765 env.set_glyph_mode(GlyphMode::Unicode);
766 assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
767 assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
768 env.set_theme("brand");
769 assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
770 }
771
772 fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
774 let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
775 move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
776 }
777
778 fn env_with(variables: &[(&str, &str)]) -> Env {
780 let mut env = Env::builtin();
781 env.force_reduced_motion(forced_reduced_motion(vars(variables)));
782 env
783 }
784
785 fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
786 let mut settings = crate::storage::Settings::in_memory();
787 settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
788 settings
789 }
790
791 #[test]
792 fn reads_the_reduced_motion_variable() {
793 assert_eq!(forced_reduced_motion(vars(&[])), None);
794 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
795 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
796 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
797 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
798 }
799
800 #[test]
801 fn tells_whether_the_variable_decides() {
802 assert!(!Env::builtin().reduced_motion_forced());
803 assert!(!env_with(&[]).reduced_motion_forced());
804 assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
805 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
806 env.apply_settings(&saved_reduced_motion(false));
807 assert!(env.reduced_motion_forced() && env.reduced_motion());
808 let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
809 assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
810 }
811
812 #[test]
813 fn the_variable_wins_over_the_saved_setting_in_both_directions() {
814 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
815 env.apply_settings(&saved_reduced_motion(false));
816 assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
817 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
818 env.apply_settings(&saved_reduced_motion(true));
819 assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
820 let mut env = env_with(&[]);
821 env.apply_settings(&saved_reduced_motion(true));
822 assert!(env.reduced_motion(), "without the variable the saved setting decides");
823 }
824
825 #[test]
826 fn the_variable_wins_when_the_setting_was_applied_first() {
827 let mut env = Env::builtin();
828 env.apply_settings(&saved_reduced_motion(false));
829 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
830 assert!(env.reduced_motion());
831 let mut env = Env::builtin();
832 env.apply_settings(&saved_reduced_motion(true));
833 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
834 assert!(!env.reduced_motion());
835 let mut env = Env::builtin();
836 env.apply_settings(&saved_reduced_motion(true));
837 env.force_reduced_motion(forced_reduced_motion(vars(&[])));
838 assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
839 }
840
841 #[test]
842 fn the_variable_wins_over_settings_applied_as_commands_after_start() {
843 use crate::runtime::{App, Command, Harness};
844 use crate::widget::View;
845
846 struct Saved(crate::storage::Settings);
847 impl App for Saved {
848 type Msg = ();
849 fn update(&mut self, (): ()) -> Command<()> {
850 self.0.apply()
851 }
852 fn view(&self, _: &mut View<'_, ()>) {}
853 }
854
855 let mut h =
856 Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
857 h.send(());
858 assert!(h.env().reduced_motion());
859 let mut h =
860 Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
861 h.send(());
862 assert!(!h.env().reduced_motion());
863 let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
864 h.send(());
865 assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
866 }
867
868 #[test]
869 fn switches_theme_locale_and_icons() {
870 let mut env = Env::builtin();
871 assert_eq!(env.theme().id(), "monochrome");
872 env.set_theme("nordic");
873 assert_eq!(env.theme().id(), "nordic");
874 env.set_theme("missing");
875 assert_eq!(env.theme().id(), "monochrome");
876 assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
877 env.set_locale("tr");
878 assert_eq!(env.i18n().active(), "tr");
879 env.set_icon_mode(IconMode::Ascii);
880 assert_eq!(env.icons().glyph("check"), "v");
881 env.set_glyph_mode(GlyphMode::Unicode);
882 assert_eq!(env.icons().glyph("check"), "✓");
883 }
884}