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 cell_pixels: Option<(u16, u16)>,
72 diagnostics: Vec<Diagnostic>,
73}
74
75impl Env {
76 #[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 pub fn load(dirs: &AssetDirs) -> io::Result<Self> {
115 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 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 #[must_use]
202 pub fn theme(&self) -> &Theme {
203 &self.theme
204 }
205
206 #[must_use]
208 pub fn themes(&self) -> Vec<(String, String)> {
209 self.themes.list()
210 }
211
212 #[must_use]
215 pub fn icon_sets(&self) -> Vec<(String, String)> {
216 self.icon_sets.list()
217 }
218
219 #[must_use]
221 pub fn icons(&self) -> &Icons {
222 &self.icons
223 }
224
225 #[must_use]
227 pub fn icon_mode(&self) -> IconMode {
228 self.icon_mode
229 }
230
231 #[must_use]
233 pub fn glyph_mode(&self) -> GlyphMode {
234 self.glyph_mode
235 }
236
237 #[must_use]
239 pub fn i18n(&self) -> &I18n {
240 &self.i18n
241 }
242
243 #[must_use]
245 pub fn keymap(&self) -> &Keymap {
246 &self.keymap
247 }
248
249 pub fn keymap_mut(&mut self) -> &mut Keymap {
252 &mut self.keymap
253 }
254
255 #[must_use]
257 pub fn depth(&self) -> ColorDepth {
258 self.depth
259 }
260
261 #[must_use]
274 pub fn remote(&self) -> bool {
275 self.remote
276 }
277
278 #[must_use]
287 pub fn remote_session() -> bool {
288 detect_remote(|name: &str| std::env::var(name).ok())
289 }
290
291 pub(crate) fn set_remote(&mut self, remote: bool) {
294 self.remote = remote;
295 }
296
297 #[must_use]
320 pub fn graphics(&self) -> Graphics {
321 self.graphics.resolve(self.depth, self.glyph_mode)
322 }
323
324 pub(crate) fn set_terminal_graphics(&mut self, answer: Graphics) {
327 self.graphics.answer = answer;
328 }
329
330 pub(crate) fn graphics_worth_asking(&self) -> bool {
333 self.graphics.worth_asking(self.depth)
334 }
335
336 #[must_use]
353 pub fn cell_pixels(&self) -> Option<(u16, u16)> {
354 self.cell_pixels
355 }
356
357 pub(crate) fn set_cell_pixels(&mut self, cell: Option<(u16, u16)>) {
359 self.cell_pixels = cell;
360 }
361
362 pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
366 self.depth = depth;
367 }
368
369 #[must_use]
377 pub fn reduced_motion(&self) -> bool {
378 self.reduced_motion
379 }
380
381 #[must_use]
386 pub fn reduced_motion_forced(&self) -> bool {
387 self.forced_reduced_motion.is_some()
388 }
389
390 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 pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
401 self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
402 }
403
404 #[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 #[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 #[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 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 pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
480 self.glyph_mode = mode;
481 self.rebuild_icons();
482 }
483
484 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 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
530fn 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
542fn 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
565fn 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 #[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 #[test]
622 fn graphics_follow_the_multiplexer_and_the_override_the_lookup_gives() {
623 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 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 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 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 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}