//! Enhanced attribute editor dialog — edit the attributes of one block //! reference (INSERT). Opened by double-clicking a block that carries //! attributes, or by ATTEDIT with such a block selected. //! //! 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 acadrust::entities::{HorizontalAlignment, VerticalAlignment}; use acadrust::types::{Color as AcadColor, LineWeight}; use iced::widget::{ button, checkbox, column, container, row, scrollable, text, text_input, Space, }; use iced::{Background, Border, Element, Length, Theme}; use crate::t; use std::borrow::Cow; use std::fmt; use crate::ui::properties::{lw_options, LwItem}; 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. The stored labels are the English source /// texts; `justify_label` / `justify_from_label` translate both directions. 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"). Kept in /// English (like the colour names) — the labels are standard CAD terms and /// their keys would collide with ribbon tool labels such as "Right". pub fn justify_label(h: HorizontalAlignment, v: VerticalAlignment) -> Cow<'static, str> { JUSTIFY .iter() .find(|(_, jh, jv)| *jh == h && *jv == v) .map(|(l, _, _)| Cow::Borrowed(*l)) .unwrap_or(Cow::Borrowed("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::None => "None".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) => crate::tf!("Color {n}").into_owned(), 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 muted_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { color: Some(theme.palette().background.base.text.scale_alpha(0.68)), } } fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { let palette = theme.palette(); let border = match status { text_input::Status::Focused { .. } => palette.primary.base.color, _ => palette.background.neutral.color, }; text_input::Style { background: Background::Color(palette.background.base.color), border: Border { color: border, width: 1.0, radius: 3.0.into() }, icon: palette.background.base.text, placeholder: palette.background.base.text.scale_alpha(0.48), value: palette.background.base.text, selection: palette.primary.base.color.scale_alpha(0.5), } } /// Horizontal 1px divider in the shared border colour (matches style windows). fn hdivider<'a>(width: Length) -> Element<'a, Message> { container(Space::new().width(width).height(1)) .width(width) .height(1) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.neutral.color )), ..Default::default() }) .into() } /// One `label : widget` row with a fixed-width label column. fn field_row<'a>( label: Cow<'static, str>, widget: Element<'a, Message>, width: Length, ) -> Element<'a, Message> { row![ container(text(label).size(12).style(muted_style)).width(LABEL_W), widget, ] .spacing(8) .align_y(iced::Center) .width(width) .into() } /// A labelled editable text field for one of the selected row's string buffers. fn edit_field<'a>( label: Cow<'static, str>, value: &'a str, on_input: impl Fn(String) -> Message + 'a, width: Length, ) -> 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(width); field_row(label, ti.into(), width) } /// A labelled pick_list of owned string options. fn pick_field<'a>( label: Cow<'static, str>, options: Vec, selected: Option, on_select: impl Fn(String) -> Message + 'a, width: Length, ) -> Element<'a, Message> { let pl = iced::widget::pick_list(selected, options, |value| value.to_string()) .on_select(on_select) .text_size(13) .padding([3, 6]) .width(width); field_row(label, pl.into(), width) } #[derive(Clone, Debug, PartialEq, Eq)] struct LocalizedChoice { raw: String, label: String, } impl LocalizedChoice { fn new(raw: String) -> Self { let label = raw .strip_prefix("Color ") .map(|number| crate::tf!("Color {}", number).into_owned()) .unwrap_or_else(|| crate::i18n::translate(&raw).into_owned()); Self { raw, label } } } impl fmt::Display for LocalizedChoice { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.label) } } /// A translated pick list which keeps the stored and emitted value unchanged. fn localized_pick_field<'a>( label: Cow<'static, str>, options: Vec, selected: Option, on_select: impl Fn(String) -> Message + 'a, width: Length, ) -> Element<'a, Message> { let options = options.into_iter().map(LocalizedChoice::new).collect::>(); let selected = selected.map(LocalizedChoice::new); let pl = iced::widget::pick_list(selected, options, |value| value.to_string()) .on_select(move |choice: LocalizedChoice| on_select(choice.raw)) .text_size(13) .padding([3, 6]) .width(width); field_row(label, pl.into(), width) } fn tab_button<'a>(label: Cow<'static, str>, this: AttrTab, active: AttrTab) -> Element<'a, Message> { let is_active = this == active; button(text(label).size(11)) .padding([4, 12]) .on_press(Message::AttrEditorTab(this)) .style(move |theme: &Theme, status| { let palette = theme.palette(); let pair = match (is_active, status) { (true, _) => palette.primary.strong, (false, button::Status::Hovered | button::Status::Pressed) => { palette.background.strong } _ => palette.background.weak, }; button::Style { background: Some(Background::Color(pair.color)), text_color: pair.text, border: Border { color: palette.background.neutral.color, 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, rows: &'a [AttrRow], selected: usize, tab: AttrTab, layers: Vec, linetypes: Vec, styles: Vec, sizing: crate::ui::modal::ModalSizing, ) -> Element<'a, Message> { let width = sizing.width; let height = sizing.height; // ── 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(t!("Apply")).size(11)) .padding([4, 14]) .on_press(Message::AttrEditorApply) .style(button::primary); let toolbar = container( row![ text(t!("Block: %{block}", block = block)).size(12).style(muted_style), Space::new().width(width), apply, ] .align_y(iced::Center), ) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.weak.color )), ..Default::default() }) .width(width) .padding([5, 8]); let tabs = row![ tab_button(t!("Attribute"), AttrTab::Attribute, tab), tab_button(t!("Text Options"), AttrTab::TextOptions, tab), tab_button(t!("Properties"), AttrTab::Properties, tab), ] .spacing(2); let body: Element<'_, Message> = if rows.is_empty() { text(t!("This block has no attributes.")).size(13).style(muted_style).into() } else { match tab { AttrTab::Attribute => attribute_tab(rows, selected, width, height), AttrTab::TextOptions => { text_options_tab(&rows[selected.min(rows.len() - 1)], styles, width, height) } AttrTab::Properties => { properties_tab( &rows[selected.min(rows.len() - 1)], layers, linetypes, width, height, ) } } }; let content = container(column![tabs, body].spacing(8)) .width(width) .height(height) .padding(12); container(column![toolbar, hdivider(width), content]) .style(|theme: &Theme| container::Style { background: Some(Background::Color( theme.palette().background.base.color )), ..Default::default() }) .width(width) .height(height) .into() } /// Attribute tab: tag / prompt / value list with row-select, plus a value box. fn attribute_tab<'a>( rows: &'a [AttrRow], selected: usize, width: Length, height: Length, ) -> Element<'a, Message> { let head = row![ container(text(t!("Tag")).size(11).style(muted_style)).width(130), container(text(t!("Prompt")).size(11).style(muted_style)).width(width), container(text(t!("Value")).size(11).style(muted_style)).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)).width(130), container(text(r.prompt.as_str()).size(12).style(muted_style)).width(width), container(text(r.value.as_str()).size(12)).width(140), ] .spacing(6); let btn = button(line) .on_press(Message::AttrEditorSelect(idx)) .padding([3, 4]) .width(width) .style(move |theme: &Theme, status| { let palette = theme.palette(); let hovered = matches!(status, button::Status::Hovered); let pair = if is_sel { palette.primary.strong } else if hovered { palette.background.strong } else { palette.background.weak }; button::Style { background: Some(Background::Color(pair.color)), text_color: pair.text, 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(width); column![ head, scrollable(list).width(width).height(height), Space::new().height(8), field_row(t!("Value:"), value_box.into(), width), ] .spacing(6) .width(width) .height(height) .into() } /// Text Options tab: the selected attribute's text formatting. fn text_options_tab<'a>( r: &'a AttrRow, styles: Vec, width: Length, height: Length, ) -> 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(t!("Text Style"), styles, style_sel, |s| { Message::AttrEditorTextStyle(s) }, width), localized_pick_field(t!("Justification"), justify_opts, justify_sel, |s| { Message::AttrEditorJustify(s) }, width), edit_field(t!("Height"), &r.height, Message::AttrEditorHeight, width), edit_field(t!("Rotation"), &r.rotation, Message::AttrEditorRotation, width), edit_field(t!("Width Factor"), &r.width_factor, Message::AttrEditorWidth, width), edit_field(t!("Oblique Angle"), &r.oblique, Message::AttrEditorOblique, width), field_row( Cow::Borrowed(""), checkbox(r.backwards) .label(t!("Backwards")) .on_toggle(Message::AttrEditorBackwards) .size(15) .text_size(12) .into(), width, ), field_row( Cow::Borrowed(""), checkbox(r.upside_down) .label(t!("Upside down")) .on_toggle(Message::AttrEditorUpsideDown) .size(15) .text_size(12) .into(), width, ), ] .spacing(8) .width(width) .height(height) .into() } /// Properties tab: the selected attribute's common entity properties. fn properties_tab<'a>( r: &'a AttrRow, layers: Vec, linetypes: Vec, width: Length, height: Length, ) -> 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 = iced::widget::pick_list(Some(lw_sel), lw_opts, |value| value.to_string()) .on_select(|it: LwItem| Message::AttrEditorLineweight(it.0)) .text_size(13) .padding([3, 6]) .width(width); column![ pick_field(t!("Layer"), layers, layer_sel, Message::AttrEditorLayer, width), localized_pick_field(t!("Linetype"), linetypes, lt_sel, Message::AttrEditorLinetype, width), localized_pick_field(t!("Color"), color_opts, color_sel, |s| { Message::AttrEditorColor(s) }, width), field_row(t!("Lineweight"), lw.into(), width), ] .spacing(8) .width(width) .height(height) .into() }