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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-13 10:39:14 +03:00
1 parent b1eba180eb
commit 1071570f40
9 files changed
+427 -67

No files matched your search

+27 -4
View File
@@ -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(),
+106 -11
View File
@@ -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(&current) {
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<f64>, Option<f64>) {
(
self.scale_manager_name_buf.trim().to_string(),
self.scale_manager_paper_buf.trim().parse::<f64>().ok(),
self.scale_manager_drawing_buf.trim().parse::<f64>().ok(),
)
}
}
+1
View File
@@ -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))
}
+29
View File
@@ -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,
&current,
&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(),
+51
View File
@@ -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<acadrust::Handle>)> {
if self.current_layout == "Model" {
+11 -43
View File
@@ -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,
+1
View File
@@ -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;
+190
View File
@@ -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<Element<'_, Message>> = 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()
}
+11 -9
View File
@@ -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)