From b1eba180eb5fa3caa466a45156bce25821171425 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 13 Jul 2026 10:18:05 +0300 Subject: [PATCH] feat(annotative): add/remove drawing annotation scales The drawing's annotation-scale list (ACAD_SCALELIST) is now editable, so the set of scales offered by the status-bar scale pill can be grown or trimmed: - Scene::add_scale / remove_scale create or drop a Scale object and its SCALELIST dictionary entry (creating the SCALELIST dictionary, and registering it in the root, for drawings that carry none). - SCALELISTEDIT gains ADD / DELETE alongside the existing listing (e.g. `SCALELISTEDIT ADD 1:50`). - The scale popup (model space) grows a delete affordance on each non-active scale and a "+ Add scale..." row that primes the command line. The current annotation scale is protected from deletion. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/display.rs | 84 +++++++++++++++++++++++++++++++------ src/app/mod.rs | 4 ++ src/app/update/mod.rs | 25 +++++++++++ src/scene/mod.rs | 71 +++++++++++++++++++++++++++++++ src/ui/popup/scale_popup.rs | 69 ++++++++++++++++++++++++++---- 5 files changed, 233 insertions(+), 20 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 188aa2441..a847e647c 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -704,19 +704,79 @@ impl OpenCADStudio { } } - // SCALELISTEDIT — list the drawing's annotation scales. + // SCALELISTEDIT — list / add / delete the drawing's annotation scales. + // SCALELISTEDIT list + // SCALELISTEDIT ADD 1:50 add (name is a paper:drawing ratio) + // SCALELISTEDIT DELETE 1:50 remove (not the current scale) cmd if cmd == "SCALELISTEDIT" || cmd.starts_with("SCALELISTEDIT ") => { - let names: Vec = self.tabs[i] - .scene - .scale_list() - .into_iter() - .map(|(n, _, _)| n) - .collect(); - if names.is_empty() { - self.command_line.push_info("No annotation scales defined."); - } else { - self.command_line - .push_output(&format!("Annotation scales: {}", names.join(", "))); + let rest = cmd.trim_start_matches("SCALELISTEDIT").trim(); + let mut parts = rest.splitn(2, char::is_whitespace); + let sub = parts.next().unwrap_or("").to_uppercase(); + let arg = parts.next().unwrap_or("").trim(); + match sub.as_str() { + "ADD" => match arg.split_once(':') { + Some((p, d)) => match (p.trim().parse::(), d.trim().parse::()) { + (Ok(paper), Ok(drawing)) if paper > 0.0 && drawing > 0.0 => { + self.push_undo_snapshot(i, "SCALELISTEDIT"); + if self.tabs[i].scene.add_scale(arg, paper, drawing) { + self.tabs[i].dirty = true; + self.command_line + .push_output(&format!("Added annotation scale {arg}.")); + } else { + self.command_line + .push_info(&format!("Scale {arg} already exists.")); + } + } + _ => self + .command_line + .push_error("SCALELISTEDIT ADD: use a ratio like 1:50."), + }, + None => self + .command_line + .push_error("SCALELISTEDIT ADD: use a ratio like 1:50."), + }, + "DELETE" | "REMOVE" => { + let current = self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .clone(); + if arg.is_empty() { + self.command_line.push_info("Usage: SCALELISTEDIT DELETE "); + } else if arg.eq_ignore_ascii_case(¤t) { + self.command_line.push_error(&format!( + "Cannot delete the current annotation scale ({arg})." + )); + } else { + self.push_undo_snapshot(i, "SCALELISTEDIT"); + if self.tabs[i].scene.remove_scale(arg) { + self.tabs[i].dirty = true; + self.command_line + .push_output(&format!("Removed annotation scale {arg}.")); + } else { + self.command_line + .push_info(&format!("No annotation scale named {arg}.")); + } + } + } + "" => { + let names: Vec = self.tabs[i] + .scene + .scale_list() + .into_iter() + .map(|(n, _, _)| n) + .collect(); + if names.is_empty() { + self.command_line.push_info("No annotation scales defined."); + } else { + self.command_line + .push_output(&format!("Annotation scales: {}", names.join(", "))); + } + } + _ => self + .command_line + .push_info("Usage: SCALELISTEDIT [ADD 1:50 | DELETE 1:50]"), } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 87384bc7c..68193b8e3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1455,6 +1455,10 @@ pub enum Message { ToggleScalePopup, /// Close the scale picker popup. CloseScalePopup, + /// Delete a named annotation scale from the drawing's ACAD_SCALELIST. + ScaleListDelete(String), + /// Prime the command line to add a new annotation scale. + ScaleListAddPrompt, /// Toggle the leftmost hamburger's Model/layout list dropdown. ToggleLayoutList, /// Close the Model/layout list dropdown. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0a102b83a..8e81657d4 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1647,6 +1647,31 @@ impl OpenCADStudio { self.scale_popup_open = false; Task::none() } + Message::ScaleListDelete(name) => { + let i = self.active_tab; + let current = self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .clone(); + // Keep the popup open so several can be removed in a row; never + // remove the current annotation scale out from under the drawing. + if !name.eq_ignore_ascii_case(¤t) { + self.push_undo_snapshot(i, "SCALELISTEDIT"); + if self.tabs[i].scene.remove_scale(&name) { + self.tabs[i].dirty = true; + } + } + Task::none() + } + Message::ScaleListAddPrompt => { + // Close the popup and prime the command line; the user types the + // ratio (e.g. 1:50) and commits it via SCALELISTEDIT ADD. + self.scale_popup_open = false; + self.command_line.input = "SCALELISTEDIT ADD ".to_string(); + Task::none() + } Message::ToggleLayoutList => { self.layout_list_open ^= true; Task::none() diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f55eafed4..b6d15f518 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1874,6 +1874,77 @@ impl Scene { list } + /// 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")) + { + Some(h) => h, + None => { + let h = self.document.allocate_handle(); + let mut d = Dictionary::new(); + d.handle = h; + d.owner = root_h; + self.document.objects.insert(h, ObjectType::Dictionary(d)); + if let Some(ObjectType::Dictionary(root)) = + self.document.objects.get_mut(&root_h) + { + root.add_entry("ACAD_SCALELIST", h); + } + 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; + s.owner_handle = scalelist_h; + self.document.objects.insert(sh, ObjectType::Scale(s)); + if let Some(ObjectType::Dictionary(sl)) = self.document.objects.get_mut(&scalelist_h) { + sl.add_entry(name, sh); + } + true + } + + /// Remove a named annotation scale from the drawing's `ACAD_SCALELIST` + /// (both the `Scale` object and its dictionary entry). Returns `false` if + /// no such scale exists. The current annotation scale should not be removed + /// — 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 { + return false; + }; + 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)); + } + true + } + /// List of user viewports in the current layout: (handle, label, frozen_layer_handles). pub fn viewport_list(&self) -> Vec<(acadrust::Handle, String, Vec)> { if self.current_layout == "Model" { diff --git a/src/ui/popup/scale_popup.rs b/src/ui/popup/scale_popup.rs index 1a2803e00..2a3de01fb 100644 --- a/src/ui/popup/scale_popup.rs +++ b/src/ui/popup/scale_popup.rs @@ -23,7 +23,7 @@ pub fn scale_popup_overlay( pill: Option, win: (f32, f32), ) -> Element<'static, Message> { - let rows: Vec> = file_scales + let mut rows: Vec> = file_scales .into_iter() .map(|(label, anno_scale, vp_scale)| { let active = if is_model { @@ -38,10 +38,18 @@ pub fn scale_popup_overlay( } else { Message::SetViewportScale(vp_scale) }; - scale_row(label, active, msg) + // Editing the drawing's scale list is a model-space concept; in paper + // space the popup only picks a viewport scale, so no delete there. + scale_row(label, active, is_model && !active, msg) }) .collect(); + // Model space gets an "add scale" affordance at the bottom. + if is_model { + rows.push(add_scale_row()); + } + + let width = if is_model { 150.0 } else { 120.0 }; let panel = container(column(rows)) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), @@ -52,25 +60,30 @@ pub fn scale_popup_overlay( }, ..Default::default() }) - .width(Length::Fixed(120.0)); + .width(Length::Fixed(width)); - let positioned = super::position_statusbar_popup(panel.into(), pill, win, 120.0, true); + let positioned = super::position_statusbar_popup(panel.into(), pill, win, width, true); mouse_area(positioned) .on_press(Message::CloseScalePopup) .into() } -fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Message> { +fn scale_row( + label: String, + active: bool, + deletable: bool, + msg: Message, +) -> Element<'static, Message> { let check = crate::ui::icons::check_cell(active, CHECK_COLOR); - let lbl = text(label) + let lbl = text(label.clone()) .size(11) .color(if active { LABEL_ON } else { LABEL_OFF }); let content = row![check, lbl].spacing(6).align_y(iced::Center); - button(content) + let select = button(content) .on_press(msg) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { @@ -80,7 +93,41 @@ fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Mess ..Default::default() }) .width(Fill) - .padding([4, 10]) + .padding([4, 10]); + + if deletable { + let del = button(text("\u{00d7}").size(13).color(LABEL_OFF)) + .on_press(Message::ScaleListDelete(label)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + text_color: match status { + button::Status::Hovered => DELETE_HOVER, + _ => LABEL_OFF, + }, + ..Default::default() + }) + .padding([4, 8]); + row![select, del].align_y(iced::Center).into() + } else { + select.into() + } +} + +fn add_scale_row() -> Element<'static, Message> { + button(text("+ Add scale...").size(11).color(CHECK_COLOR)) + .on_press(Message::ScaleListAddPrompt) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .width(Fill) + .padding([5, 10]) .into() } @@ -110,6 +157,12 @@ const CHECK_COLOR: Color = Color { b: 1.00, a: 1.0, }; +const DELETE_HOVER: Color = Color { + r: 0.95, + g: 0.45, + b: 0.40, + a: 1.0, +}; const LABEL_ON: Color = Color { r: 0.92, g: 0.92,