//! The Options window's OK / Apply / Close. Settings still change the moment //! a control is touched, so a theme, a cursor size or a language is seen at //! once — but nothing is committed until Apply or OK. Close (and the window's //! × or Esc) puts everything back the way it was when the window opened, or //! when Apply was last pressed, asking first when something would be lost. //! //! The commit point is a snapshot of the persisted configuration plus the //! few drawing variables the window edits (the 3D Modeling page writes into //! the active drawing's header); "dirty" is simply "the current snapshot //! differs from the saved one", so every control the window gains later is //! covered without registering it here. use super::OpenCADStudio; /// The drawing-header variables the Options window edits, for the active /// tab, with the tab's modified flag so a revert leaves it as it found it. #[derive(Clone, PartialEq)] struct HeaderVars { isolines: i16, display_silhouette: bool, surface_u_density: i16, surface_v_density: i16, surface_type: i16, record_solid_history: bool, show_solid_history: i16, tab_dirty: bool, } /// What the Options window can change, as it stood at the last commit. #[derive(Clone, PartialEq)] pub(super) struct OptionsSnapshot { config: super::config::AppConfig, header: Option, textfill: bool, } impl OpenCADStudio { fn options_snapshot(&self) -> OptionsSnapshot { let header = self.tabs.get(self.active_tab).map(|tab| { let h = &tab.scene.document.header; HeaderVars { isolines: h.isolines, display_silhouette: h.display_silhouette, surface_u_density: h.surface_u_density, surface_v_density: h.surface_v_density, surface_type: h.surface_type, record_solid_history: h.record_solid_history, show_solid_history: h.show_solid_history, tab_dirty: tab.dirty, } }); OptionsSnapshot { config: self.current_config(), header, textfill: crate::scene::text::sdf_atlas::textfill(), } } /// The window opens: remember the state to come back to. pub(super) fn options_open(&mut self) { self.active_modal = Some(super::ModalKind::Options); self.options_saved = Some(self.options_snapshot()); self.options_close_confirm = false; } /// Whether anything changed since the window opened or Apply was pressed. pub(super) fn options_dirty(&self) -> bool { match &self.options_saved { Some(saved) => *saved != self.options_snapshot(), None => false, } } /// Apply: the current state becomes the state to come back to, and is /// written to disk. pub(super) fn options_apply(&mut self) { self.options_saved = Some(self.options_snapshot()); self.options_close_confirm = false; self.save_config(); } /// Close: put everything back to the last commit. The caller closes the /// window afterwards. pub(super) fn options_discard(&mut self) { self.options_close_confirm = false; if let Some(saved) = self.options_saved.take() { self.options_revert_to(saved); } } /// Forget the commit point without reverting (OK, or a close that had /// nothing to lose). pub(super) fn options_forget(&mut self) { self.options_saved = None; self.options_close_confirm = false; } fn options_revert_to(&mut self, saved: OptionsSnapshot) { let current = self.options_snapshot(); if current == saved { return; } if current.config != saved.config { // `apply_config` distributes every persisted preference, the // theme and the language the way start-up does. self.apply_config(saved.config.clone()); } if current.textfill != saved.textfill { // The glyph atlas was re-baked for the other setting. self.invalidate_text_everywhere(); } if let (Some(now), Some(then)) = (¤t.header, &saved.header) { if now != then { let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { let h = &mut tab.scene.document.header; h.isolines = then.isolines; h.display_silhouette = then.display_silhouette; h.surface_u_density = then.surface_u_density; h.surface_v_density = then.surface_v_density; h.surface_type = then.surface_type; h.record_solid_history = then.record_solid_history; h.show_solid_history = then.show_solid_history; tab.dirty = then.tab_dirty; if now.show_solid_history != then.show_solid_history || now.display_silhouette != then.display_silhouette { tab.scene.bump_geometry(); } } if now.isolines != then.isolines { self.isolines_awaiting_regen = false; self.regenerate_meshes(); } } } self.save_config(); } } #[cfg(test)] mod tests { use crate::app::{settings, Message, OpenCADStudio}; fn app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); app } #[test] fn changes_show_at_once_but_close_puts_them_back() { let mut app = app(); let _ = app.update(Message::OptionsOpen); assert!(!app.options_dirty()); let before = app.zoom_factor; let _ = app.update(Message::ZoomFactorChanged(before + 7)); assert_eq!(app.zoom_factor, before + 7, "the change is live"); assert!(app.options_dirty()); // The first Close asks; the discard puts the value back. let _ = app.update(Message::OptionsClose); assert!(app.options_close_confirm); assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options)); let _ = app.update(Message::OptionsCloseDiscard); assert_eq!(app.zoom_factor, before); assert_eq!(app.active_modal, None); assert!(!app.options_dirty()); } #[test] fn apply_makes_the_change_the_new_baseline() { let mut app = app(); let _ = app.update(Message::OptionsOpen); let before = app.zoom_factor; let _ = app.update(Message::ZoomFactorChanged(before + 7)); let _ = app.update(Message::OptionsApply); assert!(!app.options_dirty(), "Apply commits"); let _ = app.update(Message::ZoomFactorChanged(before + 9)); let _ = app.update(Message::OptionsClose); let _ = app.update(Message::OptionsCloseDiscard); assert_eq!( app.zoom_factor, before + 7, "back to the applied value, not the original" ); } #[test] fn ok_commits_and_closes_and_a_clean_close_asks_nothing() { let mut app = app(); let _ = app.update(Message::OptionsOpen); let before = app.zoom_factor; let _ = app.update(Message::ZoomFactorChanged(before + 7)); let _ = app.update(Message::OptionsOk); assert_eq!(app.active_modal, None); assert_eq!(app.zoom_factor, before + 7); let _ = app.update(Message::OptionsOpen); let _ = app.update(Message::OptionsClose); assert_eq!(app.active_modal, None, "nothing to lose, nothing to ask"); assert!(!app.options_close_confirm); // Keep editing returns to the window with the change intact. let _ = app.update(Message::OptionsOpen); let _ = app.update(Message::ZoomFactorChanged(before + 11)); let _ = app.update(Message::CloseModal); assert!(app.options_close_confirm, "the × asks like Close does"); let _ = app.update(Message::OptionsCloseKeep); assert_eq!(app.active_modal, Some(crate::app::ModalKind::Options)); assert_eq!(app.zoom_factor, before + 11); } #[test] fn drawing_variables_of_the_modeling_page_are_reverted_too() { let mut app = app(); let i = app.active_tab; let isolines = app.tabs[i].scene.document.header.isolines; let silhouette = app.tabs[i].scene.document.header.display_silhouette; let _ = app.update(Message::OptionsOpen); let _ = app.update(Message::IsolinesChanged(isolines + 4)); let _ = app.update(Message::DispSilhChanged(!silhouette)); assert!(app.options_dirty()); assert!(app.tabs[i].dirty); let _ = app.update(Message::OptionsClose); let _ = app.update(Message::OptionsCloseDiscard); let h = &app.tabs[i].scene.document.header; assert_eq!(h.isolines, isolines); assert_eq!(h.display_silhouette, silhouette); assert!(!app.tabs[i].dirty, "the drawing is as unmodified as it was"); } /// The Options window is the surface that reaches past the range the /// ZOOMFACTOR system variable has, and its field is what reaches: a /// number above the variable's own ceiling is kept, one above what the /// application can store is held there, and text that is not yet a /// number leaves the setting alone so the field can be cleared and /// retyped. #[test] fn the_zoom_factor_field_reaches_past_the_system_variable() { let mut app = app(); let _ = app.update(Message::ZoomFactorInputChanged("250".into())); assert_eq!(app.zoom_factor, 250); let _ = app.update(Message::ZoomFactorInputChanged("600".into())); assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX); let _ = app.update(Message::ZoomFactorInputChanged(String::new())); assert_eq!( app.zoom_factor, settings::ZOOM_FACTOR_MAX, "a cleared field keeps the value it set" ); assert_eq!(app.zoom_factor_input, "", "while the field stays as typed"); let _ = app.update(Message::ZoomFactorInputChanged("nonsense".into())); assert_eq!(app.zoom_factor, settings::ZOOM_FACTOR_MAX); let _ = app.update(Message::ZoomFactorInputChanged("1".into())); assert_eq!( app.zoom_factor, settings::ZOOM_FACTOR_MIN, "the floor holds" ); } /// The slider and the field are two views of one setting: dragging the /// slider has to leave the number beside it reading what it just set, or /// the window shows two different answers for what the wheel does. #[test] fn the_zoom_factor_slider_and_field_stay_in_step() { let mut app = app(); let _ = app.update(Message::ZoomFactorInputChanged("250".into())); let _ = app.update(Message::ZoomFactorChanged(40)); assert_eq!(app.zoom_factor, 40); assert_eq!(app.zoom_factor_input, "40"); } /// Close puts a zoom factor back like every other preference — the /// field's text included, since it is edited in front of the value. #[test] fn closing_puts_the_zoom_factor_and_its_field_back() { let mut app = app(); let _ = app.update(Message::OptionsOpen); let before = app.zoom_factor; let _ = app.update(Message::ZoomFactorInputChanged("250".into())); assert!(app.options_dirty()); let _ = app.update(Message::OptionsClose); let _ = app.update(Message::OptionsCloseDiscard); assert_eq!(app.zoom_factor, before); assert_eq!(app.zoom_factor_input, before.to_string()); } }