//! Color schemes (schemes.rs): chosen from the menu in the sidebar's //! corner, narrowed by the light/dark filter, kept with the viewer's //! preferences. That every scheme paints the same words and figures in //! the same places, each readable, is checked inside the crate //! (schemes.rs, `painted`), where what is painted can be read. use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use ledger_gui::nav::Route; use ledger_gui::{App, data}; use std::collections::HashMap; struct TestDir(std::path::PathBuf); impl Drop for TestDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn demo(tag: &str) -> (TestDir, data::Source) { let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); ledger_gui::demo::build(&dir, today).unwrap(); let source = data::Source { data_dir: dir.join("books-data"), today: Some(today), }; (TestDir(dir), source) } /// eframe's preferences, in memory. #[derive(Default)] struct Prefs(HashMap); impl eframe::Storage for Prefs { fn get_string(&self, key: &str) -> Option { self.0.get(key).cloned() } fn set_string(&mut self, key: &str, value: String) { self.0.insert(key.to_string(), value); } fn remove_string(&mut self, key: &str) { self.0.remove(key); } fn flush(&mut self) {} } #[test] fn a_scheme_is_chosen_from_the_menu_and_kept() { let (_dir, source) = demo("schemes-menu"); let books = data::load_once(&source).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(1440.0, 920.0)) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); h.run_steps(3); assert_eq!(h.state().scheme_key(), "Wanderbooks/Light"); // The button names the scheme in force; it opens the menu. h.get_by_label_contains("Wanderbooks Light").click(); h.run_steps(2); // Narrowed to dark schemes: a light variant isn't offered. (The // filter's "Dark" comes first; the viewer's own family, under the // pointer, may have opened its flyout with its own "Dark".) h.query_all_by_label("Dark").next().unwrap().click(); h.run_steps(2); h.get_by_label_contains("Catppuccin").hover(); h.run_steps(3); assert!( h.query_by_label("Latte").is_none(), "a light variant offered" ); h.get_by_label("Mocha").click(); h.run_steps(3); assert_eq!(h.state().scheme_key(), "Catppuccin/Mocha"); // Worn: Mocha's own page color, and dark. let visuals = h.ctx.global_style().visuals.clone(); assert!(visuals.dark_mode); assert_eq!( visuals.panel_fill, egui::Color32::from_rgb(0x18, 0x18, 0x25) ); // Kept with the viewer's preferences, the filter too. let mut prefs = Prefs::default(); eframe::App::save(h.state_mut(), &mut prefs); assert_eq!(prefs.0["scheme"], "Catppuccin/Mocha"); assert_eq!(prefs.0["scheme_filter"], "Dark"); // Opened again: only the family in force carries the check, and the // variant in force is selected under its name alone. let check = egui_phosphor::regular::CHECK; h.get_by_label_contains("Catppuccin Mocha").click(); h.run_steps(2); // A family's row (not the scheme button, which names the scheme). let family = |h: &Harness<'_, App>, name: &str| -> String { h.query_all_by_label_contains(name) .map(|n| n.accesskit_node().label().unwrap_or_default().to_string()) .find(|l| !l.contains(egui_phosphor::regular::PALETTE)) .unwrap_or_else(|| panic!("no {name} row")) }; let label = family(&h, "Catppuccin"); assert!(label.contains(check), "{label:?}"); let label = family(&h, "Gruvbox"); assert!(!label.contains(check), "{label:?}"); h.query_all_by_label_contains("Catppuccin") .find(|n| { !n.accesskit_node() .label() .unwrap_or_default() .contains(egui_phosphor::regular::PALETTE) }) .unwrap() .hover(); h.run_steps(3); let on = |name: &str| format!("{:?}", h.get_by_label(name).accesskit_node().toggled()); assert_eq!(on("Mocha"), "Some(True)"); assert_eq!(on("Macchiato"), "Some(False)"); } /// The families in the menu, alphabetically (owner, 2026-10-19). const FAMILIES: [&str; 21] = [ "Ayu", "Catppuccin", "Cyberdream", "Dracula", "Everforest", "GitHub", "Gruvbox", "Kanagawa", "Material", "Melange", "Monokai Pro", "Night Owl", "Nightfox", "Nord", "One Dark", "Oxocarbon", "Rosé Pine", "Solarized", "Sonokai", "Tokyo Night", "Wanderbooks", ]; /// A family's row in the open menu (not the scheme button, which names /// the scheme in force). fn family_row<'a>(h: &'a Harness<'_, App>, name: &'a str) -> egui_kittest::Node<'a> { h.query_all_by_label_contains(name) .find(|n| { let label = n.accesskit_node().label().unwrap_or_default().to_string(); !label.contains(egui_phosphor::regular::PALETTE) // The check (in force) and the flyout's arrow aside. && label.trim_matches(|c: char| !c.is_alphanumeric()) == name }) .unwrap_or_else(|| panic!("no {name} row")) } /// Owner, 2026-10-19: Ayu was out of reach (the list scrolled, with no /// sign of it), the families weren't alphabetical, and a click on a /// variant's colors did nothing. Every family is on screen at once, in /// order, in a short window or a tall one, and a click anywhere on a /// variant's row chooses it. #[test] fn every_family_is_on_screen_in_order_and_a_whole_row_chooses() { let (_dir, source) = demo("schemes-reach"); for (w, h_) in [(1440.0, 920.0), (1000.0, 480.0)] { let books = data::load_once(&source).unwrap(); let mut h = Harness::builder() .with_size(egui::vec2(w, h_)) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); h.run_steps(3); h.get_by_label_contains("Wanderbooks Light").click(); h.run_steps(3); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h_)); let mut last: Option = None; for family in FAMILIES { let r = family_row(&h, family).rect(); assert!( screen.contains_rect(r), "{w}x{h_}: {family} at {r:?} is off screen" ); // In order: down a column, then on to the next one. if let Some(prev) = last { assert!( r.top() > prev.top() || r.left() > prev.left(), "{w}x{h_}: {family} comes before the family above it" ); } last = Some(r); } // Ayu's flyout; a click on Mirage's colors, not its name. family_row(&h, "Ayu").hover(); h.run_steps(3); let row = h.get_by_label("Mirage").rect(); let on_colors = egui::pos2(row.left() + 20.0, row.center().y); h.hover_at(on_colors); h.run_steps(1); h.drag_at(on_colors); h.run_steps(1); h.drop_at(on_colors); h.run_steps(3); assert_eq!(h.state().scheme_key(), "Ayu/Mirage", "{w}x{h_}"); assert!(row.width() >= 180.0, "the row is the button: {row:?}"); } } /// Owner, 2026-10-19: a scheme hovered in the menu for a moment is worn /// live; off its row, the next one hovered, or the one in force, is worn /// again; nothing is chosen until a click. #[test] fn a_hovered_scheme_is_shown_live_and_only_a_click_keeps_it() { let (_dir, source) = demo("schemes-preview"); let books = data::load_once(&source).unwrap(); // Frames 10 ms apart, to see the 50 ms wait. let mut h = Harness::builder() .with_size(egui::vec2(1440.0, 920.0)) .with_step_dt(0.01) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); h.run_steps(3); let page = |h: &Harness<'_, App>| h.ctx.global_style().visuals.panel_fill; let light = page(&h); h.get_by_label_contains("Wanderbooks Light").click(); h.run_steps(3); family_row(&h, "Ayu").hover(); h.run_steps(3); // A variant's row (the filter has a "Dark" too: the flyout's is last). let rest_on = |h: &mut Harness<'_, App>, name: &str| { let r = h.query_all_by_label(name).last().unwrap().rect(); h.hover_at(egui::pos2(r.left() + 20.0, r.center().y)); }; rest_on(&mut h, "Mirage"); h.run_steps(2); assert_eq!( h.state().shown_scheme_key(), "Wanderbooks/Light", "not before 50 ms" ); h.run_steps(8); assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage"); assert_eq!( h.state().scheme_key(), "Wanderbooks/Light", "only shown, not chosen" ); let mirage = page(&h); assert_ne!(mirage, light); // On to the next one hovered. rest_on(&mut h, "Dark"); h.run_steps(10); assert_eq!(h.state().shown_scheme_key(), "Ayu/Dark"); // Off every row: back to the one in force. let filter = h.query_all_by_label("All").next().unwrap().rect(); h.hover_at(filter.center()); h.run_steps(10); assert_eq!(h.state().shown_scheme_key(), "Wanderbooks/Light"); assert_eq!(page(&h), light); // Closed while one is shown: the one in force, at once; nothing kept. family_row(&h, "Ayu").hover(); h.run_steps(3); rest_on(&mut h, "Mirage"); h.run_steps(10); assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage"); h.key_press(egui::Key::Escape); h.run_steps(2); assert_eq!(h.state().shown_scheme_key(), "Wanderbooks/Light"); assert_eq!(h.state().scheme_key(), "Wanderbooks/Light"); assert_eq!(page(&h), light); let mut prefs = Prefs::default(); eframe::App::save(h.state_mut(), &mut prefs); assert_eq!( prefs.0["scheme"], "Wanderbooks/Light", "a preview is never kept" ); // A click keeps it. h.get_by_label_contains("Wanderbooks Light").click(); h.run_steps(3); family_row(&h, "Ayu").hover(); h.run_steps(3); rest_on(&mut h, "Mirage"); h.run_steps(10); h.get_by_label("Mirage").click(); h.run_steps(3); assert_eq!(h.state().scheme_key(), "Ayu/Mirage"); assert_eq!(h.state().shown_scheme_key(), "Ayu/Mirage"); assert_eq!(page(&h), mirage); } /// Pictures of the menu and of a few schemes, for a person to look at /// (`SHOT_DIR=… cargo test -p ledger-gui --test schemes -- --ignored`). #[test] #[ignore] fn pictures() { let out = std::path::PathBuf::from(std::env::var("SHOT_DIR").expect("SHOT_DIR")); std::fs::create_dir_all(&out).unwrap(); let (_dir, source) = demo("schemes-shots"); let shot_at = |size: egui::Vec2, key: &str, menu: Option<&str>, name: &str| { let books = data::load_once(&source).unwrap(); let mut h = Harness::builder() .with_size(size) .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); h.run_steps(2); let ctx = h.ctx.clone(); h.state_mut().set_scheme(&ctx, key); h.run_steps(4); if let Some(family) = menu { let label = h.state().scheme_key().replace('/', " "); h.get_by_label_contains(label.split(' ').next().unwrap()) .click(); h.run_steps(3); h.get_by_label_contains(family).hover(); h.run_steps(4); } h.render() .unwrap() .save(out.join(format!("{name}.png"))) .unwrap(); }; let shot = |key: &str, menu: Option<&str>, name: &str| { shot_at(egui::vec2(1440.0, 920.0), key, menu, name) }; shot("Wanderbooks/Light", Some("Ayu"), "menu"); shot_at( egui::vec2(1000.0, 480.0), "Wanderbooks/Light", Some("Melange"), "menu-short", ); for key in [ "Catppuccin/Mocha", "Catppuccin/Latte", "Tokyo Night/Night", "Gruvbox/Dark", "Ayu/Light", "Kanagawa/Lotus", "Nord/Nord", "Solarized/Light", ] { shot(key, None, &key.replace(['/', ' '], "-")); } }