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)>,
37 pub locales: Option<PathBuf>,
39 pub locale_sources: Vec<(String, String)>,
43 pub keymap: Option<PathBuf>,
45 pub keymap_source: Option<(String, String)>,
48}
49
50#[derive(Debug, Clone)]
52pub struct Env {
53 themes: ThemeRegistry,
54 theme: Theme,
55 icon_sets: IconSetRegistry,
56 icon_mode: IconMode,
57 glyph_mode: GlyphMode,
58 icons: Icons,
59 i18n: Arc<I18n>,
60 keymap: Keymap,
61 depth: ColorDepth,
62 reduced_motion: bool,
63 forced_reduced_motion: Option<bool>,
65 pillar: Option<PillarStyle>,
66 slide: Option<bool>,
67 diagnostics: Vec<Diagnostic>,
68}
69
70impl Env {
71 #[must_use]
74 pub fn builtin() -> Self {
75 let themes = ThemeRegistry::builtin();
76 let (theme, _) = themes.resolve_or_default("monochrome");
77 let icon_sets = IconSetRegistry::builtin();
78 let icons = icon_sets.icons(theme.icon_set(), theme.icon_overrides(), GlyphMode::Unicode);
79 Self {
80 themes,
81 theme,
82 icon_sets,
83 icon_mode: IconMode::Unicode,
84 glyph_mode: GlyphMode::Unicode,
85 icons,
86 i18n: Arc::new(I18n::builtin()),
87 keymap: Keymap::builtin(),
88 depth: ColorDepth::TrueColor,
89 reduced_motion: false,
90 forced_reduced_motion: None,
91 pillar: None,
92 slide: None,
93 diagnostics: Vec::new(),
94 }
95 }
96
97 pub fn load(dirs: &AssetDirs) -> io::Result<Self> {
107 let lookup = |name: &str| std::env::var(name).ok();
108 let mut env = Self::builtin();
109 if let Some(dir) = &dirs.themes {
110 let read = env.themes.load_dir(dir);
111 stand_in(read, dir, !dirs.theme_sources.is_empty(), &mut env.diagnostics)?;
112 }
113 for (file, text) in &dirs.theme_sources {
114 env.themes.add_source(&source_id(file), file, text);
115 }
116 if let Some(dir) = &dirs.icons {
117 let read = env.icon_sets.load_dir(dir);
118 stand_in(read, dir, !dirs.icon_sources.is_empty(), &mut env.diagnostics)?;
119 }
120 for (file, text) in &dirs.icon_sources {
121 env.icon_sets.add_source(&source_id(file), file, text);
122 }
123 let mut i18n = I18n::builtin();
124 if let Some(dir) = &dirs.locales {
125 let read = i18n.load_dir(dir);
126 stand_in(read, dir, !dirs.locale_sources.is_empty(), &mut env.diagnostics)?;
127 }
128 for (file, text) in &dirs.locale_sources {
129 i18n.add_source(file, text);
130 }
131 if let Some(code) = i18n.detect(lookup) {
132 i18n.set_active(&code);
133 }
134 if let Some(file) = &dirs.keymap {
135 let read = load_keymap(file, &mut env.diagnostics);
136 let has_source = dirs.keymap_source.is_some();
137 if let Some(keymap) = stand_in(read, file, has_source, &mut env.diagnostics)? {
138 env.keymap.overlay(&keymap);
139 }
140 }
141 if let Some((file, text)) = &dirs.keymap_source {
142 let keymap = Keymap::parse(file, text, &mut env.diagnostics);
143 env.keymap.overlay(&keymap);
144 }
145 env.diagnostics.extend(env.themes.diagnostics().iter().cloned());
146 env.diagnostics.extend(env.icon_sets.diagnostics().iter().cloned());
147 env.diagnostics.extend(i18n.diagnostics().iter().cloned());
148 env.diagnostics.extend(env.keymap.conflicts());
149 env.i18n = Arc::new(i18n);
150 env.depth = ColorDepth::detect(lookup);
151 env.force_reduced_motion(forced_reduced_motion(lookup));
152 env.icon_mode = IconMode::Auto;
153 env.glyph_mode = detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup));
154 env.rebuild_icons();
155 Ok(env)
156 }
157
158 #[must_use]
160 pub fn theme(&self) -> &Theme {
161 &self.theme
162 }
163
164 #[must_use]
166 pub fn themes(&self) -> Vec<(String, String)> {
167 self.themes.list()
168 }
169
170 #[must_use]
173 pub fn icon_sets(&self) -> Vec<(String, String)> {
174 self.icon_sets.list()
175 }
176
177 #[must_use]
179 pub fn icons(&self) -> &Icons {
180 &self.icons
181 }
182
183 #[must_use]
185 pub fn icon_mode(&self) -> IconMode {
186 self.icon_mode
187 }
188
189 #[must_use]
191 pub fn glyph_mode(&self) -> GlyphMode {
192 self.glyph_mode
193 }
194
195 #[must_use]
197 pub fn i18n(&self) -> &I18n {
198 &self.i18n
199 }
200
201 #[must_use]
203 pub fn keymap(&self) -> &Keymap {
204 &self.keymap
205 }
206
207 pub fn keymap_mut(&mut self) -> &mut Keymap {
210 &mut self.keymap
211 }
212
213 #[must_use]
215 pub fn depth(&self) -> ColorDepth {
216 self.depth
217 }
218
219 pub(crate) fn set_depth(&mut self, depth: ColorDepth) {
223 self.depth = depth;
224 }
225
226 #[must_use]
234 pub fn reduced_motion(&self) -> bool {
235 self.reduced_motion
236 }
237
238 #[must_use]
243 pub fn reduced_motion_forced(&self) -> bool {
244 self.forced_reduced_motion.is_some()
245 }
246
247 fn force_reduced_motion(&mut self, forced: Option<bool>) {
250 self.forced_reduced_motion = forced;
251 if let Some(reduced) = forced {
252 self.reduced_motion = reduced;
253 }
254 }
255
256 pub(crate) fn set_reduced_motion(&mut self, reduced: bool) {
258 self.reduced_motion = self.forced_reduced_motion.unwrap_or(reduced);
259 }
260
261 #[must_use]
263 pub fn pillar_style(&self) -> Option<PillarStyle> {
264 self.pillar
265 }
266
267 pub(crate) fn set_pillar_style(&mut self, style: PillarStyle) {
268 self.pillar = Some(style);
269 self.rebuild_icons();
270 }
271
272 #[must_use]
276 pub fn slide(&self) -> bool {
277 self.slide.unwrap_or(self.theme.motion().slide)
278 }
279
280 pub(crate) fn set_slide(&mut self, slide: bool) {
281 self.slide = Some(slide);
282 }
283
284 #[must_use]
287 pub fn diagnostics(&self) -> &[Diagnostic] {
288 &self.diagnostics
289 }
290
291 pub(crate) fn i18n_arc(&self) -> Arc<I18n> {
292 Arc::clone(&self.i18n)
293 }
294
295 pub(crate) fn set_theme(&mut self, id: &str) {
298 let (theme, diagnostics) = self.themes.resolve_or_default(id);
299 self.diagnostics.extend(diagnostics);
300 self.theme = theme;
301 self.rebuild_icons();
302 }
303
304 pub(crate) fn set_locale(&mut self, code: &str) {
305 let mut i18n = I18n::clone(&self.i18n);
306 if i18n.set_active(code) {
307 self.i18n = Arc::new(i18n);
308 } else {
309 self.diagnostics.push(Diagnostic::warning(None, format!("unknown locale `{code}`")));
310 }
311 }
312
313 pub(crate) fn set_icon_mode(&mut self, mode: IconMode) {
314 let lookup = |name: &str| std::env::var(name).ok();
315 self.icon_mode = mode;
316 self.glyph_mode = match mode {
317 IconMode::Nerd => GlyphMode::Nerd,
318 IconMode::Unicode => GlyphMode::Unicode,
319 IconMode::Ascii => GlyphMode::Ascii,
320 IconMode::Auto => detect_glyph_mode(IconMode::Auto, lookup, &default_font_dirs(lookup)),
321 };
322 self.rebuild_icons();
323 }
324
325 pub(crate) fn set_glyph_mode(&mut self, mode: GlyphMode) {
327 self.glyph_mode = mode;
328 self.rebuild_icons();
329 }
330
331 pub(crate) fn apply_settings(&mut self, settings: &crate::storage::Settings) {
334 if let Some(theme) = settings.theme() {
335 self.set_theme(&theme);
336 }
337 if let Some(language) = settings.language() {
338 self.set_locale(&language);
339 }
340 if let Some(mode) = settings.icon_mode() {
341 self.set_icon_mode(mode);
342 }
343 if let Some(reduced) = settings.reduced_motion() {
344 self.set_reduced_motion(reduced);
345 }
346 if let Some(style) = settings.pillar_style() {
347 self.set_pillar_style(style);
348 }
349 if let Some(slide) = settings.slide() {
350 self.set_slide(slide);
351 }
352 }
353
354 fn rebuild_icons(&mut self) {
355 let mut overrides: BTreeMap<_, _> = self.theme.icon_overrides().clone();
356 if let Some(style) = self.pillar {
357 overrides.insert(PILLAR.to_owned(), style.glyphs());
358 }
359 self.icons = self.icon_sets.icons_with_animations(
360 self.theme.icon_set(),
361 &overrides,
362 self.theme.animation_overrides(),
363 self.glyph_mode,
364 );
365 }
366}
367
368fn forced_reduced_motion(lookup: impl Fn(&str) -> Option<String>) -> Option<bool> {
371 lookup("QUVYTA_REDUCED_MOTION").filter(|value| !value.is_empty()).map(|value| value != "0")
372}
373
374fn stand_in<T>(
378 read: io::Result<T>,
379 path: &Path,
380 has_source: bool,
381 diagnostics: &mut Vec<Diagnostic>,
382) -> io::Result<Option<T>> {
383 match read {
384 Ok(value) => Ok(Some(value)),
385 Err(error) if has_source => {
386 let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
387 diagnostics.push(Diagnostic::warning(
388 Some(Location::from_offset(name, "", 0)),
389 format!("cannot read `{}`, the text given instead is used: {error}", path.display()),
390 ));
391 Ok(None)
392 }
393 Err(error) => Err(error),
394 }
395}
396
397fn source_id(file: &str) -> String {
399 Path::new(file).file_stem().and_then(|stem| stem.to_str()).unwrap_or(file).to_owned()
400}
401
402fn load_keymap(file: &Path, diagnostics: &mut Vec<Diagnostic>) -> io::Result<Keymap> {
403 let text = std::fs::read_to_string(file)?;
404 let name = file.file_name().and_then(|n| n.to_str()).unwrap_or("keymap.toml");
405 Ok(Keymap::parse(name, &text, diagnostics))
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn user_choices_for_pillar_and_slide_win_over_the_theme_and_survive_a_theme_switch() {
414 let mut env = Env::builtin();
415 assert_eq!(env.icons().glyph(PILLAR), "▌");
416 assert!(env.slide());
417 env.set_pillar_style(PillarStyle::Thin);
418 env.set_slide(false);
419 env.set_theme("amber");
420 assert_eq!(env.icons().glyph(PILLAR), "▎");
421 assert!(!env.slide());
422 let mut settings = crate::storage::Settings::in_memory();
423 settings.set(crate::storage::Settings::PILLAR, "thick".to_owned());
424 settings.set(crate::storage::Settings::SLIDE, true);
425 env.apply_settings(&settings);
426 assert_eq!(env.icons().glyph(PILLAR), "▌");
427 assert!(env.slide());
428 }
429
430 #[test]
431 fn locales_given_as_text_load_over_the_built_ins_and_report_problems_by_file() {
432 let english = "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n";
433 let turkish = "[meta]\nname = \"Türkçe\"\ncode = \"tr\"\nfallback = \"en\"\n[app]\ngreeting = \"Merhaba\"\n";
434 let dirs = AssetDirs {
435 locale_sources: vec![
436 ("app-en.toml".to_owned(), english.to_owned()),
437 ("app-tr.toml".to_owned(), turkish.to_owned()),
438 ("broken.toml".to_owned(), "[meta\n".to_owned()),
439 ],
440 ..AssetDirs::default()
441 };
442 let env = Env::load(&dirs).expect("nothing to read from disk");
443 let mut i18n = env.i18n().clone();
444 assert!(i18n.set_active("tr"));
445 assert_eq!(i18n.translate("app.greeting", &[]), "Merhaba");
446 assert!(i18n.set_active("en"));
447 assert_eq!(i18n.translate("app.greeting", &[]), "Hello");
448 assert_eq!(i18n.translate("quvyta.keys.quit", &[]), "quit", "built-in text stays");
449 assert!(
450 env.diagnostics().iter().any(|problem| problem.to_string().contains("broken.toml")),
451 "{:?}",
452 env.diagnostics()
453 );
454 }
455
456 const BRAND_THEME: &str = "[meta]\nname = \"Brand\"\nextends = \"monochrome\"\nicon-set = \"brand\"\n\
458 [colors]\naccent = \"#FF8800\"\n";
459 const BRAND_ICONS: &str =
460 "[meta]\nname = \"Brand\"\n[icons]\ncheck = { nerd = \"!\", unicode = \"!\", ascii = \"!\" }\n";
461 const BRAND_KEYS: &str = "[app]\nsave = \"ctrl+s\"\n";
462
463 fn brand_sources() -> AssetDirs {
465 AssetDirs {
466 theme_sources: vec![("brand.toml".to_owned(), BRAND_THEME.to_owned())],
467 icon_sources: vec![("brand.toml".to_owned(), BRAND_ICONS.to_owned())],
468 keymap_source: Some(("keymap.toml".to_owned(), BRAND_KEYS.to_owned())),
469 ..AssetDirs::default()
470 }
471 }
472
473 fn chord(text: &str) -> crate::keymap::KeyChord {
474 text.parse().expect("a chord")
475 }
476
477 #[test]
478 fn a_theme_an_icon_set_and_a_keymap_given_as_text_load_with_no_files_on_disk() {
479 let mut env = Env::load(&brand_sources()).expect("nothing to read from disk");
480 assert!(env.diagnostics().is_empty(), "{:?}", env.diagnostics());
481 assert!(env.themes().iter().any(|(id, name)| id == "brand" && name == "Brand"));
482 env.set_theme("brand");
483 assert_eq!(env.theme().id(), "brand");
484 assert_eq!(env.theme().color("accent").map(|c| c.to_string()).as_deref(), Some("#ff8800"));
485 env.set_glyph_mode(GlyphMode::Ascii);
486 assert_eq!(env.icons().glyph("check"), "!", "the icon set the theme names came from text");
487 assert_eq!(
488 env.keymap().action_for(chord("ctrl+s")),
489 Some((crate::keymap::Scope::App, "save")),
490 "the keymap came from text"
491 );
492 assert_eq!(
493 env.keymap().action_for(chord("ctrl+q")),
494 Some((crate::keymap::Scope::Global, "quit")),
495 "the built-in keymap is still under it"
496 );
497 }
498
499 #[test]
500 fn a_missing_path_no_longer_stops_the_start_when_text_stands_in_for_it() {
501 let missing = std::env::temp_dir().join("quvyta-not-installed");
502 let dirs = AssetDirs {
503 themes: Some(missing.join("themes")),
504 icons: Some(missing.join("icons")),
505 locales: Some(missing.join("locales")),
506 locale_sources: vec![(
507 "en.toml".to_owned(),
508 "[meta]\nname = \"English\"\ncode = \"en\"\n[app]\ngreeting = \"Hello\"\n".to_owned(),
509 )],
510 keymap: Some(missing.join("keymap.toml")),
511 ..brand_sources()
512 };
513 let mut env = Env::load(&dirs).expect("the text compiled in stands in for the files");
514 env.set_theme("brand");
515 assert_eq!(env.theme().id(), "brand");
516 assert_eq!(env.keymap().action_for(chord("ctrl+s")), Some((crate::keymap::Scope::App, "save")));
517 assert_eq!(env.i18n().translate("app.greeting", &[]), "Hello");
518 for file in ["themes", "icons", "locales", "keymap.toml"] {
519 assert!(
520 env.diagnostics().iter().any(|problem| problem.to_string().contains(file)),
521 "the unreadable {file} is reported: {:?}",
522 env.diagnostics()
523 );
524 }
525 let alone = AssetDirs { keymap: Some(missing.join("keymap.toml")), ..AssetDirs::default() };
526 assert!(Env::load(&alone).is_err(), "without text to stand in for it a named file must be there");
527 }
528
529 #[test]
530 fn broken_text_sources_are_skipped_with_located_diagnostics_and_the_built_ins_still_work() {
531 let dirs = AssetDirs {
532 theme_sources: vec![("brand.toml".to_owned(), "[meta\n".to_owned())],
533 icon_sources: vec![("brand.toml".to_owned(), "[icons\n".to_owned())],
534 keymap_source: Some(("keymap.toml".to_owned(), "[app\n".to_owned())),
535 ..AssetDirs::default()
536 };
537 let mut env = Env::load(&dirs).expect("broken text is never an I/O error");
538 for file in ["brand.toml", "keymap.toml"] {
539 assert!(
540 env.diagnostics().iter().any(|problem| problem
541 .location
542 .as_ref()
543 .is_some_and(|at| at.file == file && at.line > 0 && at.column > 0)),
544 "{file} is reported with file, line and column: {:?}",
545 env.diagnostics()
546 );
547 }
548 assert_eq!(env.theme().id(), "monochrome");
549 env.set_glyph_mode(GlyphMode::Unicode);
550 assert_eq!(env.icons().glyph("check"), "✓", "the built-in icon set is still there");
551 assert_eq!(env.keymap().action_for(chord("ctrl+q")), Some((crate::keymap::Scope::Global, "quit")));
552 env.set_theme("brand");
553 assert_eq!(env.theme().id(), "monochrome", "an unusable theme falls back to the default");
554 }
555
556 fn vars(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
558 let vars: Vec<(String, String)> = vars.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect();
559 move |name| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone())
560 }
561
562 fn env_with(variables: &[(&str, &str)]) -> Env {
564 let mut env = Env::builtin();
565 env.force_reduced_motion(forced_reduced_motion(vars(variables)));
566 env
567 }
568
569 fn saved_reduced_motion(reduced: bool) -> crate::storage::Settings {
570 let mut settings = crate::storage::Settings::in_memory();
571 settings.set(crate::storage::Settings::REDUCED_MOTION, reduced);
572 settings
573 }
574
575 #[test]
576 fn reads_the_reduced_motion_variable() {
577 assert_eq!(forced_reduced_motion(vars(&[])), None);
578 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "")])), None);
579 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])), Some(false));
580 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])), Some(true));
581 assert_eq!(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "yes")])), Some(true));
582 }
583
584 #[test]
585 fn tells_whether_the_variable_decides() {
586 assert!(!Env::builtin().reduced_motion_forced());
587 assert!(!env_with(&[]).reduced_motion_forced());
588 assert!(!env_with(&[("QUVYTA_REDUCED_MOTION", "")]).reduced_motion_forced(), "empty is unset");
589 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
590 env.apply_settings(&saved_reduced_motion(false));
591 assert!(env.reduced_motion_forced() && env.reduced_motion());
592 let env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
593 assert!(env.reduced_motion_forced() && !env.reduced_motion(), "forced to keep motion counts too");
594 }
595
596 #[test]
597 fn the_variable_wins_over_the_saved_setting_in_both_directions() {
598 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "1")]);
599 env.apply_settings(&saved_reduced_motion(false));
600 assert!(env.reduced_motion(), "the shell asked for reduced motion; the saved `false` loses");
601 let mut env = env_with(&[("QUVYTA_REDUCED_MOTION", "0")]);
602 env.apply_settings(&saved_reduced_motion(true));
603 assert!(!env.reduced_motion(), "the shell asked for motion; the saved `true` loses");
604 let mut env = env_with(&[]);
605 env.apply_settings(&saved_reduced_motion(true));
606 assert!(env.reduced_motion(), "without the variable the saved setting decides");
607 }
608
609 #[test]
610 fn the_variable_wins_when_the_setting_was_applied_first() {
611 let mut env = Env::builtin();
612 env.apply_settings(&saved_reduced_motion(false));
613 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "1")])));
614 assert!(env.reduced_motion());
615 let mut env = Env::builtin();
616 env.apply_settings(&saved_reduced_motion(true));
617 env.force_reduced_motion(forced_reduced_motion(vars(&[("QUVYTA_REDUCED_MOTION", "0")])));
618 assert!(!env.reduced_motion());
619 let mut env = Env::builtin();
620 env.apply_settings(&saved_reduced_motion(true));
621 env.force_reduced_motion(forced_reduced_motion(vars(&[])));
622 assert!(env.reduced_motion(), "an unset variable leaves the saved choice alone");
623 }
624
625 #[test]
626 fn the_variable_wins_over_settings_applied_as_commands_after_start() {
627 use crate::runtime::{App, Command, Harness};
628 use crate::widget::View;
629
630 struct Saved(crate::storage::Settings);
631 impl App for Saved {
632 type Msg = ();
633 fn update(&mut self, (): ()) -> Command<()> {
634 self.0.apply()
635 }
636 fn view(&self, _: &mut View<'_, ()>) {}
637 }
638
639 let mut h =
640 Harness::with_env(Saved(saved_reduced_motion(false)), env_with(&[("QUVYTA_REDUCED_MOTION", "1")]), 10, 1);
641 h.send(());
642 assert!(h.env().reduced_motion());
643 let mut h =
644 Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[("QUVYTA_REDUCED_MOTION", "0")]), 10, 1);
645 h.send(());
646 assert!(!h.env().reduced_motion());
647 let mut h = Harness::with_env(Saved(saved_reduced_motion(true)), env_with(&[]), 10, 1);
648 h.send(());
649 assert!(h.env().reduced_motion(), "without the variable the saved setting decides");
650 }
651
652 #[test]
653 fn switches_theme_locale_and_icons() {
654 let mut env = Env::builtin();
655 assert_eq!(env.theme().id(), "monochrome");
656 env.set_theme("nordic");
657 assert_eq!(env.theme().id(), "nordic");
658 env.set_theme("missing");
659 assert_eq!(env.theme().id(), "monochrome");
660 assert!(env.diagnostics().iter().any(|d| d.message.contains("`missing`")));
661 env.set_locale("tr");
662 assert_eq!(env.i18n().active(), "tr");
663 env.set_icon_mode(IconMode::Ascii);
664 assert_eq!(env.icons().glyph("check"), "v");
665 env.set_glyph_mode(GlyphMode::Unicode);
666 assert_eq!(env.icons().glyph("check"), "✓");
667 }
668}