diff --git a/src/app/mod.rs b/src/app/mod.rs index 725ed6fd6..dbafe7aaa 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -361,11 +361,14 @@ pub(super) struct OpenCADStudio { attr_editor_handle: Option, /// Block name shown in the editor's title bar. attr_editor_block: String, - /// Working copy of the block's attributes as `(tag, prompt, value)`, in the - /// same order as `Insert::attributes`. The prompt is read from the block's - /// matching ATTDEF (blank if none). Only the value is edited; the whole row - /// is written back on OK. - attr_editor_fields: Vec<(String, String, String)>, + /// Working copy of the block's attributes, in the same order as + /// `Insert::attributes`. Each row carries the value plus the text-option and + /// common-property fields the editor edits; written back on OK. + attr_editor_rows: Vec, + /// Which editor tab is showing. + attr_editor_tab: crate::ui::window::attribute_editor::AttrTab, + /// Highlighted attribute row driving the Text Options / Properties tabs. + attr_editor_selected: usize, /// Plugin ids the user turned off in the Plugin Manager. Disabled plugins /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. @@ -1456,10 +1459,29 @@ pub enum Message { // ── Attribute editor dialog ─────────────────────────────────────────── /// Open the attribute editor for an INSERT (double-click / ATTEDIT). AttrEditorOpen(acadrust::Handle), - /// Live edit of the attribute value at row `idx` in the editor dialog. + /// Switch the editor's active tab. + AttrEditorTab(crate::ui::window::attribute_editor::AttrTab), + /// Select the row the Text Options / Properties tabs act on. + AttrEditorSelect(usize), + /// Live edit of the attribute value at row `idx` (Attribute tab). AttrEditorInput { idx: usize, value: String }, - /// Apply every attribute edit to the block and close the dialog (OK). - AttrEditorOk, + // Text Options — all act on the selected row: + AttrEditorTextStyle(String), + AttrEditorJustify(String), + AttrEditorHeight(String), + AttrEditorRotation(String), + AttrEditorWidth(String), + AttrEditorOblique(String), + AttrEditorBackwards(bool), + AttrEditorUpsideDown(bool), + // Properties — all act on the selected row: + AttrEditorLayer(String), + AttrEditorLinetype(String), + AttrEditorColor(String), + AttrEditorLineweight(acadrust::types::LineWeight), + /// Commit every attribute edit to the block, keeping the dialog open + /// (Apply); the frame ✕ closes and discards any un-applied edits. + AttrEditorApply, /// Title-bar pressed: begin dragging the active modal. ModalGrab, /// Cursor moved while dragging the modal title bar. @@ -1934,7 +1956,9 @@ impl OpenCADStudio { modal_dragging: false, attr_editor_handle: None, attr_editor_block: String::new(), - attr_editor_fields: Vec::new(), + attr_editor_rows: Vec::new(), + attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute, + attr_editor_selected: 0, disabled_plugins: rustc_hash::FxHashSet::default(), external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), diff --git a/src/app/update/command.rs b/src/app/update/command.rs index e1335ea75..d4411f78b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -15,6 +15,7 @@ use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, }; use crate::ui::PropertiesPanel; +use crate::ui::window::attribute_editor::{AttrRow, AttrTab}; use acadrust::types::Color as AcadColor; use acadrust::{EntityType as AcadEntityType, Handle}; use iced::time::Instant; @@ -1215,22 +1216,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) { let i = self.active_tab; let doc = &self.tabs[i].scene.document; - // Ok((block, fields)) to open; Err(msg) to report and stay closed. The + // Ok((block, rows)) to open; Err(msg) to report and stay closed. The // borrow of `doc` ends with this match, before any `self` mutation. let result = match doc.get_entity(handle) { Some(acadrust::EntityType::Insert(ins)) if !ins.attributes.is_empty() => { // The prompt text lives on the block's ATTDEFs, not on the // attribute instances — map tag → prompt from the definition. let prompts = block_attr_prompts(doc, &ins.block_name); - let fields = ins + let rows = ins .attributes .iter() - .map(|a| { - let prompt = prompts.get(&a.tag).cloned().unwrap_or_default(); - (a.tag.clone(), prompt, a.get_value().to_string()) - }) + .map(|a| attr_row_from_entity(a, &prompts)) .collect::>(); - Ok((ins.block_name.clone(), fields)) + Ok((ins.block_name.clone(), rows)) } Some(acadrust::EntityType::Insert(_)) => { Err("ATTEDIT This block has no attributes.") @@ -1238,9 +1236,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { _ => Err("ATTEDIT Select a block with attributes."), }; match result { - Ok((block, fields)) => { + Ok((block, rows)) => { self.attr_editor_block = block; - self.attr_editor_fields = fields; + self.attr_editor_rows = rows; + self.attr_editor_selected = 0; + self.attr_editor_tab = AttrTab::Attribute; self.attr_editor_handle = Some(handle); self.active_modal = Some(crate::app::ModalKind::AttributeEditor); self.modal_offset = iced::Vector::ZERO; @@ -1249,6 +1249,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } + /// The currently-selected editor row, for the Text Options / Properties + /// edit handlers (which all act on that one attribute). + pub(super) fn attr_row_selected_mut(&mut self) -> Option<&mut AttrRow> { + let sel = self.attr_editor_selected; + self.attr_editor_rows.get_mut(sel) + } + /// Close the attribute editor without applying, if it is open. Used when /// the tab it belongs to closes or the active tab switches — its handle is /// document-local and must not outlive its tab. @@ -1259,45 +1266,35 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.attr_editor_handle = None; self.attr_editor_block.clear(); - self.attr_editor_fields.clear(); + self.attr_editor_rows.clear(); + self.attr_editor_selected = 0; + self.attr_editor_tab = AttrTab::Attribute; } - /// Apply every edited attribute value back to the block and close the - /// editor (OK). Values are written verbatim (attribute text is free-form). - /// The edits are positional — same order the dialog was populated in — so - /// blocks with duplicate tags stay correct; a tag-based fallback covers the - /// unlikely case the attribute list changed underneath the open dialog. - pub(super) fn on_attr_editor_ok(&mut self) -> Task { + /// Commit every edited attribute (value, text options, common properties) + /// back to the block, keeping the dialog open (Apply). Closing is the frame + /// ✕ (`CloseModal`), which discards any un-applied edits — matching the + /// other modal windows. Applied positionally so blocks with duplicate tags + /// stay correct; guarded on block name + count so an edit can never land on + /// a different entity if the selection changed under the open dialog. + pub(super) fn on_attr_editor_apply(&mut self) -> Task { let i = self.active_tab; - let Some(handle) = self.attr_editor_handle.take() else { - self.active_modal = None; + let Some(handle) = self.attr_editor_handle else { return Task::none(); }; - let fields = std::mem::take(&mut self.attr_editor_fields); - self.attr_editor_block.clear(); - self.active_modal = None; - self.modal_offset = iced::Vector::ZERO; + // Snapshot the working copy so the document can be mutated while the + // dialog's rows stay put (it remains open for further edits). + let rows = self.attr_editor_rows.clone(); + let block = self.attr_editor_block.clone(); self.push_undo_snapshot(i, "ATTEDIT"); let mut changed = false; if let Some(acadrust::EntityType::Insert(ins)) = self.tabs[i].scene.document.get_entity_mut(handle) { - if fields.len() == ins.attributes.len() { - for (attr, (_, _, val)) in ins.attributes.iter_mut().zip(fields.iter()) { - if attr.get_value() != val { - attr.set_value(val.clone()); - changed = true; - } - } - } else { - for (tag, _, val) in &fields { - if let Some(attr) = ins.attributes.iter_mut().find(|a| &a.tag == tag) { - if attr.get_value() != val { - attr.set_value(val.clone()); - changed = true; - } - } + if ins.block_name == block && rows.len() == ins.attributes.len() { + for (attr, row) in ins.attributes.iter_mut().zip(rows.iter()) { + changed |= apply_attr_row(attr, row); } } } @@ -1313,6 +1310,107 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } +/// Build the editor's working copy of one attribute. Angles are shown in +/// degrees; numeric fields become strings so the user can type freely. +fn attr_row_from_entity( + a: &acadrust::entities::AttributeEntity, + prompts: &rustc_hash::FxHashMap, +) -> AttrRow { + let fmt = |v: f64| format!("{v}"); + AttrRow { + tag: a.tag.clone(), + prompt: prompts.get(&a.tag).cloned().unwrap_or_default(), + value: a.get_value().to_string(), + text_style: a.text_style.clone(), + height: fmt(a.height), + rotation: fmt(a.rotation.to_degrees()), + width_factor: fmt(a.width_factor), + oblique: fmt(a.oblique_angle.to_degrees()), + h_align: a.horizontal_alignment, + v_align: a.vertical_alignment, + backwards: a.text_generation_flags & 0x2 != 0, + upside_down: a.text_generation_flags & 0x4 != 0, + layer: a.common.layer.clone(), + color: a.common.color, + linetype: a.common.linetype.clone(), + line_weight: a.common.line_weight, + } +} + +/// Write one edited row back onto its attribute; returns whether anything +/// changed. Numeric fields are parsed (angles from degrees); an unparseable +/// field is left as-is. +fn apply_attr_row(a: &mut acadrust::entities::AttributeEntity, row: &AttrRow) -> bool { + let mut ch = false; + if a.get_value() != row.value { + a.set_value(row.value.clone()); + ch = true; + } + if a.text_style != row.text_style { + a.text_style = row.text_style.clone(); + ch = true; + } + if let Ok(h) = row.height.trim().parse::() { + if a.height != h { + a.height = h; + ch = true; + } + } + if let Ok(deg) = row.rotation.trim().parse::() { + let rad = deg.to_radians(); + if (a.rotation - rad).abs() > 1e-12 { + a.rotation = rad; + ch = true; + } + } + if let Ok(w) = row.width_factor.trim().parse::() { + if a.width_factor != w { + a.width_factor = w; + ch = true; + } + } + if let Ok(deg) = row.oblique.trim().parse::() { + let rad = deg.to_radians(); + if (a.oblique_angle - rad).abs() > 1e-12 { + a.oblique_angle = rad; + ch = true; + } + } + if a.horizontal_alignment != row.h_align { + a.horizontal_alignment = row.h_align; + ch = true; + } + if a.vertical_alignment != row.v_align { + a.vertical_alignment = row.v_align; + ch = true; + } + // Text generation flags: bit 0x2 = backwards, bit 0x4 = upside down. + let new_flags = (a.text_generation_flags & !0x6) + | if row.backwards { 0x2 } else { 0 } + | if row.upside_down { 0x4 } else { 0 }; + if new_flags != a.text_generation_flags { + a.text_generation_flags = new_flags; + ch = true; + } + if a.common.layer != row.layer { + a.common.layer = row.layer.clone(); + ch = true; + } + if a.common.color != row.color { + a.common.color = row.color; + ch = true; + } + if a.common.linetype != row.linetype { + a.common.linetype = row.linetype.clone(); + ch = true; + } + if a.common.line_weight != row.line_weight { + a.common.line_weight = row.line_weight; + ch = true; + } + ch +} + /// Map each attribute tag to the prompt text declared on the block's matching /// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is /// defined once on the block definition. Tags with no definition are absent. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 78185d7e0..5db14ac2b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -108,7 +108,9 @@ impl OpenCADStudio { Some(AttributeEditor) => { self.attr_editor_handle = None; self.attr_editor_block.clear(); - self.attr_editor_fields.clear(); + self.attr_editor_rows.clear(); + self.attr_editor_selected = 0; + self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute; } _ => {} } @@ -2268,13 +2270,102 @@ impl OpenCADStudio { self.open_attribute_editor(handle); Task::none() } - Message::AttrEditorInput { idx, value } => { - if let Some(field) = self.attr_editor_fields.get_mut(idx) { - field.2 = value; + Message::AttrEditorTab(t) => { + self.attr_editor_tab = t; + Task::none() + } + Message::AttrEditorSelect(idx) => { + if idx < self.attr_editor_rows.len() { + self.attr_editor_selected = idx; } Task::none() } - Message::AttrEditorOk => self.on_attr_editor_ok(), + Message::AttrEditorInput { idx, value } => { + if let Some(r) = self.attr_editor_rows.get_mut(idx) { + r.value = value; + } + Task::none() + } + Message::AttrEditorTextStyle(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.text_style = s; + } + Task::none() + } + Message::AttrEditorJustify(label) => { + if let Some((h, v)) = + crate::ui::window::attribute_editor::justify_from_label(&label) + { + if let Some(r) = self.attr_row_selected_mut() { + r.h_align = h; + r.v_align = v; + } + } + Task::none() + } + Message::AttrEditorHeight(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.height = s; + } + Task::none() + } + Message::AttrEditorRotation(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.rotation = s; + } + Task::none() + } + Message::AttrEditorWidth(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.width_factor = s; + } + Task::none() + } + Message::AttrEditorOblique(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.oblique = s; + } + Task::none() + } + Message::AttrEditorBackwards(b) => { + if let Some(r) = self.attr_row_selected_mut() { + r.backwards = b; + } + Task::none() + } + Message::AttrEditorUpsideDown(b) => { + if let Some(r) = self.attr_row_selected_mut() { + r.upside_down = b; + } + Task::none() + } + Message::AttrEditorLayer(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.layer = s; + } + Task::none() + } + Message::AttrEditorLinetype(s) => { + if let Some(r) = self.attr_row_selected_mut() { + r.linetype = if s == "ByLayer" { String::new() } else { s }; + } + Task::none() + } + Message::AttrEditorColor(label) => { + if let Some(c) = crate::ui::window::attribute_editor::color_from_label(&label) { + if let Some(r) = self.attr_row_selected_mut() { + r.color = c; + } + } + Task::none() + } + Message::AttrEditorLineweight(lw) => { + if let Some(r) = self.attr_row_selected_mut() { + r.line_weight = lw; + } + Task::none() + } + Message::AttrEditorApply => self.on_attr_editor_apply(), Message::ModalGrab => { // Start a drag; the first ModalDragMove seeds the reference. self.modal_dragging = true; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3174ebdcf..6d5a8c9a6 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1363,7 +1363,7 @@ impl OpenCADStudio { OverwriteWarning => (420, 180), SaveDialog => (560, 480), PointStyle => (360, 470), - AttributeEditor => (460, 440), + AttributeEditor => (640, 500), }; 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 484b7f416..f5f4f91ac 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -511,14 +511,36 @@ impl OpenCADStudio { 360, 470, ), - super::super::ModalKind::AttributeEditor => sized( - crate::ui::window::attribute_editor::view_window( - &self.attr_editor_block, - &self.attr_editor_fields, - ), - 460, - 440, - ), + super::super::ModalKind::AttributeEditor => { + let doc = &self.tabs[self.active_tab].scene.document; + let layers: Vec = doc.layers.iter().map(|l| l.name.clone()).collect(); + let mut linetypes: Vec = vec!["ByLayer".to_string()]; + linetypes.extend( + doc.line_types + .iter() + .map(|lt| lt.name.clone()) + .filter(|n| !n.is_empty() && n != "ByLayer"), + ); + let styles: Vec = doc + .text_styles + .iter() + .map(|s| s.name.trim().to_string()) + .filter(|n| !n.is_empty()) + .collect(); + sized( + crate::ui::window::attribute_editor::view_window( + &self.attr_editor_block, + &self.attr_editor_rows, + self.attr_editor_selected, + self.attr_editor_tab, + layers, + linetypes, + styles, + ), + 640, + 500, + ) + } super::super::ModalKind::SaveDialog => sized( save_as_dialog_window( &self.save_dialog_filename, diff --git a/src/ui/window/attribute_editor.rs b/src/ui/window/attribute_editor.rs index f44a3909b..1afb0b814 100644 --- a/src/ui/window/attribute_editor.rs +++ b/src/ui/window/attribute_editor.rs @@ -1,22 +1,149 @@ -//! Attribute editor dialog — edit the attribute values of a single block +//! Enhanced attribute editor dialog — edit the attributes of one block //! reference (INSERT). Opened by double-clicking a block that carries -//! attributes, or by the ATTEDIT command with such a block selected. +//! attributes, or by ATTEDIT with such a block selected. //! -//! Each attribute is one row: its prompt/tag (read-only) and an editable value -//! box. OK writes every value back to the block; Cancel (the ✕ or button) -//! discards. The heavy lifting — applying edits, undo, repaint — lives in the -//! update handler (`Message::AttrEditorOk`); this module is pure layout. +//! Three tabs, mirroring the standard enhanced attribute editor: +//! * **Attribute** — the tag / prompt / value list; the selected row's value +//! is edited below the list. +//! * **Text Options** — the selected attribute's text formatting (style, +//! justification, height, rotation, width, oblique, flags). +//! * **Properties** — the selected attribute's common entity properties +//! (layer, linetype, colour, lineweight). +//! +//! This module is pure layout over a working copy ([`AttrRow`]); applying the +//! edits, undo and repaint live in the update handler (`Message::AttrEditorApply`). use crate::app::Message; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use acadrust::entities::{HorizontalAlignment, VerticalAlignment}; +use acadrust::types::{Color as AcadColor, LineWeight}; +use iced::widget::{ + button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, +}; use iced::{Background, Border, Color, Element, Length, Theme}; +use crate::ui::properties::{lw_options, LwItem}; + +// Palette shared with the style-manager windows so the editor reads as one of +// them: toolbar (TB) on top, BG panels, ACTIVE for the selected tab / row. +const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; -const WHITE: Color = Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }; +const WHITE: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; -const ACCENT: Color = Color { r: 0.30, g: 0.62, b: 0.95, a: 1.0 }; +const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 }; const FIELD_BG: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }; -const BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 }; +const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; +const ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; +const ROW_SEL: Color = ACTIVE; +const ROW_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; + +const LABEL_W: f32 = 120.0; + +/// Which tab of the editor is showing. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AttrTab { + Attribute, + TextOptions, + Properties, +} + +/// Editable working copy of one attribute. Numeric fields are kept as strings +/// so the user can type freely; they are parsed when Apply is pressed. Angles +/// are shown and entered in degrees. +#[derive(Clone)] +pub struct AttrRow { + pub tag: String, + pub prompt: String, + pub value: String, + // Text options + pub text_style: String, + pub height: String, + pub rotation: String, + pub width_factor: String, + pub oblique: String, + pub h_align: HorizontalAlignment, + pub v_align: VerticalAlignment, + pub backwards: bool, + pub upside_down: bool, + // Properties (common entity) + pub layer: String, + pub color: AcadColor, + pub linetype: String, + pub line_weight: LineWeight, +} + +/// Combined justification options (horizontal × vertical), matching the +/// standard text-justification list. +pub const JUSTIFY: &[(&str, HorizontalAlignment, VerticalAlignment)] = &[ + ("Left", HorizontalAlignment::Left, VerticalAlignment::Baseline), + ("Center", HorizontalAlignment::Center, VerticalAlignment::Baseline), + ("Right", HorizontalAlignment::Right, VerticalAlignment::Baseline), + ("Aligned", HorizontalAlignment::Aligned, VerticalAlignment::Baseline), + ("Middle", HorizontalAlignment::Middle, VerticalAlignment::Baseline), + ("Fit", HorizontalAlignment::Fit, VerticalAlignment::Baseline), + ("Top Left", HorizontalAlignment::Left, VerticalAlignment::Top), + ("Top Center", HorizontalAlignment::Center, VerticalAlignment::Top), + ("Top Right", HorizontalAlignment::Right, VerticalAlignment::Top), + ("Middle Left", HorizontalAlignment::Left, VerticalAlignment::Middle), + ("Middle Center", HorizontalAlignment::Center, VerticalAlignment::Middle), + ("Middle Right", HorizontalAlignment::Right, VerticalAlignment::Middle), + ("Bottom Left", HorizontalAlignment::Left, VerticalAlignment::Bottom), + ("Bottom Center", HorizontalAlignment::Center, VerticalAlignment::Bottom), + ("Bottom Right", HorizontalAlignment::Right, VerticalAlignment::Bottom), +]; + +/// The justification label for an (h, v) pair (defaults to "Left"). +pub fn justify_label(h: HorizontalAlignment, v: VerticalAlignment) -> &'static str { + JUSTIFY + .iter() + .find(|(_, jh, jv)| *jh == h && *jv == v) + .map(|(l, _, _)| *l) + .unwrap_or("Left") +} + +/// Resolve a justification label back to its (h, v) pair. +pub fn justify_from_label(label: &str) -> Option<(HorizontalAlignment, VerticalAlignment)> { + JUSTIFY + .iter() + .find(|(l, _, _)| *l == label) + .map(|(_, h, v)| (*h, *v)) +} + +const COLOR_OPTIONS: &[&str] = &[ + "ByLayer", "ByBlock", "Red", "Yellow", "Green", "Cyan", "Blue", "Magenta", "White", +]; + +/// Display label for a colour (falls back to `Color ` for non-standard ACI). +pub fn color_label(c: AcadColor) -> String { + match c { + AcadColor::ByLayer => "ByLayer".into(), + AcadColor::ByBlock => "ByBlock".into(), + AcadColor::Index(1) => "Red".into(), + AcadColor::Index(2) => "Yellow".into(), + AcadColor::Index(3) => "Green".into(), + AcadColor::Index(4) => "Cyan".into(), + AcadColor::Index(5) => "Blue".into(), + AcadColor::Index(6) => "Magenta".into(), + AcadColor::Index(7) => "White".into(), + AcadColor::Index(n) => format!("Color {n}"), + AcadColor::Rgb { r, g, b } => format!("{r},{g},{b}"), + } +} + +/// Resolve a colour label back to an `AcadColor` (None if unknown). +pub fn color_from_label(label: &str) -> Option { + Some(match label { + "ByLayer" => AcadColor::ByLayer, + "ByBlock" => AcadColor::ByBlock, + "Red" => AcadColor::Index(1), + "Yellow" => AcadColor::Index(2), + "Green" => AcadColor::Index(3), + "Cyan" => AcadColor::Index(4), + "Blue" => AcadColor::Index(5), + "Magenta" => AcadColor::Index(6), + "White" => AcadColor::Index(7), + _ => return None, + }) +} fn field_style(_t: &Theme, _s: text_input::Status) -> text_input::Style { text_input::Style { @@ -29,114 +156,304 @@ fn field_style(_t: &Theme, _s: text_input::Status) -> text_input::Style { } } -/// Build the attribute editor dialog body. `block` is the reference's block -/// name (shown as a subtitle); `fields` are the `(tag, prompt, value)` triples -/// in attribute order — the row index is the routing key back to -/// `Message::AttrEditorInput`. Each row is labelled with the prompt (falling -/// back to the tag when the block defines none), so the label reads as it would -/// when inserting the block. +fn accent_btn(_t: &Theme, status: button::Status) -> button::Style { + let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { + Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 } + } else { + ACCENT + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: WHITE, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + } +} + +/// Horizontal 1px divider in the shared border colour (matches style windows). +fn hdivider<'a>() -> Element<'a, Message> { + container(Space::new().width(Length::Fill).height(1)) + .width(Length::Fill) + .height(1) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BORDER)), + ..Default::default() + }) + .into() +} + +/// One `label : widget` row with a fixed-width label column. +fn field_row<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { + row![ + container(text(label).size(12).color(DIM)).width(LABEL_W), + widget, + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A labelled editable text field for one of the selected row's string buffers. +fn edit_field<'a>( + label: &'a str, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, +) -> Element<'a, Message> { + let ti = text_input("", value) + .on_input(on_input) + .on_submit(Message::AttrEditorApply) + .style(field_style) + .size(13) + .padding([3, 6]) + .width(Length::Fill); + field_row(label, ti.into()) +} + +/// A labelled pick_list of owned string options. +fn pick_field<'a>( + label: &'a str, + options: Vec, + selected: Option, + on_select: impl Fn(String) -> Message + 'a, +) -> Element<'a, Message> { + let pl = pick_list(options, selected, on_select) + .text_size(13) + .padding([3, 6]) + .width(Length::Fill); + field_row(label, pl.into()) +} + +fn tab_button<'a>(label: &'a str, this: AttrTab, active: AttrTab) -> Element<'a, Message> { + let is_active = this == active; + button(text(label).size(11).color(WHITE)) + .padding([4, 12]) + .on_press(Message::AttrEditorTab(this)) + .style(move |_t: &Theme, status| button::Style { + background: Some(Background::Color(match (is_active, status) { + (true, _) => ACTIVE, + (false, button::Status::Hovered | button::Status::Pressed) => { + Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } + } + _ => Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 }, + })), + text_color: WHITE, + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + ..Default::default() + }) + .into() +} + +/// Build the enhanced attribute editor. `rows` is the working copy (attribute +/// order); `selected` is the highlighted row driving the Text Options / +/// Properties tabs. `layers` / `linetypes` / `styles` are the document's pick +/// lists. pub fn view_window<'a>( block: &'a str, - fields: &'a [(String, String, String)], + rows: &'a [AttrRow], + selected: usize, + tab: AttrTab, + layers: Vec, + linetypes: Vec, + styles: Vec, ) -> Element<'a, Message> { - let mut list = column![].spacing(6); - for (idx, (tag, prompt, value)) in fields.iter().enumerate() { - let label = if prompt.trim().is_empty() { - tag.as_str() - } else { - prompt.as_str() - }; - - let value_box = text_input("", value) - .on_input(move |v| Message::AttrEditorInput { idx, value: v }) - .on_submit(Message::AttrEditorOk) - .style(field_style) - .size(13) - .padding([4, 6]) - .width(Length::Fill); - - let attr_row = row![ - container(text(label).size(13).color(WHITE)) - .width(170) - .padding([4, 6]), - value_box, + // ── Top toolbar: block name on the left, Apply on the right ─────────── + // Mirrors the style-manager windows (actions left, primary action right). + let apply = button(text("Apply").size(11).color(WHITE)) + .padding([4, 14]) + .on_press(Message::AttrEditorApply) + .style(accent_btn); + let toolbar = container( + row![ + text(format!("Block: {block}")).size(12).color(DIM), + Space::new().width(Length::Fill), + apply, ] - .spacing(8) - .align_y(iced::Center); - list = list.push(attr_row); - } - - let body: Element<'_, Message> = if fields.is_empty() { - text("This block has no attributes.") - .size(13) - .color(DIM) - .into() - } else { - scrollable(list).height(Length::Fill).into() - }; - - let ok = button(text("OK").size(13).color(WHITE)) - .padding([5, 22]) - .on_press(Message::AttrEditorOk) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.32, g: 0.55, b: 0.85, a: 1.0 } - } else { - ACCENT - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { radius: 4.0.into(), ..Default::default() }, - ..Default::default() - } - }); - - let cancel = button(text("Cancel").size(13).color(WHITE)) - .padding([5, 18]) - .on_press(Message::CloseModal) - .style(|_: &Theme, status| { - let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) { - Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } - } else { - Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 } - }; - button::Style { - background: Some(Background::Color(bg)), - text_color: WHITE, - border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, - ..Default::default() - } - }); - - let header = column![ - text("Edit Attributes").size(18).color(WHITE), - text(format!("Block: {block}")).size(12).color(DIM), - ] - .spacing(2); - - container( - column![ - header, - Space::new().height(10), - body, - Space::new().height(12), - row![ - Space::new().width(Length::Fill), - cancel, - Space::new().width(8), - ok - ] - .align_y(iced::Center), - ] - .padding(4), + .align_y(iced::Center), ) .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), + background: Some(Background::Color(TB)), ..Default::default() }) .width(Length::Fill) - .height(Length::Fill) - .padding(14) + .padding([5, 8]); + + let tabs = row![ + tab_button("Attribute", AttrTab::Attribute, tab), + tab_button("Text Options", AttrTab::TextOptions, tab), + tab_button("Properties", AttrTab::Properties, tab), + ] + .spacing(2); + + let body: Element<'_, Message> = if rows.is_empty() { + text("This block has no attributes.").size(13).color(DIM).into() + } else { + match tab { + AttrTab::Attribute => attribute_tab(rows, selected), + AttrTab::TextOptions => text_options_tab(&rows[selected.min(rows.len() - 1)], styles), + AttrTab::Properties => { + properties_tab(&rows[selected.min(rows.len() - 1)], layers, linetypes) + } + } + }; + + let content = container(column![tabs, body].spacing(8)) + .width(Length::Fill) + .height(Length::Fill) + .padding(12); + + container(column![toolbar, hdivider(), content]) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Attribute tab: tag / prompt / value list with row-select, plus a value box. +fn attribute_tab<'a>(rows: &'a [AttrRow], selected: usize) -> Element<'a, Message> { + let head = row![ + container(text("Tag").size(11).color(DIM)).width(130), + container(text("Prompt").size(11).color(DIM)).width(Length::Fill), + container(text("Value").size(11).color(DIM)).width(140), + ] + .spacing(6); + + let mut list = column![].spacing(1); + for (idx, r) in rows.iter().enumerate() { + let is_sel = idx == selected; + let line = row![ + container(text(r.tag.as_str()).size(12).color(WHITE)).width(130), + container(text(r.prompt.as_str()).size(12).color(DIM)).width(Length::Fill), + container(text(r.value.as_str()).size(12).color(WHITE)).width(140), + ] + .spacing(6); + let btn = button(line) + .on_press(Message::AttrEditorSelect(idx)) + .padding([3, 4]) + .width(Length::Fill) + .style(move |_t: &Theme, status| { + let hovered = matches!(status, button::Status::Hovered); + let bg = if is_sel { + ROW_SEL + } else if hovered { + Color { r: 0.18, g: 0.18, b: 0.18, a: 1.0 } + } else { + ROW_BG + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: WHITE, + border: Border::default(), + ..Default::default() + } + }); + list = list.push(btn); + } + + let sel = selected.min(rows.len() - 1); + let value_box = text_input("", rows[sel].value.as_str()) + .on_input(move |v| Message::AttrEditorInput { idx: sel, value: v }) + .on_submit(Message::AttrEditorApply) + .style(field_style) + .size(13) + .padding([4, 6]) + .width(Length::Fill); + + column![ + head, + scrollable(list).height(Length::Fill), + Space::new().height(8), + field_row("Value:", value_box.into()), + ] + .spacing(6) + .into() +} + +/// Text Options tab: the selected attribute's text formatting. +fn text_options_tab<'a>(r: &'a AttrRow, styles: Vec) -> Element<'a, Message> { + let style_sel = if r.text_style.is_empty() { + None + } else { + Some(r.text_style.clone()) + }; + let justify_opts: Vec = JUSTIFY.iter().map(|(l, _, _)| l.to_string()).collect(); + let justify_sel = Some(justify_label(r.h_align, r.v_align).to_string()); + + column![ + pick_field("Text Style", styles, style_sel, |s| { + Message::AttrEditorTextStyle(s) + }), + pick_field("Justification", justify_opts, justify_sel, |s| { + Message::AttrEditorJustify(s) + }), + edit_field("Height", &r.height, Message::AttrEditorHeight), + edit_field("Rotation", &r.rotation, Message::AttrEditorRotation), + edit_field("Width Factor", &r.width_factor, Message::AttrEditorWidth), + edit_field("Oblique Angle", &r.oblique, Message::AttrEditorOblique), + field_row( + "", + checkbox(r.backwards) + .label("Backwards") + .on_toggle(Message::AttrEditorBackwards) + .size(15) + .text_size(12) + .into(), + ), + field_row( + "", + checkbox(r.upside_down) + .label("Upside down") + .on_toggle(Message::AttrEditorUpsideDown) + .size(15) + .text_size(12) + .into(), + ), + ] + .spacing(8) + .into() +} + +/// Properties tab: the selected attribute's common entity properties. +fn properties_tab<'a>( + r: &'a AttrRow, + layers: Vec, + linetypes: Vec, +) -> Element<'a, Message> { + let layer_sel = Some(r.layer.clone()); + let lt_sel = Some(if r.linetype.is_empty() { + "ByLayer".to_string() + } else { + r.linetype.clone() + }); + let mut color_opts: Vec = COLOR_OPTIONS.iter().map(|s| s.to_string()).collect(); + // The attribute may carry a colour outside the standard set (ACI 8-255 or + // RGB). Surface its label so it displays correctly and stays selectable, + // rather than showing blank; picking a listed colour still overrides it. + let cur_color = color_label(r.color); + if !color_opts.iter().any(|o| o == &cur_color) { + color_opts.insert(0, cur_color.clone()); + } + let color_sel = Some(cur_color); + + let lw_opts = lw_options(); + let lw_sel = LwItem(r.line_weight); + let lw = pick_list(lw_opts, Some(lw_sel), |it: LwItem| { + Message::AttrEditorLineweight(it.0) + }) + .text_size(13) + .padding([3, 6]) + .width(Length::Fill); + + column![ + pick_field("Layer", layers, layer_sel, Message::AttrEditorLayer), + pick_field("Linetype", linetypes, lt_sel, Message::AttrEditorLinetype), + pick_field("Color", color_opts, color_sel, |s| { + Message::AttrEditorColor(s) + }), + field_row("Lineweight", lw.into()), + ] + .spacing(8) .into() }