From cd766bc1f5a1f49e3815266520bc518ce7f616d1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 13 Jul 2026 12:14:40 +0300 Subject: [PATCH] fix(annotative): stage scale-manager edits like the style managers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scale manager now runs as a transaction, matching the style managers' staging: New starts a blank editor draft (nothing is added to the drawing until Apply), Delete removes immediately but staged, and closing the window without Apply restores the snapshot taken on open — so a scale created or deleted mid-session is discarded on cancel. Apply commits the staged list changes as a single undo entry. This replaces the earlier behaviour where New copied the selected scale and edits landed immediately. A `changed` flag guards the commit so a no-op / failed Apply (blank or invalid fields, or an edit that didn't apply) no longer dirties the drawing or pushes a phantom undo entry. Set Current is deliberately outside the transaction — it takes effect immediately like the scale pill (and syncs CANNOSCALEVALUE), so it is never rolled back on close. Renaming the current scale follows the pointer; editing one of the built-in fallback scales (shown when the drawing has no scale list) materialises it as a real scale; the snapshot restore touches only the ACAD_SCALELIST root entry. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 8 +++ src/app/style_ops.rs | 120 ++++++++++++++++++++++++++++++++++ src/app/update/mod.rs | 105 ++++++++++++++++++++--------- src/ui/style/scale_manager.rs | 35 +++++----- 4 files changed, 220 insertions(+), 48 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ba7b9d6f..0874d1c85 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -664,6 +664,12 @@ pub(super) struct OpenCADStudio { scale_manager_name_buf: String, scale_manager_paper_buf: String, scale_manager_drawing_buf: String, + /// The editor holds an unsaved *new* scale (created only on Apply) rather + /// than editing the selected one. Reset when a row is selected. + scale_manager_new: bool, + /// Open transaction for the scale manager — restored if the window closes + /// without Apply, mirroring the style managers' staging. + scale_stage: Option, // ── Plot Style Panel ────────────────────────────────────────────────── /// Selected ACI index in the panel (1-255). @@ -2257,6 +2263,8 @@ impl OpenCADStudio { scale_manager_name_buf: String::new(), scale_manager_paper_buf: String::new(), scale_manager_drawing_buf: String::new(), + scale_manager_new: false, + scale_stage: None, layout_manager_rename_buf: String::new(), plotstyle_panel_aci: 1, ps_color_buf: String::new(), diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index 430726192..ec8ec50b8 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -608,3 +608,123 @@ fn object_handle(doc: &acadrust::CadDocument, name: &str, kind: StyleKind) -> Op matches.then_some(h) }) } + +// ── Annotation-scale manager staging (mirrors the style-manager stage) ────── + +/// Snapshot of the drawing's annotation-scale *list* a scale manager can stage: +/// the `Scale` objects and the `ACAD_SCALELIST` dictionary. The current scale is +/// deliberately excluded — Set Current takes effect immediately, like the pill. +pub(super) struct ScaleSnapshot { + scales: Vec<(Handle, ObjectType)>, + scalelist: Option<(Handle, ObjectType)>, +} + +/// An in-progress scale-manager transaction (baseline restored on discard). +/// `changed` guards against a no-op Apply dirtying the drawing / pushing undo. +pub(super) struct ScaleStage { + dirty_at_open: bool, + baseline: ScaleSnapshot, + changed: bool, +} + +impl OpenCADStudio { + fn capture_scale_state(&self) -> ScaleSnapshot { + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let scales: Vec<(Handle, ObjectType)> = doc + .objects + .iter() + .filter(|(_, o)| matches!(o, ObjectType::Scale(_))) + .map(|(h, o)| (*h, o.clone())) + .collect(); + let root_h = doc.header.named_objects_dict_handle; + let scalelist_h = + crate::scene::annotative::as_dict(doc, root_h).and_then(|d| d.get("ACAD_SCALELIST")); + let scalelist = scalelist_h.and_then(|h| doc.objects.get(&h).map(|o| (h, o.clone()))); + ScaleSnapshot { scales, scalelist } + } + + fn restore_scale_state(&mut self, snap: &ScaleSnapshot) { + let i = self.active_tab; + let root_h = self.tabs[i].scene.document.header.named_objects_dict_handle; + let cur_scalelist_h = + crate::scene::annotative::as_dict(&self.tabs[i].scene.document, root_h) + .and_then(|d| d.get("ACAD_SCALELIST")); + let doc = &mut self.tabs[i].scene.document; + doc.objects.retain(|_, o| !matches!(o, ObjectType::Scale(_))); + if let Some(h) = cur_scalelist_h { + doc.objects.remove(&h); + } + for (h, o) in &snap.scales { + doc.objects.insert(*h, o.clone()); + } + if let Some((h, o)) = &snap.scalelist { + doc.objects.insert(*h, o.clone()); + } + // Restore ONLY the ACAD_SCALELIST root entry — leave every other root + // entry alone (a modal blocks concurrent edits, but be surgical anyway). + let baseline_scalelist_h = snap.scalelist.as_ref().map(|(h, _)| *h); + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { + root.entries.retain(|(k, _)| k != "ACAD_SCALELIST"); + if let Some(h) = baseline_scalelist_h { + root.entries.push(("ACAD_SCALELIST".to_string(), h)); + } + } + self.tabs[i].scene.bump_geometry(); + } + + /// Begin a staging transaction for a freshly-opened scale manager. + pub(super) fn scale_stage_begin(&mut self) { + let dirty_at_open = self.tabs[self.active_tab].dirty; + let baseline = self.capture_scale_state(); + self.scale_stage = Some(ScaleStage { + dirty_at_open, + baseline, + changed: false, + }); + } + + /// Flag that the staged scale list has actually been mutated, so a later + /// Apply commits (and a no-op Apply doesn't dirty the drawing). + pub(super) fn scale_stage_mark(&mut self) { + if let Some(s) = self.scale_stage.as_mut() { + s.changed = true; + } + } + + /// Commit staged scale changes (Apply): one undo entry, mark dirty, and + /// re-baseline so the still-open window keeps staging afresh. A no-op Apply + /// (nothing staged) does nothing. + pub(super) fn scale_stage_commit(&mut self) { + let i = self.active_tab; + let Some(stage) = self.scale_stage.take() else { + return; + }; + if !stage.changed { + self.scale_stage = Some(stage); + return; + } + let edited = self.capture_scale_state(); + self.restore_scale_state(&stage.baseline); + self.push_undo_snapshot(i, "SCALELISTEDIT"); + self.restore_scale_state(&edited); + self.tabs[i].dirty = true; + self.scale_stage = Some(ScaleStage { + dirty_at_open: true, + baseline: edited, + changed: false, + }); + } + + /// Discard staged scale changes (window closed without Apply): restore the + /// snapshot taken when the manager opened. No-op when nothing was staged. + pub(super) fn scale_stage_discard(&mut self) { + let Some(stage) = self.scale_stage.take() else { + return; + }; + if stage.changed { + self.restore_scale_state(&stage.baseline); + self.tabs[self.active_tab].dirty = stage.dirty_at_open; + } + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 40f63bd4a..529e4aa93 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -77,6 +77,9 @@ impl OpenCADStudio { ) { self.style_stage_discard(); } + if self.active_modal == Some(ScaleManager) { + self.scale_stage_discard(); + } match self.active_modal { Some(Layers) => self.ribbon.deactivate_tool_if("LAYERS"), Some(Plot) => { @@ -1650,6 +1653,10 @@ impl OpenCADStudio { Message::ScaleManagerOpen => { let i = self.active_tab; self.scale_popup_open = false; + // Snapshot so New / Delete / edits revert if closed without Apply. + self.scale_stage_begin(); + // Open on the current scale, ready to edit (not a new draft). + self.scale_manager_new = false; let cur = self.tabs[i] .scene .document @@ -1661,6 +1668,7 @@ impl OpenCADStudio { Task::none() } Message::ScaleManagerSelect(name) => { + self.scale_manager_new = false; self.load_scale_editor(&name); Task::none() } @@ -1677,35 +1685,69 @@ impl OpenCADStudio { Task::none() } Message::ScaleManagerNew => { - let i = self.active_tab; - if let (name, Some(paper), Some(drawing)) = self.scale_editor_values() { - if !name.is_empty() && paper > 0.0 && drawing > 0.0 { - self.push_undo_snapshot(i, "SCALELISTEDIT"); - if self.tabs[i].scene.add_scale(&name, paper, drawing) { - self.scale_manager_selected = name; - self.tabs[i].dirty = true; - } - } - } + // Start a blank draft — nothing is added to the drawing until + // Apply; closing the manager without Apply discards it. + self.scale_manager_new = true; + self.scale_manager_selected.clear(); + self.scale_manager_name_buf.clear(); + self.scale_manager_paper_buf = "1".to_string(); + self.scale_manager_drawing_buf = "1".to_string(); Task::none() } Message::ScaleManagerApply => { + // Fold the editor's add / edit into the staged document, then + // commit the whole transaction (this change plus any staged + // deletes) as one undo entry. A new draft is added; otherwise + // the selected scale is renamed / re-ratioed. let i = self.active_tab; - let old = self.scale_manager_selected.clone(); - if !old.is_empty() { - if let (name, Some(paper), Some(drawing)) = self.scale_editor_values() { - if !name.is_empty() && paper > 0.0 && drawing > 0.0 { - self.push_undo_snapshot(i, "SCALELISTEDIT"); - if self.tabs[i].scene.edit_scale(&old, &name, paper, drawing) { - self.scale_manager_selected = name; - self.tabs[i].dirty = true; + let name = self.scale_manager_name_buf.trim().to_string(); + let paper = self.scale_manager_paper_buf.trim().parse::().ok(); + let drawing = self.scale_manager_drawing_buf.trim().parse::().ok(); + if let (false, Some(paper), Some(drawing)) = (name.is_empty(), paper, drawing) { + if paper > 0.0 && drawing > 0.0 { + let ok = if self.scale_manager_new { + self.tabs[i].scene.add_scale(&name, paper, drawing) + } else { + let old = self.scale_manager_selected.clone(); + if !old.is_empty() + && self.tabs[i].scene.edit_scale(&old, &name, paper, drawing) + { + // Follow a rename of the current annotation scale + // so its pointer doesn't dangle. + if self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .eq_ignore_ascii_case(&old) + { + self.tabs[i].scene.document.header.current_annotation_scale = + name.clone(); + } + true + } else if !old.is_empty() + && self.tabs[i].scene.scale_paper_drawing(&old).is_none() + { + // Editing one of the built-in fallback scales the + // picker shows when the drawing has no scale list + // of its own — materialise it as a real scale. + self.tabs[i].scene.add_scale(&name, paper, drawing) + } else { + false } + }; + if ok { + self.scale_manager_new = false; + self.scale_manager_selected = name; + self.scale_stage_mark(); } } } + self.scale_stage_commit(); Task::none() } Message::ScaleManagerDelete => { + // Staged: reverted on close unless a later Apply commits it. let i = self.active_tab; let sel = self.scale_manager_selected.clone(); let cur = self.tabs[i] @@ -1714,16 +1756,21 @@ impl OpenCADStudio { .header .current_annotation_scale .clone(); - if !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { - self.push_undo_snapshot(i, "SCALELISTEDIT"); + if !self.scale_manager_new && !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { if self.tabs[i].scene.remove_scale(&sel) { self.scale_manager_selected.clear(); - self.tabs[i].dirty = true; + self.scale_manager_name_buf.clear(); + self.scale_manager_paper_buf.clear(); + self.scale_manager_drawing_buf.clear(); + self.scale_stage_mark(); } } Task::none() } Message::ScaleManagerSetCurrent => { + // Set Current takes effect immediately, exactly like the scale + // pill — it isn't part of the staged list transaction, so it is + // never rolled back when the manager closes. let i = self.active_tab; let sel = self.scale_manager_selected.clone(); if let Some((_, anno, _)) = self @@ -1734,9 +1781,13 @@ impl OpenCADStudio { .find(|(n, _, _)| n.eq_ignore_ascii_case(&sel)) { self.tabs[i].scene.annotation_scale = anno; - self.tabs[i].scene.document.header.current_annotation_scale = sel; + self.tabs[i].scene.document.header.current_annotation_scale = sel.clone(); + if let Some((p, d)) = self.tabs[i].scene.scale_paper_drawing(&sel) { + if d != 0.0 { + self.tabs[i].scene.document.header.annotation_scale_value = p / d; + } + } self.tabs[i].scene.bump_geometry(); - self.tabs[i].dirty = true; } Task::none() } @@ -4040,12 +4091,4 @@ impl OpenCADStudio { } } - /// The scale-manager editor's (name, paper?, drawing?) parsed from buffers. - fn scale_editor_values(&self) -> (String, Option, Option) { - ( - self.scale_manager_name_buf.trim().to_string(), - self.scale_manager_paper_buf.trim().parse::().ok(), - self.scale_manager_drawing_buf.trim().parse::().ok(), - ) - } } diff --git a/src/ui/style/scale_manager.rs b/src/ui/style/scale_manager.rs index a8f9df382..eae4ec110 100644 --- a/src/ui/style/scale_manager.rs +++ b/src/ui/style/scale_manager.rs @@ -146,28 +146,29 @@ pub fn view_window<'a, 'b>( }); // ── Right: editor (name + paper:drawing) ────────────────────────────── - let field = |label: &'static str, value: &str, on: fn(String) -> Message| { - row![ - text(label).size(11).color(DIM).width(96), - text_input("", value) - .on_input(on) - .style(field_style) - .size(12) - .padding([5, 8]) - .width(Fill), - ] - .align_y(iced::Center) - .spacing(6) - }; + let field = + |label: &'static str, ph: &'static str, value: &str, on: fn(String) -> Message| { + row![ + text(label).size(11).color(DIM).width(96), + text_input(ph, value) + .on_input(on) + .style(field_style) + .size(12) + .padding([5, 8]) + .width(Fill), + ] + .align_y(iced::Center) + .spacing(6) + }; let editor = container( column![ text("Scale").size(10).color(DIM), - field("Name", name_buf, Message::ScaleManagerNameBuf), - field("Paper units", paper_buf, Message::ScaleManagerPaperBuf), - field("Drawing units", drawing_buf, Message::ScaleManagerDrawingBuf), + field("Name", "e.g. 1:50", name_buf, Message::ScaleManagerNameBuf), + field("Paper units", "1", paper_buf, Message::ScaleManagerPaperBuf), + field("Drawing units", "50", drawing_buf, Message::ScaleManagerDrawingBuf), Space::new().height(6), - text("Type the ratio in the fields, then New to add or Apply to update the selected scale.") + text("New starts a blank scale; edit the fields and click Apply to save it. Changes are discarded if you close without Apply.") .size(10) .color(DIM), ]