Merge pull request #1353 from HakanSeven12/ui/options-ok-apply-close

feat(options): OK / Apply / Close with a discard guard

# Conflicts:
#	src/app/view/modal.rs
#	src/ui/window/options.rs
This commit is contained in:
Hakan Seven committed 2026-09-19 22:59:56 +03:00
commit 1c0ef9d7a0
8 files changed
+365 -55

No files matched your search

+15
View File
@@ -18,6 +18,7 @@ mod dimension_preview_tests;
mod document;
mod drafting_settings;
pub(crate) mod expr_eval;
mod options_session;
mod find_replace;
pub(crate) mod helpers;
mod history;
@@ -570,6 +571,10 @@ pub(super) struct OpenCADStudio {
dyn_input: bool,
/// Currently visible page in the application Options dialog.
options_tab: crate::ui::window::options::OptionsTab,
/// The Options window's commit point (see `options_session`).
options_saved: Option<options_session::OptionsSnapshot>,
/// Close was pressed with unapplied changes; the discard guard is up.
options_close_confirm: bool,
spacemouse: crate::input::spacemouse::Service,
spacemouse_preferences: crate::input::spacemouse::Preferences,
spacemouse_paused: bool,
@@ -2860,6 +2865,14 @@ pub enum Message {
DraftingSettingsClose,
DraftingSettingsCloseDiscard,
DraftingSettingsCloseKeep,
/// Options window: commit the changes made so far.
OptionsApply,
/// Options window: commit and close.
OptionsOk,
/// Options window: close, asking first when changes would be lost.
OptionsClose,
OptionsCloseDiscard,
OptionsCloseKeep,
AutoConstrainSelectRow(usize),
AutoConstrainToggleKind(settings::AutoConstraintKind),
AutoConstrainMoveUp,
@@ -3900,6 +3913,8 @@ impl OpenCADStudio {
grid_beyond_limits: true,
dyn_input: true,
options_tab: crate::ui::window::options::OptionsTab::General,
options_saved: None,
options_close_confirm: false,
spacemouse: {
let service = crate::input::spacemouse::Service::default();
service.set_actions(navigation::actions());
+225
View File
@@ -0,0 +1,225 @@
//! 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<HeaderVars>,
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)) = (&current.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::{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");
}
}
+37 -1
View File
@@ -177,6 +177,10 @@ impl OpenCADStudio {
self.drafting_settings_state = None;
self.drafting_settings_saved = None;
}
if self.active_modal == Some(Options) {
self.options_saved = None;
self.options_close_confirm = false;
}
#[cfg(not(target_arch = "wasm32"))]
if self.active_modal == Some(FileInUse) {
self.pending_save_failure = None;
@@ -7065,7 +7069,35 @@ impl OpenCADStudio {
}
// ── Options / About windows ───────────────────────────────────
Message::OptionsOpen => {
self.active_modal = Some(super::ModalKind::Options);
self.options_open();
Task::none()
}
Message::OptionsApply => {
self.options_apply();
Task::none()
}
Message::OptionsOk => {
self.options_apply();
self.options_forget();
self.close_active_modal();
Task::none()
}
Message::OptionsClose => {
if !self.options_close_confirm && self.options_dirty() {
self.options_close_confirm = true;
return Task::none();
}
self.options_discard();
self.close_active_modal();
Task::none()
}
Message::OptionsCloseDiscard => {
self.options_discard();
self.close_active_modal();
Task::none()
}
Message::OptionsCloseKeep => {
self.options_close_confirm = false;
Task::none()
}
@@ -7682,6 +7714,10 @@ impl OpenCADStudio {
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
return self.update(Message::RecoveryDecline);
}
// The Options window's × and Esc behave like its Close button.
if self.active_modal == Some(super::ModalKind::Options) {
return self.update(Message::OptionsClose);
}
// Closing the shortcut editor with un-applied rows needs an
// explicit discard; a second close attempt (or the overlay's
// Discard button) goes through.
+8 -2
View File
@@ -265,7 +265,10 @@ impl OpenCADStudio {
},
)
}
super::super::ModalKind::Options => sized_flow(
super::super::ModalKind::Options => {
let dirty = self.options_dirty();
let close_confirm = self.options_close_confirm;
sized_flow(
ex,
880,
620,
@@ -361,10 +364,13 @@ impl OpenCADStudio {
&self.paper_bg_input,
&self.desk_bg_input,
self.bg_picker,
dirty,
close_confirm,
flow,
)
},
),
)
}
super::super::ModalKind::DraftingSettings => {
let state = self.drafting_settings_state.as_ref();
let dirty = self.drafting_settings_dirty();