diff --git a/src/app/mod.rs b/src/app/mod.rs index 0874d1c85..2bbbcf8a1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -661,12 +661,11 @@ pub(super) struct OpenCADStudio { // ── Annotation-scale Manager ────────────────────────────────────────── scale_manager_selected: String, - 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, + /// The scale row being renamed inline (double-click), + its edit buffer. + scale_rename: Option, + scale_rename_buf: String, /// Open transaction for the scale manager — restored if the window closes /// without Apply, mirroring the style managers' staging. scale_stage: Option, @@ -1472,8 +1471,16 @@ pub enum Message { ScaleManagerOpen, /// Select a scale row in the manager (loads it into the editor). ScaleManagerSelect(String), - /// Add a new scale from the editor fields. + /// Add a new scale to the list (staged) and select it for editing. ScaleManagerNew, + /// Duplicate the selected scale under a unique name (staged). + ScaleManagerCopy, + /// Begin inline rename of a scale row (double-click). + ScaleRenameStart(String), + /// Edit the inline-rename buffer. + ScaleRenameEdit(String), + /// Commit the inline rename. + ScaleRenameCommit, /// Delete the selected scale. ScaleManagerDelete, /// Set the selected scale as the current annotation scale. @@ -1481,7 +1488,6 @@ pub enum Message { /// Apply the editor fields to the selected scale (rename / re-ratio). ScaleManagerApply, /// Annotation-scale manager editor field edits. - ScaleManagerNameBuf(String), ScaleManagerPaperBuf(String), ScaleManagerDrawingBuf(String), /// Toggle the leftmost hamburger's Model/layout list dropdown. @@ -2260,10 +2266,10 @@ impl OpenCADStudio { // Layout Manager layout_manager_selected: "Model".to_string(), scale_manager_selected: String::new(), - scale_manager_name_buf: String::new(), scale_manager_paper_buf: String::new(), scale_manager_drawing_buf: String::new(), - scale_manager_new: false, + scale_rename: None, + scale_rename_buf: String::new(), scale_stage: None, layout_manager_rename_buf: String::new(), plotstyle_panel_aci: 1, diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index ec8ec50b8..05b93fa59 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -612,11 +612,14 @@ fn object_handle(doc: &acadrust::CadDocument, name: &str, kind: StyleKind) -> Op // ── 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. +/// the `Scale` objects and the `ACAD_SCALELIST` dictionary. Set Current takes +/// effect immediately (like the pill) and is *not* reverted; `current` is kept +/// only to repair the header pointer if a staged rename/delete of the current +/// scale is rolled back, leaving it dangling. pub(super) struct ScaleSnapshot { scales: Vec<(Handle, ObjectType)>, scalelist: Option<(Handle, ObjectType)>, + current: String, } /// An in-progress scale-manager transaction (baseline restored on discard). @@ -625,6 +628,10 @@ pub(super) struct ScaleStage { dirty_at_open: bool, baseline: ScaleSnapshot, changed: bool, + /// The manager materialised the fallback scale set into real objects on + /// open. Even with no user edit these must be reverted on close, so the + /// discard restores the baseline when this is set (not only on `changed`). + materialized: bool, } impl OpenCADStudio { @@ -637,19 +644,20 @@ impl OpenCADStudio { .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_h = self.tabs[i].scene.scalelist_dict_handle(); let scalelist = scalelist_h.and_then(|h| doc.objects.get(&h).map(|o| (h, o.clone()))); - ScaleSnapshot { scales, scalelist } + let current = doc.header.current_annotation_scale.clone(); + ScaleSnapshot { + scales, + scalelist, + current, + } } 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 cur_scalelist_h = self.tabs[i].scene.scalelist_dict_handle(); let doc = &mut self.tabs[i].scene.document; doc.objects.retain(|_, o| !matches!(o, ObjectType::Scale(_))); if let Some(h) = cur_scalelist_h { @@ -670,6 +678,24 @@ impl OpenCADStudio { root.entries.push(("ACAD_SCALELIST".to_string(), h)); } } + // Repair a dangling current-scale pointer. A staged rename/delete of the + // current scale rewrote the header outside this snapshot; if the live + // name no longer resolves against the restored list, fall back to the + // snapshot's name. A Set Current to a still-valid scale is preserved. + let cur = self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .clone(); + let resolves = self.tabs[i] + .scene + .scale_list() + .iter() + .any(|(n, _, _)| n.eq_ignore_ascii_case(&cur)); + if !resolves { + self.tabs[i].scene.document.header.current_annotation_scale = snap.current.clone(); + } self.tabs[i].scene.bump_geometry(); } @@ -681,6 +707,7 @@ impl OpenCADStudio { dirty_at_open, baseline, changed: false, + materialized: false, }); } @@ -692,6 +719,14 @@ impl OpenCADStudio { } } + /// Flag that the manager materialised the fallback scale set on open, so + /// the discard reverts it even when the user made no edit. + pub(super) fn scale_stage_materialized(&mut self) { + if let Some(s) = self.scale_stage.as_mut() { + s.materialized = 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. @@ -713,6 +748,9 @@ impl OpenCADStudio { dirty_at_open: true, baseline: edited, changed: false, + // The materialised set is now committed as the new baseline, so it + // no longer needs reverting on close. + materialized: false, }); } @@ -722,7 +760,9 @@ impl OpenCADStudio { let Some(stage) = self.scale_stage.take() else { return; }; - if stage.changed { + // Revert on a real edit *or* when only the fallback set was materialised + // (opened but not applied) — otherwise those 12 scales would leak in. + if stage.changed || stage.materialized { 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 529e4aa93..1d32c8963 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1653,29 +1653,42 @@ impl OpenCADStudio { Message::ScaleManagerOpen => { let i = self.active_tab; self.scale_popup_open = false; - // Snapshot so New / Delete / edits revert if closed without Apply. + // Snapshot so New / Copy / 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; + // Fallback scales are virtual (no real objects), so they can't + // be edited or renamed. Materialise the standard set into real + // staged objects so the manager behaves like a drawing with its + // own list; the stage reverts them on close unless applied. + if self.tabs[i].scene.ensure_real_scale_list() { + self.scale_stage_materialized(); + } + self.scale_rename = None; let cur = self.tabs[i] .scene .document .header .current_annotation_scale .clone(); - self.load_scale_editor(&cur); + // Select the current scale, or the first one if it isn't listed. + if self.tabs[i].scene.scale_paper_drawing(&cur).is_some() { + self.load_scale_editor(&cur); + } else if let Some((first, _, _)) = + self.tabs[i].scene.scale_list().into_iter().next() + { + self.load_scale_editor(&first); + } self.active_modal = Some(crate::app::ModalKind::ScaleManager); Task::none() } Message::ScaleManagerSelect(name) => { - self.scale_manager_new = false; + // Stage the current editor edits before switching so they aren't + // lost, then load the newly-selected scale. + self.scale_rename = None; + self.scale_apply_current(); self.load_scale_editor(&name); Task::none() } - Message::ScaleManagerNameBuf(s) => { - self.scale_manager_name_buf = s; - Task::none() - } Message::ScaleManagerPaperBuf(s) => { self.scale_manager_paper_buf = s; Task::none() @@ -1685,64 +1698,83 @@ impl OpenCADStudio { Task::none() } Message::ScaleManagerNew => { - // 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(); + // Add a new scale to the list immediately (staged) and select it, + // like the style managers' New. The user edits its name / ratio; + // it's kept only if Apply is pressed before the window closes. + self.scale_apply_current(); + let i = self.active_tab; + let name = self.unique_scale_name("New Scale"); + if self.tabs[i].scene.add_scale(&name, 1.0, 1.0) { + self.load_scale_editor(&name); + self.scale_stage_mark(); + } 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. + Message::ScaleManagerCopy => { + // Duplicate the selected scale under a unique name (staged). + self.scale_apply_current(); let i = self.active_tab; - 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 - } - }; + let sel = self.scale_manager_selected.clone(); + if !sel.is_empty() { + let (paper, drawing) = + self.tabs[i].scene.scale_paper_drawing(&sel).unwrap_or((1.0, 1.0)); + let name = self.unique_scale_name(&sel); + if self.tabs[i].scene.add_scale(&name, paper, drawing) { + self.load_scale_editor(&name); + self.scale_stage_mark(); + } + } + Task::none() + } + Message::ScaleRenameStart(name) => { + // Stage current editor edits, then rename this row inline. + self.scale_apply_current(); + self.scale_rename_buf = name.clone(); + self.scale_rename = Some(name); + iced::widget::operation::focus(crate::ui::style::scale_manager::rename_input_id()) + } + Message::ScaleRenameEdit(s) => { + self.scale_rename_buf = s; + Task::none() + } + Message::ScaleRenameCommit => { + let i = self.active_tab; + if let Some(old) = self.scale_rename.take() { + let new = self.scale_rename_buf.trim().to_string(); + if !new.is_empty() && !new.eq_ignore_ascii_case(&old) { + let (paper, drawing) = + self.tabs[i].scene.scale_paper_drawing(&old).unwrap_or((1.0, 1.0)); + // Only fall back to add_scale for a built-in fallback (no + // real object); never for a real scale whose rename was + // rejected (name collision) — that would duplicate it. + let ok = self.tabs[i].scene.edit_scale(&old, &new, paper, drawing) + || (self.tabs[i].scene.scale_paper_drawing(&old).is_none() + && self.tabs[i].scene.add_scale(&new, paper, drawing)); if ok { - self.scale_manager_new = false; - self.scale_manager_selected = name; + if self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .eq_ignore_ascii_case(&old) + { + self.tabs[i].scene.document.header.current_annotation_scale = + new.clone(); + } + if self.scale_manager_selected.eq_ignore_ascii_case(&old) { + self.load_scale_editor(&new); + } self.scale_stage_mark(); } } } + Task::none() + } + Message::ScaleManagerApply => { + // Fold the editor into the selected scale, then commit the staged + // transaction (this edit plus any New / Copy / Delete since open) + // as one undo entry. + self.scale_apply_current(); self.scale_stage_commit(); Task::none() } @@ -1756,10 +1788,9 @@ impl OpenCADStudio { .header .current_annotation_scale .clone(); - if !self.scale_manager_new && !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { + if !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { if self.tabs[i].scene.remove_scale(&sel) { self.scale_manager_selected.clear(); - self.scale_manager_name_buf.clear(); self.scale_manager_paper_buf.clear(); self.scale_manager_drawing_buf.clear(); self.scale_stage_mark(); @@ -3542,6 +3573,7 @@ impl OpenCADStudio { Task::none() } Message::TextStyleDialogSelect(name) => { + self.stage_textstyle_bufs(); let i = self.active_tab; self.textstyle_selected = name; self.load_textstyle_bufs(i); @@ -3655,6 +3687,7 @@ impl OpenCADStudio { Task::none() } Message::TableStyleDialogSelect(name) => { + self.stage_tablestyle_bufs(); self.tablestyle_selected = name; let i = self.active_tab; self.load_tablestyle_bufs(i); @@ -3672,25 +3705,7 @@ impl OpenCADStudio { } Message::TableStyleApply => { - use acadrust::objects::ObjectType; - let i = self.active_tab; - let name = self.tablestyle_selected.clone(); - let h: Option = self.ts_hmargin.trim().parse().ok(); - let v: Option = self.ts_vmargin.trim().parse().ok(); - let desc = self.ts_description.clone(); - for obj in self.tabs[i].scene.document.objects.values_mut() { - if let ObjectType::TableStyle(s) = obj { - if s.name == name { - if let Some(h) = h { - s.horizontal_margin = h; - } - if let Some(v) = v { - s.vertical_margin = v; - } - s.description = desc.clone(); - } - } - } + self.stage_tablestyle_bufs(); self.style_stage_commit(); Task::none() } @@ -3935,6 +3950,7 @@ impl OpenCADStudio { Task::none() } Message::MLeaderStyleDialogSelect(name) => { + self.stage_mleaderstyle_bufs(); self.mleaderstyle_selected = name; let i = self.active_tab; self.load_mleaderstyle_bufs(i); @@ -4002,6 +4018,8 @@ impl OpenCADStudio { } Message::DimStyleDialogSelect(name) => { let i = self.active_tab; + // Stage the current edits before switching so they aren't lost. + self.apply_dimstyle_bufs(i); self.dimstyle_selected = name; self.load_dimstyle_bufs(i); Task::none() @@ -4078,7 +4096,6 @@ impl OpenCADStudio { fn load_scale_editor(&mut self, name: &str) { let i = self.active_tab; self.scale_manager_selected = name.to_string(); - self.scale_manager_name_buf = name.to_string(); match self.tabs[i].scene.scale_paper_drawing(name) { Some((p, d)) => { self.scale_manager_paper_buf = format!("{p}"); @@ -4091,4 +4108,81 @@ impl OpenCADStudio { } } + /// Fold the editor's paper:drawing ratio into the selected scale, keeping + /// its name (renaming is done inline in the list). Staged, no commit — so + /// the ratio edit survives Apply *and* switching to another row. Editing a + /// built-in fallback scale materialises it as a real one. + fn scale_apply_current(&mut self) { + let i = self.active_tab; + let sel = self.scale_manager_selected.clone(); + if sel.is_empty() { + return; + } + let paper = self.scale_manager_paper_buf.trim().parse::().ok(); + let drawing = self.scale_manager_drawing_buf.trim().parse::().ok(); + if let (Some(paper), Some(drawing)) = (paper, drawing) { + if paper > 0.0 && drawing > 0.0 { + // Skip when the editor still holds the stored ratio, so merely + // navigating between scales doesn't dirty the drawing. + if let Some((cp, cd)) = self.tabs[i].scene.scale_paper_drawing(&sel) { + if (cp - paper).abs() < 1e-9 && (cd - drawing).abs() < 1e-9 { + return; + } + } + let changed = self.tabs[i].scene.edit_scale(&sel, &sel, paper, drawing) + || (self.tabs[i].scene.scale_paper_drawing(&sel).is_none() + && self.tabs[i].scene.add_scale(&sel, paper, drawing)); + if changed { + self.scale_stage_mark(); + } + } + } + } + + /// Write the table-style editor buffers (margins / description) into the + /// selected style (staged, no commit), so edits survive switching as well + /// as Apply. + fn stage_tablestyle_bufs(&mut self) { + use acadrust::objects::ObjectType; + let i = self.active_tab; + let name = self.tablestyle_selected.clone(); + let h: Option = self.ts_hmargin.trim().parse().ok(); + let v: Option = self.ts_vmargin.trim().parse().ok(); + let desc = self.ts_description.clone(); + for obj in self.tabs[i].scene.document.objects.values_mut() { + if let ObjectType::TableStyle(s) = obj { + if s.name == name { + if let Some(h) = h { + s.horizontal_margin = h; + } + if let Some(v) = v { + s.vertical_margin = v; + } + s.description = desc.clone(); + } + } + } + } + + /// A scale name based on `base`, suffixed " (n)" until it's unique in the + /// drawing's scale list (used by New / Copy). + fn unique_scale_name(&self, base: &str) -> String { + let existing: std::collections::HashSet = self.tabs[self.active_tab] + .scene + .scale_list() + .into_iter() + .map(|(n, _, _)| n.to_ascii_lowercase()) + .collect(); + if !existing.contains(&base.to_ascii_lowercase()) { + return base.to_string(); + } + let mut n = 2; + loop { + let candidate = format!("{base} ({n})"); + if !existing.contains(&candidate.to_ascii_lowercase()) { + return candidate; + } + n += 1; + } + } } diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 1af8519ec..0061d2657 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -497,7 +497,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_text_style_apply(&mut self) -> Task { + /// Write the editor buffers into the selected text style (staged, no + /// commit), so edits survive switching to another style as well as Apply. + pub(super) fn stage_textstyle_bufs(&mut self) { let i = self.active_tab; let name = self.textstyle_selected.clone(); let font = self.textstyle_font.clone(); @@ -520,6 +522,10 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { s.height = h.max(0.0); } } + } + + pub(super) fn on_text_style_apply(&mut self) -> Task { + self.stage_textstyle_bufs(); self.style_stage_commit(); Task::none() } @@ -838,7 +844,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Task::none() } - pub(super) fn on_mleader_style_apply(&mut self) -> Task { + /// Write the editor buffers into the selected multileader style (staged, no + /// commit), so edits survive switching to another style as well as Apply. + pub(super) fn stage_mleaderstyle_bufs(&mut self) { let i = self.active_tab; let (ld, lg, asz, th, sf, bg, fsa, ssa, mp, dt, lc, tc) = ( self.mls_landing_distance.parse::().ok(), @@ -920,6 +928,10 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { s.block_content_scale_z = v; } } + } + + pub(super) fn on_mleader_style_apply(&mut self) -> Task { + self.stage_mleaderstyle_bufs(); self.style_stage_commit(); Task::none() } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index a80f25cbe..65c9c1cf3 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -86,7 +86,8 @@ impl OpenCADStudio { &scales, &self.scale_manager_selected, ¤t, - &self.scale_manager_name_buf, + self.scale_rename.as_deref(), + &self.scale_rename_buf, &self.scale_manager_paper_buf, &self.scale_manager_drawing_buf, ), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index dcd952cea..54ddc942a 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1825,6 +1825,59 @@ impl Scene { /// ratio (0.02 for "1:50"). Sorted smallest ratio first (1:100 … 1:1 … /// 10:1). Falls back to a standard ratio set when the drawing carries no /// scale list of its own, so the scale picker is always usable. (#154) + /// Standard fallback ratio set as (label, paper/drawing factor). Shown when + /// the drawing defines no annotation scales of its own, and materialised as + /// real `Scale` objects on demand by [`Scene::ensure_real_scale_list`]. + const DEFAULT_SCALES: &'static [(&'static str, f64)] = &[ + ("1:500", 0.002), + ("1:200", 0.005), + ("1:100", 0.01), + ("1:50", 0.02), + ("1:20", 0.05), + ("1:10", 0.1), + ("1:5", 0.2), + ("1:2", 0.5), + ("1:1", 1.0), + ("2:1", 2.0), + ("5:1", 5.0), + ("10:1", 10.0), + ]; + + /// True when the drawing owns at least one annotation scale of its own + /// (i.e. `scale_list` returns real objects rather than the fallback set). + fn has_own_scales(&self) -> bool { + self.document.objects.values().any(|o| { + matches!(o, ObjectType::Scale(s) + if !s.is_temporary + && !s.name.contains('|') + && !s.name.to_ascii_uppercase().ends_with("_XREF")) + }) + } + + /// If the drawing has no annotation scales of its own, populate it with the + /// standard fallback set as real `Scale` objects (and the `ACAD_SCALELIST` + /// dictionary) so they can be selected, edited and renamed uniformly with a + /// drawing that ships its own list. Returns `true` if any were created. + /// + /// The scale manager calls this on open and stages the result, so the set + /// is discarded again unless the user applies an edit. + pub fn ensure_real_scale_list(&mut self) -> bool { + if self.has_own_scales() { + return false; + } + for &(label, factor) in Self::DEFAULT_SCALES { + // Fallback labels are "paper:drawing"; parse them for tidy whole + // units, else derive drawing units from the factor (paper = 1). + let (paper, drawing) = label + .split_once(':') + .and_then(|(p, d)| Some((p.trim().parse::().ok()?, d.trim().parse::().ok()?))) + .filter(|&(p, d)| p > 0.0 && d > 0.0) + .unwrap_or((1.0, 1.0 / factor)); + self.add_scale(label, paper, drawing); + } + true + } + pub fn scale_list(&self) -> Vec<(String, f32, f64)> { let mut list: Vec<(String, f32, f64)> = self .document @@ -1852,21 +1905,7 @@ impl Scene { // annotation / viewport scale picker would be empty and so appear // broken. Substitute the standard ratio set — file scales still // win whenever the drawing actually defines any. (#154) - const DEFAULT_SCALES: &[(&str, f64)] = &[ - ("1:500", 0.002), - ("1:200", 0.005), - ("1:100", 0.01), - ("1:50", 0.02), - ("1:20", 0.05), - ("1:10", 0.1), - ("1:5", 0.2), - ("1:2", 0.5), - ("1:1", 1.0), - ("2:1", 2.0), - ("5:1", 5.0), - ("10:1", 10.0), - ]; - list = DEFAULT_SCALES + list = Self::DEFAULT_SCALES .iter() .map(|&(label, vp)| (label.to_string(), (1.0 / vp) as f32, vp)) .collect(); @@ -1874,18 +1913,61 @@ impl Scene { list } + /// Handle of the drawing's `ACAD_SCALELIST` dictionary, located robustly. + /// + /// The canonical path is the root named-objects dict → `"ACAD_SCALELIST"`, + /// but DWGs written by other programs don't always expose a resolvable root + /// dict (the header handle points at no loaded object). In that case fall + /// back to the owner dictionary shared by the drawing's `Scale` objects. + /// Returns `None` only when the drawing genuinely has no scale list. + pub(crate) fn scalelist_dict_handle(&self) -> Option { + use acadrust::objects::ObjectType; + let root_h = self.document.header.named_objects_dict_handle; + if let Some(h) = crate::scene::annotative::as_dict(&self.document, root_h) + .and_then(|d| d.get("ACAD_SCALELIST")) + { + return Some(h); + } + let owner = self.document.objects.values().find_map(|o| match o { + ObjectType::Scale(s) if !s.is_temporary => Some(s.owner_handle), + _ => None, + })?; + matches!( + self.document.objects.get(&owner), + Some(ObjectType::Dictionary(_)) + ) + .then_some(owner) + } + + /// Handle of the real (non-temporary) `Scale` object with this display name. + /// Scales are matched by their `name` field, not the dictionary key — some + /// files key the entries by an internal identifier (`*A1`, `A0`, …) that + /// bears no relation to the scale name. + fn scale_object_handle(&self, name: &str) -> Option { + use acadrust::objects::ObjectType; + self.document.objects.iter().find_map(|(h, o)| match o { + ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { + Some(*h) + } + _ => None, + }) + } + /// Add a named annotation scale to the drawing's `ACAD_SCALELIST`. Returns /// `false` if a scale with that name already exists. Creates the SCALELIST /// dictionary (and registers it in the root dictionary) when the drawing /// has none of its own — many minimal files carry no scale list at all. pub fn add_scale(&mut self, name: &str, paper: f64, drawing: f64) -> bool { use acadrust::objects::{Dictionary, ObjectType, Scale}; - let root_h = self.document.header.named_objects_dict_handle; - let scalelist_h = match crate::scene::annotative::as_dict(&self.document, root_h) - .and_then(|d| d.get("ACAD_SCALELIST")) - { + // Reject a duplicate by the scale's display name (the dictionary key may + // be an unrelated internal identifier, so check the objects directly). + if self.scale_object_handle(name).is_some() { + return false; + } + let scalelist_h = match self.scalelist_dict_handle() { Some(h) => h, None => { + let root_h = self.document.header.named_objects_dict_handle; let h = self.document.allocate_handle(); let mut d = Dictionary::new(); d.handle = h; @@ -1899,12 +1981,6 @@ impl Scene { h } }; - // Reject a duplicate name (case-insensitive). - if crate::scene::annotative::as_dict(&self.document, scalelist_h) - .is_some_and(|d| d.entries.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))) - { - return false; - } let sh = self.document.allocate_handle(); let mut s = Scale::new(name, paper, drawing); s.handle = sh; @@ -1922,25 +1998,18 @@ impl Scene { /// — the caller guards that. pub fn remove_scale(&mut self, name: &str) -> bool { use acadrust::objects::ObjectType; - let root_h = self.document.header.named_objects_dict_handle; - let Some(scalelist_h) = crate::scene::annotative::as_dict(&self.document, root_h) - .and_then(|d| d.get("ACAD_SCALELIST")) - else { - return false; - }; - let Some(sh) = crate::scene::annotative::as_dict(&self.document, scalelist_h).and_then( - |d| { - d.entries - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case(name)) - .map(|(_, h)| *h) - }, - ) else { + let Some(sh) = self.scale_object_handle(name) else { return false; }; + // Resolve the dictionary before removing the object (the fallback lookup + // reads a surviving scale's owner). + let dict_h = self.scalelist_dict_handle(); self.document.objects.remove(&sh); - if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&scalelist_h) { - sl.entries.retain(|(n, _)| !n.eq_ignore_ascii_case(name)); + if let Some(dict_h) = dict_h { + if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&dict_h) { + // Match by handle — the entry key is not necessarily the name. + sl.entries.retain(|(_, h)| *h != sh); + } } true } @@ -1949,28 +2018,16 @@ impl Scene { /// if the scale is missing or the new name collides with another scale. pub fn edit_scale(&mut self, old_name: &str, new_name: &str, paper: f64, drawing: f64) -> bool { use acadrust::objects::ObjectType; - let root_h = self.document.header.named_objects_dict_handle; - let Some(scalelist_h) = crate::scene::annotative::as_dict(&self.document, root_h) - .and_then(|d| d.get("ACAD_SCALELIST")) - else { + let Some(sh) = self.scale_object_handle(old_name) else { return false; }; - let Some(sh) = crate::scene::annotative::as_dict(&self.document, scalelist_h).and_then( - |d| { - d.entries - .iter() - .find(|(n, _)| n.eq_ignore_ascii_case(old_name)) - .map(|(_, h)| *h) - }, - ) else { - return false; - }; - // A new name that collides with a *different* scale is rejected. - if !new_name.eq_ignore_ascii_case(old_name) - && crate::scene::annotative::as_dict(&self.document, scalelist_h) - .is_some_and(|d| d.entries.iter().any(|(n, _)| n.eq_ignore_ascii_case(new_name))) - { - return false; + // A new name that collides with a *different* scale object is rejected. + if !new_name.eq_ignore_ascii_case(old_name) { + if let Some(other) = self.scale_object_handle(new_name) { + if other != sh { + return false; + } + } } if let Some(ObjectType::Scale(s)) = self.document.objects.get_mut(&sh) { s.name = new_name.to_string(); @@ -1978,9 +2035,12 @@ impl Scene { s.drawing_units = drawing; s.is_unit_scale = (paper - drawing).abs() < 1e-10; } - if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&scalelist_h) { - if let Some(e) = sl.entries.iter_mut().find(|(n, _)| n.eq_ignore_ascii_case(old_name)) { - e.0 = new_name.to_string(); + // Keep the owning dictionary's entry key in sync, matched by handle. + if let Some(dict_h) = self.scalelist_dict_handle() { + if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&dict_h) { + if let Some(e) = sl.entries.iter_mut().find(|(_, h)| *h == sh) { + e.0 = new_name.to_string(); + } } } true diff --git a/src/ui/popup/scale_popup.rs b/src/ui/popup/scale_popup.rs index d21fee568..b0c6aeb41 100644 --- a/src/ui/popup/scale_popup.rs +++ b/src/ui/popup/scale_popup.rs @@ -91,7 +91,7 @@ fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Mess } fn manage_row() -> Element<'static, Message> { - button(text("Manage scales...").size(11).color(CHECK_COLOR)) + button(text("Manage...").size(11).color(CHECK_COLOR)) .on_press(Message::ScaleManagerOpen) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { diff --git a/src/ui/style/scale_manager.rs b/src/ui/style/scale_manager.rs index eae4ec110..0f51dfd88 100644 --- a/src/ui/style/scale_manager.rs +++ b/src/ui/style/scale_manager.rs @@ -7,9 +7,16 @@ use crate::app::Message; use crate::ui::style::style_manager::{hdivider, tb_button, vsep, BG, BORDER, DIM, LIST, TB, TEXT}; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::widget::{ + column, container, mouse_area, row, scrollable, text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Fill, Theme}; +/// Inline-rename text-input id, so the rename-start handler can focus it. +pub fn rename_input_id() -> iced::widget::Id { + iced::widget::Id::new("scale-rename-input") +} + const INPUT_BG: Color = Color { r: 0.10, g: 0.10, @@ -55,7 +62,8 @@ pub fn view_window<'a, 'b>( scales: &'b [(String, String)], selected: &'b str, current: &'b str, - name_buf: &'a str, + rename_active: Option<&'b str>, + rename_buf: &'a str, paper_buf: &'a str, drawing_buf: &'a str, ) -> Element<'a, Message> { @@ -63,6 +71,7 @@ pub fn view_window<'a, 'b>( let toolbar = container( row![ tb_button("New", Message::ScaleManagerNew, false), + tb_button("Copy", Message::ScaleManagerCopy, false), tb_button("Delete", Message::ScaleManagerDelete, false), Space::new().width(Fill), tb_button("Set Current", Message::ScaleManagerSetCurrent, false), @@ -82,6 +91,18 @@ pub fn view_window<'a, 'b>( let rows: Vec> = scales .iter() .map(|(name, ratio)| { + // The row being renamed shows an inline text field; a single click + // selects, a double click starts renaming. + if rename_active == Some(name.as_str()) { + return text_input("", rename_buf) + .id(rename_input_id()) + .on_input(Message::ScaleRenameEdit) + .on_submit(Message::ScaleRenameCommit) + .size(11) + .padding([4, 8]) + .width(Fill) + .into(); + } let is_sel = name.as_str() == selected; let is_cur = name.eq_ignore_ascii_case(current); let check = crate::ui::icons::check_cell(is_cur, CURRENT_CHECK); @@ -92,26 +113,17 @@ pub fn view_window<'a, 'b>( ] .spacing(4) .align_y(iced::Center); - button(label) - .on_press(Message::ScaleManagerSelect(name.clone())) - .style(move |_: &Theme, st| button::Style { - background: Some(Background::Color(if is_sel { - ACTIVE - } else if matches!(st, button::Status::Hovered) { - Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, - } - } else { - Color::TRANSPARENT - })), - text_color: TEXT, - ..Default::default() - }) - .width(Fill) + let cell = container(label) .padding([4, 8]) + .width(Fill) + .style(move |_: &Theme| container::Style { + background: is_sel.then_some(Background::Color(ACTIVE)), + text_color: Some(TEXT), + ..Default::default() + }); + mouse_area(cell) + .on_press(Message::ScaleManagerSelect(name.clone())) + .on_double_click(Message::ScaleRenameStart(name.clone())) .into() }) .collect(); @@ -164,11 +176,10 @@ pub fn view_window<'a, 'b>( let editor = container( column![ text("Scale").size(10).color(DIM), - 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("New starts a blank scale; edit the fields and click Apply to save it. Changes are discarded if you close without Apply.") + text("Double-click a scale to rename it; edit its paper : drawing ratio here. New / Copy add a scale. Changes are kept only if you click Apply before closing.") .size(10) .color(DIM), ]