diff --git a/src/app/mod.rs b/src/app/mod.rs index 22f3a8d1e..9a5d6886b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -391,6 +391,10 @@ pub(super) struct OpenCADStudio { style_rename: Option, /// Edit buffer for the inline rename text input. style_rename_buf: String, + /// Active style-manager transaction. Edits mutate the document live for an + /// in-dialog preview but only persist on Apply; closing without Apply + /// restores this snapshot. `None` when no style manager is staging. + style_stage: Option, // ── TextStyle Font Browser ──────────────────────────────────────────── textstyle_selected: String, @@ -1390,6 +1394,7 @@ impl OpenCADStudio { // TextStyle font browser style_rename: None, style_rename_buf: String::new(), + style_stage: None, textstyle_selected: "Standard".to_string(), textstyle_font: String::new(), textstyle_width: "1.0".to_string(), diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index 55cc285bd..47e41df07 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -341,15 +341,19 @@ impl OpenCADStudio { // ── Public operations (called by the message handlers) ───────────────── + // Note: the structural ops below mutate the document live (so the dialog + // shows a preview) but do NOT mark the tab dirty, push undo, or rebuild the + // drawing. Those side effects are deferred to `style_stage_commit` (Apply); + // closing the window without Apply calls `style_stage_discard`, which + // restores the snapshot taken when the manager opened. + pub(super) fn style_new(&mut self, kind: StyleKind) { let i = self.active_tab; let name = self.unique_new_name(kind); - self.push_undo_snapshot(i, "STYLE NEW"); let h = self.tabs[i].scene.document.allocate_handle(); self.insert_default_style(kind, &name, h); self.set_style_selected(kind, name.clone()); self.load_style_bufs(kind); - self.tabs[i].dirty = true; self.after_style_change(kind); self.command_line .push_output(&format!("Style '{name}' created.")); @@ -359,28 +363,24 @@ impl OpenCADStudio { let i = self.active_tab; let src = self.style_selected(kind); let name = self.unique_suffixed_name(kind, &src); - self.push_undo_snapshot(i, "STYLE COPY"); let h = self.tabs[i].scene.document.allocate_handle(); if !self.clone_style_as(kind, &src, &name, h) { return; } self.set_style_selected(kind, name.clone()); self.load_style_bufs(kind); - self.tabs[i].dirty = true; self.after_style_change(kind); self.command_line .push_output(&format!("Style '{name}' created.")); } pub(super) fn style_delete(&mut self, kind: StyleKind) { - let i = self.active_tab; let name = self.style_selected(kind); if name.eq_ignore_ascii_case("Standard") { self.command_line .push_error("Cannot delete the Standard style."); return; } - self.push_undo_snapshot(i, "STYLE DEL"); if !self.remove_style_storage(kind, &name) { return; } @@ -391,7 +391,6 @@ impl OpenCADStudio { .unwrap_or_else(|| "Standard".to_string()); self.set_style_selected(kind, first); self.load_style_bufs(kind); - self.tabs[i].dirty = true; self.after_style_change(kind); self.command_line .push_output(&format!("Style '{name}' deleted.")); @@ -408,7 +407,6 @@ impl OpenCADStudio { /// Commit the inline rename. No-op (with feedback) on empty / unchanged / /// colliding names, and the Standard style cannot be renamed. pub(super) fn style_rename_commit(&mut self, kind: StyleKind) { - let i = self.active_tab; let Some(old) = self.style_rename.take() else { return; }; @@ -427,13 +425,11 @@ impl OpenCADStudio { .push_error(&format!("Style '{new}' already exists.")); return; } - self.push_undo_snapshot(i, "STYLE RENAME"); self.rename_style_storage(kind, &old, &new); if self.style_selected(kind).eq_ignore_ascii_case(&old) { self.set_style_selected(kind, new.clone()); } self.load_style_bufs(kind); - self.tabs[i].dirty = true; self.after_style_change(kind); self.command_line .push_output(&format!("Renamed '{old}' → '{new}'.")); @@ -443,6 +439,139 @@ impl OpenCADStudio { self.style_rename = None; self.style_rename_buf.clear(); } + + // ── Staging: nothing persists until Apply ────────────────────────────── + // + // A style manager is a transaction. When it opens we snapshot the style + // tables / objects / current pointers; every New / Copy / Delete / Rename / + // Set Current / property edit mutates the live document for an in-dialog + // preview, but the tab stays clean and the drawing is not rebuilt. Apply + // commits (marks dirty, pushes one undo entry, rebuilds); closing the + // window without Apply discards by restoring the snapshot. + + /// Snapshot the style-related document state into a transferable record. + fn capture_style_state(&self) -> StyleStateSnapshot { + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let style_objects = doc + .objects + .iter() + .filter(|(_, o)| { + matches!( + o, + ObjectType::TableStyle(_) + | ObjectType::MLineStyle(_) + | ObjectType::MultiLeaderStyle(_) + ) + }) + .map(|(&h, o)| (h, o.clone())) + .collect(); + StyleStateSnapshot { + text_styles: doc.text_styles.clone(), + dim_styles: doc.dim_styles.clone(), + style_objects, + current_text: doc.header.current_text_style_name.clone(), + current_dim: doc.header.current_dimstyle_name.clone(), + multiline_style: doc.header.multiline_style.clone(), + active_table: self.ribbon.active_table_style.clone(), + active_mleader: self.ribbon.active_mleader_style.clone(), + tab_active_mleader: self.tabs[i].active_mleader_style.clone(), + } + } + + /// Overwrite the live style state with a snapshot (used by commit's undo + /// dance and by discard). + fn restore_style_state(&mut self, snap: &StyleStateSnapshot) { + let i = self.active_tab; + let doc = &mut self.tabs[i].scene.document; + doc.text_styles = snap.text_styles.clone(); + doc.dim_styles = snap.dim_styles.clone(); + doc.objects.retain(|_, o| { + !matches!( + o, + ObjectType::TableStyle(_) + | ObjectType::MLineStyle(_) + | ObjectType::MultiLeaderStyle(_) + ) + }); + for (h, o) in &snap.style_objects { + doc.objects.insert(*h, o.clone()); + } + doc.header.current_text_style_name = snap.current_text.clone(); + doc.header.current_dimstyle_name = snap.current_dim.clone(); + doc.header.multiline_style = snap.multiline_style.clone(); + self.ribbon.active_table_style = snap.active_table.clone(); + self.ribbon.active_mleader_style = snap.active_mleader.clone(); + self.tabs[i].active_mleader_style = snap.tab_active_mleader.clone(); + } + + /// Begin a staging transaction for a freshly-opened style manager. + pub(super) fn style_stage_begin(&mut self) { + let dirty_at_open = self.tabs[self.active_tab].dirty; + let baseline = self.capture_style_state(); + self.style_stage = Some(StyleStage { + dirty_at_open, + baseline, + }); + } + + /// Commit the staged changes (Apply): make them permanent with a single + /// undo entry, mark the tab dirty, and rebuild the drawing. + pub(super) fn style_stage_commit(&mut self) { + let i = self.active_tab; + let Some(stage) = self.style_stage.take() else { + // No active transaction — still refresh the drawing so a bare Apply + // is harmless. + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.sync_ribbon_styles(); + return; + }; + // Capture the edited state, rewind to the baseline so the undo entry + // restores the pre-edit document, then re-apply the edits on top. + let edited = self.capture_style_state(); + self.restore_style_state(&stage.baseline); + self.push_undo_snapshot(i, "STYLE"); + self.restore_style_state(&edited); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.sync_ribbon_styles(); + // Re-baseline so further edits in the still-open window stage afresh. + self.style_stage = Some(StyleStage { + dirty_at_open: true, + baseline: edited, + }); + } + + /// Discard staged changes (window closed without Apply): restore the + /// snapshot taken when the manager opened. + pub(super) fn style_stage_discard(&mut self) { + let Some(stage) = self.style_stage.take() else { + return; + }; + self.restore_style_state(&stage.baseline); + self.tabs[self.active_tab].dirty = stage.dirty_at_open; + self.sync_ribbon_styles(); + } +} + +/// Snapshot of every document field a style manager can touch. +pub(super) struct StyleStateSnapshot { + text_styles: acadrust::tables::Table, + dim_styles: acadrust::tables::Table, + style_objects: Vec<(Handle, ObjectType)>, + current_text: String, + current_dim: String, + multiline_style: String, + active_table: String, + active_mleader: String, + tab_active_mleader: String, +} + +/// An in-progress style-manager transaction. +pub(super) struct StyleStage { + dirty_at_open: bool, + baseline: StyleStateSnapshot, } // ── Object-store helpers ─────────────────────────────────────────────────── diff --git a/src/app/update.rs b/src/app/update.rs index a526fa9a3..0afd02b14 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1519,6 +1519,17 @@ impl OpenCADStudio { self.save_dialog_window = None; return Task::none(); } + // A style manager closed via OS ✕ counts as Cancel: discard any + // staged (not-yet-applied) changes by restoring the snapshot + // taken when it opened. + if self.textstyle_window == Some(id) + || self.dimstyle_window == Some(id) + || self.tablestyle_window == Some(id) + || self.mleaderstyle_window == Some(id) + || self.mlstyle_window == Some(id) + { + self.style_stage_discard(); + } // Each popup window that's launched from a ribbon tool // also turns that tool blue (`activate_tool`); when the // window closes, the matching tool needs to be cleared @@ -6127,6 +6138,7 @@ impl OpenCADStudio { ..Default::default() }); self.textstyle_window = Some(id); + self.style_stage_begin(); task.map(|_| Message::Noop) } Message::TextStyleDialogClose => { @@ -6143,12 +6155,11 @@ impl OpenCADStudio { Task::none() } Message::TextStyleDialogSetCurrent => { + // Staged: persists on Apply. let i = self.active_tab; let name = self.textstyle_selected.clone(); if self.tabs[i].scene.document.text_styles.get(&name).is_some() { - self.push_undo_snapshot(i, "STYLE SET"); self.tabs[i].scene.document.header.current_text_style_name = name.clone(); - self.tabs[i].dirty = true; self.sync_ribbon_styles(); self.command_line .push_output(&format!("Current text style: {}", name)); @@ -6199,63 +6210,52 @@ impl OpenCADStudio { Task::none() } Message::TextStyleToggle(field) => { + // Staged: mutate live for preview, persist on Apply. let i = self.active_tab; let name = self.textstyle_selected.clone(); - if self.tabs[i].scene.document.text_styles.get(&name).is_some() { - self.push_undo_snapshot(i, "STYLE EDIT"); - if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { - match field { - "backward" => s.flags.backward = !s.flags.backward, - "upside_down" => s.flags.upside_down = !s.flags.upside_down, - "annotative" => s.annotative = !s.annotative, - _ => {} - } + if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { + match field { + "backward" => s.flags.backward = !s.flags.backward, + "upside_down" => s.flags.upside_down = !s.flags.upside_down, + "annotative" => s.annotative = !s.annotative, + _ => {} } - self.tabs[i].scene.bump_geometry(); - self.tabs[i].dirty = true; } Task::none() } Message::TextStyleApply => { let i = self.active_tab; let name = self.textstyle_selected.clone(); - if self.tabs[i].scene.document.text_styles.get(&name).is_some() { - self.push_undo_snapshot(i, "STYLE EDIT"); - let font = self.textstyle_font.clone(); - let width_str = self.textstyle_width.clone(); - let oblique_str = self.textstyle_oblique.clone(); - let height_str = self.textstyle_height.clone(); - let bigfont = self.textstyle_bigfont.clone(); - let ttf = self.textstyle_ttf.clone(); - if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { - s.font_file = font; - s.big_font_file = bigfont; - s.true_type_font = ttf; - if let Ok(w) = width_str.trim().parse::() { - s.width_factor = w; - } - if let Ok(a) = oblique_str.trim().parse::() { - s.oblique_angle = a.to_radians(); - } - if let Ok(h) = height_str.trim().parse::() { - s.height = h.max(0.0); - } + let font = self.textstyle_font.clone(); + let width_str = self.textstyle_width.clone(); + let oblique_str = self.textstyle_oblique.clone(); + let height_str = self.textstyle_height.clone(); + let bigfont = self.textstyle_bigfont.clone(); + let ttf = self.textstyle_ttf.clone(); + if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { + s.font_file = font; + s.big_font_file = bigfont; + s.true_type_font = ttf; + if let Ok(w) = width_str.trim().parse::() { + s.width_factor = w; + } + if let Ok(a) = oblique_str.trim().parse::() { + s.oblique_angle = a.to_radians(); + } + if let Ok(h) = height_str.trim().parse::() { + s.height = h.max(0.0); } - self.tabs[i].scene.bump_geometry(); - self.tabs[i].dirty = true; } + self.style_stage_commit(); Task::none() } Message::TextStyleFontPick(font_file) => { + // Staged: update the buffer + live style; persist on Apply. let i = self.active_tab; self.textstyle_font = font_file.clone(); let name = self.textstyle_selected.clone(); - if self.tabs[i].scene.document.text_styles.get(&name).is_some() { - self.push_undo_snapshot(i, "STYLE FONT"); - if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { - s.font_file = font_file; - } - self.tabs[i].dirty = true; + if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { + s.font_file = font_file; } Task::none() } @@ -6288,6 +6288,7 @@ impl OpenCADStudio { ..Default::default() }); self.tablestyle_window = Some(id); + self.style_stage_begin(); task.map(|_| Message::Noop) } Message::TableStyleDialogClose => { @@ -6321,7 +6322,6 @@ impl OpenCADStudio { let h: Option = self.ts_hmargin.trim().parse().ok(); let v: Option = self.ts_vmargin.trim().parse().ok(); let desc = self.ts_description.clone(); - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { @@ -6335,7 +6335,7 @@ impl OpenCADStudio { } } } - self.tabs[i].dirty = true; + self.style_stage_commit(); Task::none() } @@ -6347,8 +6347,6 @@ impl OpenCADStudio { "Up" => TableFlowDirection::Up, _ => TableFlowDirection::Down, }; - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; } Task::none() } @@ -6404,9 +6402,6 @@ impl OpenCADStudio { _ => TableBorderType::Single, }; } - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6419,9 +6414,6 @@ impl OpenCADStudio { { bd.is_invisible = !bd.is_invisible; } - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6432,9 +6424,6 @@ impl OpenCADStudio { if let Some(c) = Self::ts_cell_of(s, row) { c.fill_enabled = !c.fill_enabled; } - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6456,9 +6445,6 @@ impl OpenCADStudio { _ => CellAlignment::MiddleCenter, }; } - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6521,9 +6507,6 @@ impl OpenCADStudio { } } } - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6532,7 +6515,6 @@ impl OpenCADStudio { use acadrust::objects::ObjectType; let i = self.active_tab; let name = self.tablestyle_selected.clone(); - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { @@ -6544,7 +6526,6 @@ impl OpenCADStudio { } } } - self.tabs[i].dirty = true; Task::none() } @@ -6552,7 +6533,6 @@ impl OpenCADStudio { use acadrust::objects::ObjectType; let i = self.active_tab; let name = self.tablestyle_selected.clone(); - self.push_undo_snapshot(i, "TABLESTYLE EDIT"); for obj in self.tabs[i].scene.document.objects.values_mut() { if let ObjectType::TableStyle(s) = obj { if s.name == name { @@ -6560,7 +6540,6 @@ impl OpenCADStudio { } } } - self.tabs[i].dirty = true; Task::none() } @@ -6627,6 +6606,7 @@ impl OpenCADStudio { ..Default::default() }); self.mlstyle_window = Some(id); + self.style_stage_begin(); task.map(|_| Message::Noop) } Message::MlStyleDialogClose => { @@ -6651,9 +6631,8 @@ impl OpenCADStudio { .values() .any(|o| matches!(o, ObjectType::MLineStyle(s) if s.name == name)); if exists { - self.push_undo_snapshot(i, "MLSTYLE SET"); + // Staged: persists on Apply. self.tabs[i].scene.document.header.multiline_style = name.clone(); - self.tabs[i].dirty = true; self.command_line .push_output(&format!("Current multiline style: {}", name)); } @@ -6663,7 +6642,12 @@ impl OpenCADStudio { // Apply pair as every other style manager. The editor is currently // read-only, so there is nothing to apply yet — wire this up when // editable MLineStyle properties land. - Message::MlStyleApply => Task::none(), + Message::MlStyleApply => { + // Multiline styles have no editable properties yet; Apply still + // commits any staged structural / current-style changes. + self.style_stage_commit(); + Task::none() + } Message::MlStyleDialogNew => { self.style_new(crate::app::StyleKind::MLine); Task::none() @@ -6716,6 +6700,7 @@ impl OpenCADStudio { ..Default::default() }); self.mleaderstyle_window = Some(id); + self.style_stage_begin(); task.map(|_| Message::Noop) } Message::MLeaderStyleDialogClose => { @@ -6802,9 +6787,6 @@ impl OpenCADStudio { } _ => {} } - self.push_undo_snapshot(i, "MLEADERSTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6900,9 +6882,6 @@ impl OpenCADStudio { } _ => {} } - self.push_undo_snapshot(i, "MLEADERSTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -6939,9 +6918,6 @@ impl OpenCADStudio { "block_content_handle" => s.block_content_handle = handle, _ => {} } - self.push_undo_snapshot(i, "MLEADERSTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } Task::none() } @@ -7026,10 +7002,8 @@ impl OpenCADStudio { if let Some(v) = bsz { s.block_content_scale_z = v; } - self.push_undo_snapshot(i, "MLEADERSTYLE EDIT"); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); } + self.style_stage_commit(); Task::none() } @@ -7067,6 +7041,7 @@ impl OpenCADStudio { ..Default::default() }); self.dimstyle_window = Some(id); + self.style_stage_begin(); task.map(|_| Message::Noop) } Message::DimStyleDialogClose => { @@ -7079,6 +7054,7 @@ impl OpenCADStudio { Message::DimStyleDialogApply => { let i = self.active_tab; self.apply_dimstyle_bufs(i); + self.style_stage_commit(); Task::none() } Message::DimStyleDialogSelect(name) => { @@ -7100,11 +7076,10 @@ impl OpenCADStudio { Task::none() } Message::DimStyleDialogSetCurrent => { + // Staged: persists on Apply. let i = self.active_tab; - self.push_undo_snapshot(i, "DIMSTYLE SETCURRENT"); self.tabs[i].scene.document.header.current_dimstyle_name = self.dimstyle_selected.clone(); - self.tabs[i].dirty = true; self.sync_ribbon_styles(); self.command_line.push_output(&format!( "Current dim style set to '{}'.", @@ -7147,7 +7122,7 @@ impl OpenCADStudio { .map(|b| b.handle) .unwrap_or(acadrust::types::Handle::NULL) }; - self.push_undo_snapshot(i, "DIMSTYLE EDIT"); + // Staged: persists on Apply. if let Some(ds) = self.tabs[i].scene.document.dim_styles.get_mut(&name) { match field { "dimblk" => ds.dimblk = handle, @@ -7160,8 +7135,6 @@ impl OpenCADStudio { _ => {} } } - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); Task::none() } } @@ -7888,7 +7861,6 @@ impl OpenCADStudio { /// Write edit buffers back into the selected dim style document entry. fn apply_dimstyle_bufs(&mut self, tab: usize) { - self.push_undo_snapshot(tab, "DIMSTYLE EDIT"); let doc = &mut self.tabs[tab].scene.document; let Some(ds) = doc.dim_styles.get_mut(&self.dimstyle_selected) else { return; @@ -7979,7 +7951,6 @@ impl OpenCADStudio { ds.dimtofl = self.ds_dimtofl; ds.dimalt = self.ds_dimalt; ds.dimapost = self.ds_dimapost.clone(); - self.tabs[tab].dirty = true; self.command_line .push_output(&format!("DimStyle '{}' updated.", self.dimstyle_selected)); } diff --git a/src/ui/mleaderstyle.rs b/src/ui/mleaderstyle.rs index fe576fee5..1b3b327c9 100644 --- a/src/ui/mleaderstyle.rs +++ b/src/ui/mleaderstyle.rs @@ -2,7 +2,7 @@ use crate::app::Message; use iced::widget::{ - button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, + button, checkbox, column, container, pick_list, row, scrollable, text, text_input, }; use iced::{Background, Border, Color, Element, Fill, Theme}; diff --git a/src/ui/mlstyle.rs b/src/ui/mlstyle.rs index 95b0df30e..b041f67b2 100644 --- a/src/ui/mlstyle.rs +++ b/src/ui/mlstyle.rs @@ -1,8 +1,8 @@ //! Multiline Style Manager window — fills the entire OS window. use crate::app::Message; -use iced::widget::{column, container, row, scrollable, text, Space}; -use iced::{Background, Color, Element, Fill, Theme}; +use iced::widget::{column, container, row, scrollable, text}; +use iced::{Color, Element, Fill}; const DIM: Color = Color { r: 0.55, diff --git a/src/ui/tablestyle.rs b/src/ui/tablestyle.rs index ed570fd3b..73314f0d8 100644 --- a/src/ui/tablestyle.rs +++ b/src/ui/tablestyle.rs @@ -3,7 +3,6 @@ use crate::app::Message; use iced::widget::{ button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column, - Space, }; use iced::{Background, Border, Color, Element, Fill, Theme};