From 1071570f402e850e61e316ab671e34fffccdbf02 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 13 Jul 2026 10:39:14 +0300 Subject: [PATCH] feat(annotative): annotation-scale manager window Replace the scale popup's inline delete/add affordances with a single "Manage scales..." row that opens a proper manager, matching the style managers: it reuses the shared style-manager palette and chrome (toolbar + list + editor frame), so it looks and behaves like the Text / Dim / MLeader managers. The manager lists the drawing's annotation scales (name + ratio, the current one checked) and edits them through a Name / Paper units / Drawing units editor: New adds a scale from the fields, Apply renames / re-ratios the selected one, Delete removes it (never the current scale), and Set Current makes the selected scale the model-space annotation scale. Backed by Scene::add_scale / remove_scale / edit_scale on ACAD_SCALELIST. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 31 +++++- src/app/update/mod.rs | 117 +++++++++++++++++++-- src/app/view/mod.rs | 1 + src/app/view/modal.rs | 29 ++++++ src/scene/mod.rs | 51 +++++++++ src/ui/popup/scale_popup.rs | 54 ++-------- src/ui/style/mod.rs | 1 + src/ui/style/scale_manager.rs | 190 ++++++++++++++++++++++++++++++++++ src/ui/style/style_manager.rs | 20 ++-- 9 files changed, 427 insertions(+), 67 deletions(-) create mode 100644 src/ui/style/scale_manager.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 68193b8e3..4ba7b9d6f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -659,6 +659,12 @@ pub(super) struct OpenCADStudio { layout_manager_selected: String, layout_manager_rename_buf: String, + // ── Annotation-scale Manager ────────────────────────────────────────── + scale_manager_selected: String, + scale_manager_name_buf: String, + scale_manager_paper_buf: String, + scale_manager_drawing_buf: String, + // ── Plot Style Panel ────────────────────────────────────────────────── /// Selected ACI index in the panel (1-255). plotstyle_panel_aci: u8, @@ -1106,6 +1112,7 @@ pub enum ModalKind { AttributeEditor, LayerDeleteWarning, Aliases, + ScaleManager, } /// Identifies a DimStyle field that can be edited in the dialog. @@ -1455,10 +1462,22 @@ 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, + /// Open the annotation-scale manager (from the scale popup's Manage row). + ScaleManagerOpen, + /// Select a scale row in the manager (loads it into the editor). + ScaleManagerSelect(String), + /// Add a new scale from the editor fields. + ScaleManagerNew, + /// Delete the selected scale. + ScaleManagerDelete, + /// Set the selected scale as the current annotation scale. + ScaleManagerSetCurrent, + /// 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. ToggleLayoutList, /// Close the Model/layout list dropdown. @@ -2234,6 +2253,10 @@ impl OpenCADStudio { alias_editor_rows: Vec::new(), // 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(), layout_manager_rename_buf: String::new(), plotstyle_panel_aci: 1, ps_color_buf: String::new(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8e81657d4..40f63bd4a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1647,29 +1647,97 @@ impl OpenCADStudio { self.scale_popup_open = false; Task::none() } - Message::ScaleListDelete(name) => { + Message::ScaleManagerOpen => { let i = self.active_tab; - let current = self.tabs[i] + self.scale_popup_open = false; + let cur = 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.load_scale_editor(&cur); + self.active_modal = Some(crate::app::ModalKind::ScaleManager); + Task::none() + } + Message::ScaleManagerSelect(name) => { + 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() + } + Message::ScaleManagerDrawingBuf(s) => { + self.scale_manager_drawing_buf = s; + 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; + } + } + } + Task::none() + } + Message::ScaleManagerApply => { + 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; + } + } + } + } + Task::none() + } + Message::ScaleManagerDelete => { + let i = self.active_tab; + let sel = self.scale_manager_selected.clone(); + let cur = self.tabs[i] + .scene + .document + .header + .current_annotation_scale + .clone(); + if !sel.is_empty() && !sel.eq_ignore_ascii_case(&cur) { self.push_undo_snapshot(i, "SCALELISTEDIT"); - if self.tabs[i].scene.remove_scale(&name) { + if self.tabs[i].scene.remove_scale(&sel) { + self.scale_manager_selected.clear(); 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(); + Message::ScaleManagerSetCurrent => { + let i = self.active_tab; + let sel = self.scale_manager_selected.clone(); + if let Some((_, anno, _)) = self + .tabs[i] + .scene + .scale_list() + .into_iter() + .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.bump_geometry(); + self.tabs[i].dirty = true; + } Task::none() } Message::ToggleLayoutList => { @@ -3953,4 +4021,31 @@ impl OpenCADStudio { Message::DsSetHandle { field, value } => self.on_ds_set_handle(field, value), } } + + /// Load a named scale into the scale-manager editor buffers (name + the + /// paper / drawing units); blank ratios when the scale isn't found. + 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}"); + self.scale_manager_drawing_buf = format!("{d}"); + } + None => { + self.scale_manager_paper_buf.clear(); + self.scale_manager_drawing_buf.clear(); + } + } + } + + /// 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/app/view/mod.rs b/src/app/view/mod.rs index ef91f3e68..c648a9a58 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1573,6 +1573,7 @@ impl OpenCADStudio { AttributeEditor => (640, 500), LayerDeleteWarning => (440, 200), Aliases => (480, 520), + ScaleManager => (520, 360), }; Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H)) } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6e44c94cb..a80f25cbe 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -65,6 +65,35 @@ impl OpenCADStudio { 320, ) } + super::super::ModalKind::ScaleManager => { + let tab = &self.tabs[self.active_tab]; + let scales: Vec<(String, String)> = tab + .scene + .scale_list() + .into_iter() + .map(|(name, _, _)| { + let ratio = tab + .scene + .scale_paper_drawing(&name) + .map(|(p, d)| format!("{p}:{d}")) + .unwrap_or_default(); + (name, ratio) + }) + .collect(); + let current = tab.scene.document.header.current_annotation_scale.clone(); + sized( + crate::ui::style::scale_manager::view_window( + &scales, + &self.scale_manager_selected, + ¤t, + &self.scale_manager_name_buf, + &self.scale_manager_paper_buf, + &self.scale_manager_drawing_buf, + ), + 520, + 360, + ) + } super::super::ModalKind::Plotstyle => sized( crate::ui::style::plotstyle::view_window( self.active_plot_style.as_ref(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b6d15f518..dcd952cea 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1945,6 +1945,57 @@ impl Scene { true } + /// Rename `old_name` and/or change its paper:drawing ratio. Returns `false` + /// 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 { + 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; + } + if let Some(ObjectType::Scale(s)) = self.document.objects.get_mut(&sh) { + s.name = new_name.to_string(); + s.paper_units = paper; + 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(); + } + } + true + } + + /// The (paper_units, drawing_units) of a named scale, for the editor. + pub fn scale_paper_drawing(&self, name: &str) -> Option<(f64, f64)> { + self.document.objects.values().find_map(|o| match o { + ObjectType::Scale(s) if !s.is_temporary && s.name.eq_ignore_ascii_case(name) => { + Some((s.paper_units, s.drawing_units)) + } + _ => None, + }) + } + /// 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 2a3de01fb..d21fee568 100644 --- a/src/ui/popup/scale_popup.rs +++ b/src/ui/popup/scale_popup.rs @@ -38,15 +38,13 @@ pub fn scale_popup_overlay( } else { Message::SetViewportScale(vp_scale) }; - // 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) + scale_row(label, active, msg) }) .collect(); - // Model space gets an "add scale" affordance at the bottom. + // Model space gets a "Manage scales..." row that opens the scale manager. if is_model { - rows.push(add_scale_row()); + rows.push(manage_row()); } let width = if is_model { 150.0 } else { 120.0 }; @@ -69,21 +67,16 @@ pub fn scale_popup_overlay( .into() } -fn scale_row( - label: String, - active: bool, - deletable: bool, - msg: Message, -) -> Element<'static, Message> { +fn scale_row(label: String, active: bool, msg: Message) -> Element<'static, Message> { let check = crate::ui::icons::check_cell(active, CHECK_COLOR); - let lbl = text(label.clone()) + let lbl = text(label) .size(11) .color(if active { LABEL_ON } else { LABEL_OFF }); let content = row![check, lbl].spacing(6).align_y(iced::Center); - let select = button(content) + button(content) .on_press(msg) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { @@ -93,32 +86,13 @@ fn scale_row( ..Default::default() }) .width(Fill) - .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() - } + .padding([4, 10]) + .into() } -fn add_scale_row() -> Element<'static, Message> { - button(text("+ Add scale...").size(11).color(CHECK_COLOR)) - .on_press(Message::ScaleListAddPrompt) +fn manage_row() -> Element<'static, Message> { + button(text("Manage scales...").size(11).color(CHECK_COLOR)) + .on_press(Message::ScaleManagerOpen) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => ROW_HOVER, @@ -157,12 +131,6 @@ 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, diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index b5093b65a..cc9f26a09 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -6,4 +6,5 @@ pub mod textstyle; pub mod tablestyle; pub mod point_style; pub mod style_list; +pub mod scale_manager; pub mod style_manager; diff --git a/src/ui/style/scale_manager.rs b/src/ui/style/scale_manager.rs new file mode 100644 index 000000000..a8f9df382 --- /dev/null +++ b/src/ui/style/scale_manager.rs @@ -0,0 +1,190 @@ +//! Annotation-scale manager — add / delete / edit the drawing's ACAD_SCALELIST. +//! +//! Shares the style managers' frame (toolbar + list + editor + palette) so it +//! looks and behaves like Text / Dim / MLeader style managers, but scales are +//! plain `Scale` objects rather than symbol-table styles, so it drives its own +//! messages instead of the StyleKind machinery. + +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::{Background, Border, Color, Element, Fill, Theme}; + +const INPUT_BG: Color = Color { + r: 0.10, + g: 0.10, + b: 0.10, + a: 1.0, +}; +const ACTIVE: Color = Color { + r: 0.20, + g: 0.40, + b: 0.70, + a: 1.0, +}; +const CURRENT_CHECK: Color = Color { + r: 0.30, + g: 0.82, + b: 0.36, + a: 1.0, +}; + +fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { + text_input::Style { + background: Background::Color(INPUT_BG), + border: Border { + color: BORDER, + width: 1.0, + radius: 4.0.into(), + }, + icon: TEXT, + placeholder: DIM, + value: TEXT, + selection: Color { + r: 0.20, + g: 0.46, + b: 0.80, + a: 0.45, + }, + } +} + +/// `scales` is (name, "paper:drawing" ratio label). The editor buffers hold the +/// name / paper / drawing of the row being added or edited. +pub fn view_window<'a, 'b>( + scales: &'b [(String, String)], + selected: &'b str, + current: &'b str, + name_buf: &'a str, + paper_buf: &'a str, + drawing_buf: &'a str, +) -> Element<'a, Message> { + // ── Toolbar: New / Delete | Set Current / Apply ─────────────────────── + let toolbar = container( + row![ + tb_button("New", Message::ScaleManagerNew, false), + tb_button("Delete", Message::ScaleManagerDelete, false), + Space::new().width(Fill), + tb_button("Set Current", Message::ScaleManagerSetCurrent, false), + tb_button("Apply", Message::ScaleManagerApply, true), + ] + .spacing(4) + .align_y(iced::Center), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(TB)), + ..Default::default() + }) + .width(Fill) + .padding([5, 8]); + + // ── Left: scale list ────────────────────────────────────────────────── + let rows: Vec> = scales + .iter() + .map(|(name, ratio)| { + 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); + let label = row![ + check, + text(name.clone()).size(11).color(TEXT).width(Fill), + text(ratio.clone()).size(10).color(DIM), + ] + .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) + .padding([4, 8]) + .into() + }) + .collect(); + + let list_panel = container( + column![ + text("Scales").size(10).color(DIM), + container(scrollable(column(rows).spacing(1)).height(Fill)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(LIST)), + border: Border { + color: BORDER, + width: 1.0, + radius: 3.0.into() + }, + ..Default::default() + }) + .width(Fill) + .height(Fill) + .padding(2), + ] + .spacing(4) + .height(Fill), + ) + .width(190) + .height(Fill) + .padding(iced::Padding { + top: 12.0, + right: 8.0, + bottom: 12.0, + left: 12.0, + }); + + // ── 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 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), + Space::new().height(6), + text("Type the ratio in the fields, then New to add or Apply to update the selected scale.") + .size(10) + .color(DIM), + ] + .spacing(8), + ) + .width(Fill) + .height(Fill) + .padding(12); + + let body = row![list_panel, vsep(), editor].height(Fill); + + container(column![toolbar, hdivider(), body]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into() +} diff --git a/src/ui/style/style_manager.rs b/src/ui/style/style_manager.rs index 62ec2a6e0..8307028f3 100644 --- a/src/ui/style/style_manager.rs +++ b/src/ui/style/style_manager.rs @@ -12,31 +12,33 @@ use iced::widget::button::{Status, Style}; use iced::widget::{button, column, container, row, scrollable, text, Space}; use iced::{Background, Border, Color, Element, Fill, Theme}; -const TB: Color = Color { +// Shared palette — also reused by the scale manager so it matches the style +// managers exactly ([[feedback_shared_infra]]). +pub(crate) const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0, }; -const BG: Color = Color { +pub(crate) const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0, }; -const BORDER: Color = Color { +pub(crate) const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }; -const TEXT: Color = Color { +pub(crate) const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0, }; -const DIM: Color = Color { +pub(crate) const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, @@ -48,7 +50,7 @@ const ACCENT: Color = Color { b: 0.85, a: 1.0, }; -const LIST: Color = Color { +pub(crate) const LIST: Color = Color { r: 0.12, g: 0.12, b: 0.12, @@ -170,7 +172,7 @@ pub fn view<'a, 'b>(s: Scaffold<'a, 'b>) -> Element<'a, Message> { // ── Shared chrome ────────────────────────────────────────────────────────── -fn tb_button<'a>(label: &'a str, msg: Message, accent: bool) -> Element<'a, Message> { +pub(crate) fn tb_button<'a>(label: &'a str, msg: Message, accent: bool) -> Element<'a, Message> { let pad = if accent { [4, 14] } else { [4, 10] }; button(text(label).size(11)) .on_press(msg) @@ -212,7 +214,7 @@ fn btn_s(accent: bool) -> impl Fn(&Theme, Status) -> Style { } } -fn hdivider<'a>() -> Element<'a, Message> { +pub(crate) fn hdivider<'a>() -> Element<'a, Message> { container(Space::new().width(Fill).height(1)) .width(Fill) .height(1) @@ -223,7 +225,7 @@ fn hdivider<'a>() -> Element<'a, Message> { .into() } -fn vsep<'a>() -> Element<'a, Message> { +pub(crate) fn vsep<'a>() -> Element<'a, Message> { container(Space::new().width(1).height(Fill)) .width(1) .height(Fill)