//! Saved appearance preferences and live OS changes through the whole UI. use egui::{Theme, ThemePreference}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::{SheetApp, UiState, control, theme::Tokens}; use serde_json::{Value, json}; fn harness(ui: UiState) -> egui_kittest::Harness<'static, SheetApp> { let mut session = Session::new(); session.new_workbook(); let mut app = SheetApp::new(session, Default::default()); app.ui = ui; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state( |ui, app: &mut SheetApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); app.ui(ui); }, app, ); h.run_steps(4); h } fn preferences(h: &egui_kittest::Harness<'static, SheetApp>) -> Value { serde_json::to_value(&h.state().ui).unwrap() } fn system_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, theme: Option) { h.input_mut().system_theme = theme; h.run_steps(2); } fn set_mode(h: &mut egui_kittest::Harness<'static, SheetApp>, mode: &str) { let ctx = h.ctx.clone(); let control::Outcome::Done(response) = control::handle(h.state_mut(), &ctx, "engine.execute", &json!({"command": "view.theme", "params": {"mode": mode}})) else { panic!("engine.execute should return a response"); }; assert_eq!(response["ok"], true, "{response}"); h.run_steps(2); } fn select_ribbon_theme(h: &mut egui_kittest::Harness<'static, SheetApp>, label: &str) { // The View tab's first combo box is the display theme (the interface language follows it). h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("display theme combo").click(); h.run_steps(2); h.get_by_role_and_label(egui::accesskit::Role::Button, label).click(); h.run_steps(2); } fn assert_appearance(h: &egui_kittest::Harness<'static, SheetApp>, dark: bool) { let visuals = &h.ctx.global_style().visuals; let tokens = if dark { Tokens::dark() } else { Tokens::light() }; assert_eq!(visuals.dark_mode, dark); assert_eq!(visuals.panel_fill, tokens.window); assert_eq!(visuals.window_fill, tokens.menu_bg); assert_eq!(visuals.selection.bg_fill, tokens.accent_soft); } #[test] fn old_preferences_stay_manual_and_system_preference_survives_reload() { for dark in [false, true] { let ui: UiState = serde_json::from_value(json!({"dark": dark})).unwrap(); assert_eq!(ui.dark, dark); let mut h = harness(ui); assert_eq!(preferences(&h)["systemTheme"], false, "old saved settings remain manual"); system_theme(&mut h, Some(if dark { Theme::Light } else { Theme::Dark })); assert_appearance(&h, dark); } let mut h = harness(UiState::default()); set_mode(&mut h, "system"); system_theme(&mut h, Some(Theme::Dark)); let saved = serde_json::to_string(&h.state().ui).unwrap(); let mut restored = harness(serde_json::from_str(&saved).unwrap()); assert_eq!(preferences(&restored)["systemTheme"], true); system_theme(&mut restored, Some(Theme::Light)); assert_appearance(&restored, false); system_theme(&mut restored, Some(Theme::Dark)); assert_appearance(&restored, true); } #[test] fn system_tracks_live_os_changes_with_both_gridcraft_palettes_and_light_fallback() { let mut h = harness(UiState::default()); set_mode(&mut h, "system"); assert_eq!(h.ctx.options(|o| o.theme_preference), ThemePreference::System); // egui switches between these two style slots when the OS appearance changes. for (theme, tokens) in [(Theme::Light, Tokens::light()), (Theme::Dark, Tokens::dark())] { let style = h.ctx.style_of(theme); assert_eq!(style.visuals.panel_fill, tokens.window); assert_eq!(style.visuals.window_fill, tokens.menu_bg); assert_eq!(style.visuals.selection.bg_fill, tokens.accent_soft); } for (reported, dark) in [(Some(Theme::Dark), true), (Some(Theme::Light), false), (Some(Theme::Dark), true), (None, false)] { system_theme(&mut h, reported); assert_appearance(&h, dark); assert_eq!(preferences(&h)["systemTheme"], true); assert_eq!(h.ctx.theme(), if dark { Theme::Dark } else { Theme::Light }); } } #[test] fn ribbon_selects_system_and_fixed_modes_ignore_subsequent_os_changes() { let mut h = harness(UiState::default()); system_theme(&mut h, Some(Theme::Dark)); assert_appearance(&h, false); h.state_mut().run("ui.ribbonTab", json!({"tab": "View"})).unwrap(); h.run_steps(2); select_ribbon_theme(&mut h, "System"); assert_eq!(preferences(&h)["systemTheme"], true); assert_eq!(h.ctx.theme(), Theme::Dark); assert_appearance(&h, true); for (label, dark, preference) in [("Dark", true, ThemePreference::Dark), ("Light", false, ThemePreference::Light)] { select_ribbon_theme(&mut h, label); assert_eq!(preferences(&h)["systemTheme"], false); assert_eq!(h.ctx.options(|o| o.theme_preference), preference); for os in [Some(Theme::Dark), Some(Theme::Light), None] { system_theme(&mut h, os); assert_appearance(&h, dark); } } } #[test] fn invalid_mode_preserves_preferences_and_legacy_overrides_leave_system_mode() { let mut h = harness(UiState::default()); set_mode(&mut h, "system"); system_theme(&mut h, Some(Theme::Dark)); let before = preferences(&h); assert!(h.state_mut().run("view.theme", json!({"mode": "sepia"})).is_err()); h.run_steps(2); assert_eq!(preferences(&h), before); assert_appearance(&h, true); // The old toggle flips the displayed appearance, even when System chose it. h.state_mut().run("view.darkMode", json!({})).unwrap(); h.run_steps(2); assert_eq!(preferences(&h)["systemTheme"], false); assert_appearance(&h, false); set_mode(&mut h, "system"); system_theme(&mut h, Some(Theme::Light)); h.state_mut().run("view.darkMode", json!({"on": true})).unwrap(); h.run_steps(2); assert_eq!(preferences(&h)["systemTheme"], false); assert_appearance(&h, true); set_mode(&mut h, "system"); system_theme(&mut h, Some(Theme::Dark)); let ctx = h.ctx.clone(); let control::Outcome::Done(response) = control::handle(h.state_mut(), &ctx, "ui.set", &json!({"dark": false})) else { panic!("ui.set should return a response"); }; assert_eq!(response["ok"], true); h.run_steps(2); assert_eq!(preferences(&h)["systemTheme"], false); assert_appearance(&h, false); system_theme(&mut h, Some(Theme::Dark)); assert_appearance(&h, false); } #[test] fn title_bar_switch_and_file_menu_choose_the_theme() { // #194: one click in the title bar switches light/dark; File › Display theme offers all three. let mut h = harness(UiState::default()); system_theme(&mut h, Some(Theme::Light)); assert_appearance(&h, false); h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click(); h.run_steps(2); assert_eq!(preferences(&h)["dark"], true); assert_eq!(preferences(&h)["systemTheme"], false); assert_appearance(&h, true); h.get_by_role_and_label(egui::accesskit::Role::CheckBox, "Dark Mode").click(); h.run_steps(2); assert_appearance(&h, false); // File opens Backstage; its Options page holds the Office Theme choice (#331). h.get_by_label("File").click(); h.run_steps(3); h.get_by_role_and_label(egui::accesskit::Role::Button, "Options").click(); h.run_steps(3); // The first combo box on the page is Display theme. h.get_all_by_role(egui::accesskit::Role::ComboBox).next().expect("theme combo").click(); h.run_steps(3); h.get_by_label("System").click(); h.run_steps(3); assert_eq!(preferences(&h)["systemTheme"], true); system_theme(&mut h, Some(Theme::Dark)); assert_appearance(&h, true); }