From b5dbef5776c7e6c7c6b44b34c396909239e0db1e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 22 Jul 2026 11:11:10 +0300 Subject: [PATCH] feat(props): editable block Name row + collapsible XYZ groups - Block reference Name (Misc) is editable: the dropdown re-points the insert to another definition; typing a new name renames the definition everywhere (record re-key, Block marker, every INSERT reference). Anonymous (*) and xref blocks stay read-only, with the same guards added to Scene::rename_block. - Custom single-control dropdown (text field + caret) built on the shared floating_below overlay so the list always opens downward, unlike iced's space-based menu placement. - Consecutive " X/Y/Z" rows (Position, Scale, Start, Center, ...) collapse into one expandable summary row across all entities; the expand state persists across rebuilds and selection changes. - The BEDIT block tab no longer offers the layout Rename/Delete context menu. Co-Authored-By: Claude Fable 5 --- src/app/mod.rs | 5 + src/app/properties.rs | 49 +++++ src/app/update/command.rs | 109 ++++++++++- src/app/update/mod.rs | 23 ++- src/scene/entity.rs | 19 +- src/scene/mod.rs | 14 ++ src/scene/model/object.rs | 7 + src/scene/selection.rs | 1 + src/ui/color_select.rs | 10 + src/ui/properties.rs | 397 +++++++++++++++++++++++++++++++++----- 10 files changed, 583 insertions(+), 51 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index c8e02cfb0..e6ef8494c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1671,6 +1671,11 @@ pub enum Message { }, /// User committed a geometry/common field edit (Enter pressed). PropGeomCommit(&'static str), + /// Toggle a collapsed coordinate group ("Position", "Scale", …) open or + /// closed in the Properties panel, keyed `section:base`. + PropGroupToggle(String), + /// Toggle the editable-dropdown (block Name) option list open/closed. + PropEditChoiceToggle, /// User is typing in a block-attribute value field (live buffer update), /// keyed by the attribute tag. PropAttrInput { tag: String, value: String }, diff --git a/src/app/properties.rs b/src/app/properties.rs index 845a39022..6884f78d3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -20,6 +20,9 @@ impl OpenCADStudio { // closes, matching the deselect / reselect / click-away expectation. let color_palette_open = self.tabs[i].properties.color_palette_open; let edit_buf = std::mem::take(&mut self.tabs[i].properties.edit_buf); + // Expanded coordinate groups persist across rebuilds AND selection + // changes — it's a per-user view preference, not per-entity state. + let expanded_groups = std::mem::take(&mut self.tabs[i].properties.expanded_groups); // Which entities the previous panel was built for — an uncommitted // edit buffer only survives a rebuild for the *same* selection. let prev_handles = std::mem::take(&mut self.tabs[i].properties.source_handles); @@ -497,6 +500,41 @@ impl OpenCADStudio { let src_mm = if src == 0 { 1.0 } else { insunits_to_mm(src) }; let factor = if host_mm.abs() > 1e-12 { src_mm / host_mm } else { 1.0 }; set_row(&mut sections, "unit_factor", format!("{factor:.4}")); + + // Name row: editable for regular blocks — pick an + // existing definition to re-point this reference, or + // type a new name to rename the definition (every + // insert of it follows). Anonymous (*) and + // xref(-dependent) blocks keep the read-only row. + let regular = |br: &acadrust::tables::BlockRecord| { + !br.is_anonymous() + && !br.flags.is_xref + && !br.name.contains('|') + }; + let editable = doc + .block_records + .get(&ins.block_name) + .map(®ular) + .unwrap_or(false); + if editable { + let mut options: Vec = doc + .block_records + .iter() + .filter(|br| regular(br)) + .map(|br| br.name.clone()) + .collect(); + options.sort_by(|a, b| { + a.to_lowercase().cmp(&b.to_lowercase()) + }); + set_row_value( + &mut sections, + "block", + crate::scene::model::object::PropValue::EditChoice { + value: ins.block_name.clone(), + options, + }, + ); + } } // Underlay: name + path from the referenced definition. acadrust::EntityType::Underlay(ul) => { @@ -919,6 +957,7 @@ impl OpenCADStudio { } else { Default::default() }; + panel.expanded_groups = expanded_groups; panel.source_handles = new_handles; panel.prop_vertex = prop_vertex; panel @@ -1455,6 +1494,16 @@ fn merge_prop_value( selected: VARIES_LABEL.into(), options: options.clone(), }, + ( + PropValue::EditChoice { options, .. }, + PropValue::EditChoice { + options: other_options, + .. + }, + ) if options == other_options => PropValue::EditChoice { + value: VARIES_LABEL.into(), + options: options.clone(), + }, (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b88e11d9a..dfb4c77a8 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1416,6 +1416,40 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { _ => {} } } + } else if field == "block" { + // Name dropdown on a block reference: re-point the + // selected inserts to the picked definition. A stale + // typed value in the text buffer would mask the pick, + // so drop it; the pick also closes the list. + self.tabs[i].properties.edit_buf.remove("block"); + self.tabs[i].properties.edit_choice_open = false; + let canon = self.tabs[i] + .scene + .document + .block_records + .get(&value) + .map(|br| br.name.clone()); + if let Some(canon) = canon { + let mut changed = false; + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + if ins.block_name != canon { + ins.block_name = canon.clone(); + changed = true; + } + } + } + if changed { + // The picked block may not be in the defn cache + // yet — rebuild block definitions too. + self.tabs[i].scene.bump_geometry(); + } + } } else if field == "plot_style" { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's @@ -1515,9 +1549,21 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { let handles = self.property_target_handles(i); if !handles.is_empty() { if let Some(raw_val) = self.tabs[i].properties.edit_buf.remove(field) { - let val = crate::app::expr_eval::eval_to_string(&raw_val); + // Block names are free-form text — a name like "10-5" + // must not be arithmetic-evaluated. + let val = if field == "block" { + raw_val + } else { + crate::app::expr_eval::eval_to_string(&raw_val) + }; self.push_undo_snapshot(i, "CHPROP"); - if field == "frozen_layers" { + if field == "block" { + // Name row on a block reference: an existing name + // re-points the selected inserts; a new one renames + // the definition they share. Commit closes the list. + self.tabs[i].properties.edit_choice_open = false; + self.apply_block_name_commit(i, &handles, val.trim()); + } else if field == "frozen_layers" { // Resolve layer names → handles, then apply to viewports. let layer_handles: Vec = val .split(',') @@ -1633,6 +1679,65 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { Task::none() } + /// Properties-panel Name commit for block references. A `new` matching an + /// existing regular block re-points the selected inserts to it; otherwise + /// the definition the (single-block) selection references is renamed and + /// every insert of it follows. Anonymous/xref definitions never get here — + /// their Name row stays read-only. + fn apply_block_name_commit(&mut self, i: usize, handles: &[acadrust::Handle], new: &str) { + if new.is_empty() { + return; + } + // Existing target → swap the selected references to it. Anonymous, + // xref and xref-dependent definitions are not valid targets. + let target = self.tabs[i].scene.document.block_records.get(new).map(|br| { + ( + br.name.clone(), + br.is_anonymous() || br.flags.is_xref || br.name.contains('|'), + ) + }); + if let Some((canon, protected)) = target { + if protected { + return; + } + let mut changed = false; + for &handle in handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + if ins.block_name != canon { + ins.block_name = canon.clone(); + changed = true; + } + } + } + if changed { + self.tabs[i].scene.bump_geometry(); + } + return; + } + // New name → rename. Only when every selected insert references the + // same definition; a mixed selection makes the rename target ambiguous. + let mut old: Option = None; + for &handle in handles { + if let Some(acadrust::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity(handle) + { + match &old { + None => old = Some(ins.block_name.clone()), + Some(o) if o.eq_ignore_ascii_case(&ins.block_name) => {} + _ => return, + } + } + } + if let Some(old) = old { + self.tabs[i].scene.rename_block(&old, new); + } + } + /// Commit an edited block-attribute value from the Properties panel. /// Writes the new value into the matching attribute (by tag) of every /// selected INSERT, then re-tessellates so the attribute text repaints. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 0d2dbf806..637857cbd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2695,6 +2695,20 @@ impl OpenCADStudio { Message::PropGeomCommit(field) => self.on_prop_geom_commit(field), + Message::PropGroupToggle(key) => { + let groups = &mut self.tabs[self.active_tab].properties.expanded_groups; + if !groups.remove(&key) { + groups.insert(key); + } + Task::none() + } + + Message::PropEditChoiceToggle => { + let panel = &mut self.tabs[self.active_tab].properties; + panel.edit_choice_open = !panel.edit_choice_open; + Task::none() + } + Message::PropAttrInput { tag, value } => { self.tabs[self.active_tab] .properties @@ -2896,7 +2910,14 @@ impl OpenCADStudio { } Message::LayoutContextMenu(name) => { - if name != "Model" { + // No menu on the transient BEDIT block tab: Rename/Delete are + // layout operations and don't apply to it. + let is_block_tab = self.tabs[self.active_tab] + .block_edit + .as_ref() + .map(|be| be.block_name == name) + .unwrap_or(false); + if name != "Model" && !is_block_tab { self.layout_context_menu = Some(name); } Task::none() diff --git a/src/scene/entity.rs b/src/scene/entity.rs index b4a9f80c9..a4299de02 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -229,15 +229,30 @@ impl Scene { /// Rename a block definition: re-key its record, update the Block marker's /// name, and repoint every INSERT that referenced the old name so all - /// instances keep resolving. Returns false if `old` is missing, `new` is - /// already taken, or the names are equal (case-insensitive). (#261) + /// instances keep resolving. Returns false if `old` is missing or + /// anonymous/xref, `new` is invalid or already taken, or the names are + /// equal (case-insensitive). (#261) pub fn rename_block(&mut self, old: &str, new: &str) -> bool { + if !crate::scene::valid_block_name(new) { + return false; + } if old.eq_ignore_ascii_case(new) { return false; } if self.document.block_records.get(new).is_some() { return false; } + // Anonymous (*) names are program-owned and re-numbered on save; an + // xref('|') symbol name is bound to the referenced file. + if self + .document + .block_records + .get(old) + .map(|br| br.is_anonymous() || br.flags.is_xref || br.name.contains('|')) + .unwrap_or(true) + { + return false; + } let Some(mut br) = self.document.block_records.remove(old) else { return false; }; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index ae75f5805..5fc17cfc4 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -204,6 +204,20 @@ pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) - 1.0 } +/// A block name a user may assign: non-empty, no control characters, none of +/// the symbol-table-reserved characters (`*` marks anonymous blocks, `|` +/// xref-dependent ones; the rest are the DXF symbol-name exclusions). +pub(crate) fn valid_block_name(name: &str) -> bool { + !name.is_empty() + && !name.chars().any(|c| { + c.is_control() + || matches!( + c, + '<' | '>' | '/' | '\\' | '"' | ':' | ';' | '?' | '*' | '|' | ',' | '=' | '`' + ) + }) +} + /// Pre-built entity caches returned by [`build_derived_caches`]. /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 169099950..abff61a74 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -17,6 +17,13 @@ pub enum PropValue { selected: String, options: Vec, }, + /// Editable text plus a dropdown of existing options (block reference + /// Name row): picking an option re-points the reference, submitting a + /// new name renames the definition. + EditChoice { + value: String, + options: Vec, + }, /// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker. ColorChoice(AcadColor), /// Color varies across the current multi-selection. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index c87d0e3f2..d9bb9fd3b 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -293,6 +293,7 @@ impl Scene { PropValue::ReadOnly(s) | PropValue::EditText(s) => s, PropValue::LayerChoice(s) => s, PropValue::Choice { selected, .. } => selected, + PropValue::EditChoice { value, .. } => value, PropValue::ColorChoice(c) => Self::format_color(c), PropValue::LwChoice(lw) => Self::format_lineweight(lw), PropValue::LinetypeChoice(s) => s, diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index a63bc70f6..c42680e1c 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -295,6 +295,16 @@ pub fn color_grid_window(on_pick: impl Fn(AcadColor) -> Message) -> Element<'sta .into() } +/// Render `base` inline with `popup` floating just below it — the shared +/// dropdown mechanic for the panel's custom dropdowns (colour picker, block +/// Name). Unlike iced's menu overlay it always opens downward. +pub fn floating_below<'a>( + base: Element<'a, Message>, + popup: Element<'a, Message>, +) -> Element<'a, Message> { + Element::new(Floating { base, popup }) +} + /// A widget that renders `base` inline and `popup` as a floating overlay /// anchored just below it. struct Floating<'a> { diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 68da12c7b..1889acd60 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -7,7 +7,7 @@ //! • Linetype → read-only for now //! • Geometry → text_input per coordinate / dimension field -use rustc_hash::FxHashMap as HashMap; +use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet}; use std::fmt; use crate::ui::ROW_H; @@ -155,6 +155,13 @@ pub struct PropertiesPanel { /// Which vertex a multi-vertex entity (polyline) is focused on — driven by /// the Current Vertex ◀ / ▶ stepper. Reset to 0 when the selection changes. pub prop_vertex: usize, + /// Coordinate groups ("Position", "Scale", …) the user expanded into their + /// component X/Y/Z rows. Collapsed by default; keyed `section:base` and + /// carried across panel rebuilds so the state survives edits and selection + /// changes. + pub expanded_groups: HashSet, + /// Whether the editable-dropdown (block Name) option list is open. + pub edit_choice_open: bool, } impl Default for PropertiesPanel { @@ -178,6 +185,8 @@ impl Default for PropertiesPanel { bg_color_picker_open: false, open_color_field: None, prop_vertex: 0, + expanded_groups: HashSet::default(), + edit_choice_open: false, } } } @@ -330,58 +339,68 @@ impl PropertiesPanel { let mut col = column![hdr].spacing(0); - for prop in §ion.props { - match &prop.value { - PropValue::ColorChoice(color) => { - col = col.push(self.render_color_row(&prop.label, prop.field, *color)); - } - PropValue::ColorVaries => { - col = col.push(self.render_color_varies_row(&prop.label)); - } - PropValue::LayerChoice(layer) => { - col = col.push(self.render_layer_row(&prop.label, layer)); - } - PropValue::LwChoice(lw) => { - col = col.push(self.render_lw_row(&prop.label, *lw)); - } - PropValue::LwVaries => { - col = col.push(self.render_lw_varies_row(&prop.label)); - } - PropValue::LinetypeChoice(lt) => { - col = col.push(self.render_linetype_row(&prop.label, lt)); - } - PropValue::Choice { selected, options } => { - col = col.push(self.render_choice_row( - &prop.label, - prop.field, - selected, - options, - )); - } - PropValue::BoolToggle { field, value } => { - col = col.push(render_bool_row(&prop.label, *field, *value)); - } - PropValue::Stepper { display, .. } => { - col = col.push(render_stepper_row(&prop.label, display)); - } - PropValue::EditText(val) => { - col = col.push(self.render_edit_row(&prop.label, prop.field, val)); - } - PropValue::ReadOnly(val) => { - col = col.push(render_ro_row(&prop.label, val)); - } - PropValue::HatchPatternChoice(current) => { - col = col.push(self.render_hatch_pattern_row(&prop.label, current)); - } - PropValue::AttrText { tag, value } => { - col = col.push(self.render_attr_row(tag, value)); + // Consecutive " X / Y [/ Z]" text rows collapse + // into one clickable summary row; clicking expands the components. + let mut idx = 0; + while idx < section.props.len() { + let group_len = coord_group_len(§ion.props, idx); + if group_len >= 2 { + let base = coord_base(§ion.props[idx].label); + let key = format!("{}:{}", section.title, base); + let expanded = self.expanded_groups.contains(&key); + let joined = section.props[idx..idx + group_len] + .iter() + .map(prop_text_value) + .collect::>() + .join(", "); + col = col.push(render_group_row(base, key, expanded, joined)); + if expanded { + for prop in §ion.props[idx..idx + group_len] { + col = col.push(self.render_prop_row(prop, coord_component(&prop.label))); + } } + idx += group_len; + } else { + let prop = §ion.props[idx]; + col = col.push(self.render_prop_row(prop, &prop.label)); + idx += 1; } } col.into() } + /// Render one property row with an explicit display label (the grouped + /// coordinate rows shorten "Position X" to "X"). + fn render_prop_row<'a>( + &'a self, + prop: &'a crate::scene::model::object::Property, + label: &'a str, + ) -> Element<'a, Message> { + match &prop.value { + PropValue::ColorChoice(color) => self.render_color_row(label, prop.field, *color), + PropValue::ColorVaries => self.render_color_varies_row(label), + PropValue::LayerChoice(layer) => self.render_layer_row(label, layer), + PropValue::LwChoice(lw) => self.render_lw_row(label, *lw), + PropValue::LwVaries => self.render_lw_varies_row(label), + PropValue::LinetypeChoice(lt) => self.render_linetype_row(label, lt), + PropValue::Choice { selected, options } => { + self.render_choice_row(label, prop.field, selected, options) + } + PropValue::EditChoice { value, options } => { + self.render_edit_choice_row(label, prop.field, value, options) + } + PropValue::BoolToggle { field, value } => render_bool_row(label, *field, *value), + PropValue::Stepper { display, .. } => render_stepper_row(label, display), + PropValue::EditText(val) => self.render_edit_row(label, prop.field, val), + PropValue::ReadOnly(val) => render_ro_row(label, val), + PropValue::HatchPatternChoice(current) => { + self.render_hatch_pattern_row(label, current) + } + PropValue::AttrText { tag, value } => self.render_attr_row(tag, value), + } + } + // ── Layer row (combo_box) ───────────────────────────────────────────── fn render_layer_row<'a>(&'a self, label: &'a str, current: &'a str) -> Element<'a, Message> { @@ -681,6 +700,139 @@ impl PropertiesPanel { prop_row_widget(label, ti.into()) } + /// Editable dropdown row (block reference Name): a text field with a caret + /// button in one bordered control. Typing + Enter commits through the + /// normal PropGeomCommit path (existing name → re-point, new name → + /// rename); the caret opens a floating list of the definitions (always + /// downward, via the shared `floating_below` mechanic) and picking one + /// applies through PropGeomChoiceChanged. Typed text filters the list. + fn render_edit_choice_row<'a>( + &'a self, + label: &'a str, + field: &'static str, + entity_val: &'a str, + options: &'a [String], + ) -> Element<'a, Message> { + let typed = self.edit_buf.get(field); + let display = typed.map(|s| s.as_str()).unwrap_or(entity_val); + + let input = text_input("", display) + .on_input(move |v| Message::PropGeomInput { field, value: v }) + .on_submit(Message::PropGeomCommit(field)) + .size(FONT_SZ) + .style(|_: &Theme, status| text_input::Style { + // The wrapping container draws the border; keep the input flat + // so field + caret read as one control. + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 0.0.into(), + }, + ..text_input_style(&Theme::Dark, status) + }) + .padding([3, 6]) + .width(Length::Fill); + let caret = button( + container(crate::ui::icons::arrow_toggle( + self.edit_choice_open, + FONT_SZ, + VALUE_COLOR, + )) + .height(Length::Fill) + .align_y(iced::Center), + ) + .on_press(Message::PropEditChoiceToggle) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.28, + g: 0.28, + b: 0.28, + a: 1.0, + }, + _ => VALUE_BG, + })), + text_color: VALUE_COLOR, + border: Border::default(), + ..Default::default() + }) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 3.0, + right: 3.0, + }) + .height(Length::Fixed(ROW_H - 6.0)); + let head = container(row![input, caret].align_y(iced::Center)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(VALUE_BG)), + border: Border { + color: BORDER, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .width(Length::Fill); + + if !self.edit_choice_open { + return prop_row_widget(label, head.into()); + } + + // Open list: all definitions, filtered by any typed text. + let filter = typed.map(|s| s.to_lowercase()); + let mut list = column![].spacing(1); + for opt in options { + if let Some(f) = &filter { + if !opt.to_lowercase().contains(f.as_str()) { + continue; + } + } + let value = opt.clone(); + list = list.push( + button(text(opt.as_str()).size(FONT_SZ).color(VALUE_COLOR)) + .on_press(Message::PropGeomChoiceChanged { field, value }) + .style(|_: &Theme, status| button::Style { + background: matches!(status, button::Status::Hovered).then_some( + Background::Color(Color { + r: 0.25, + g: 0.45, + b: 0.70, + a: 1.0, + }), + ), + text_color: VALUE_COLOR, + ..Default::default() + }) + .padding([2, 6]) + .width(Length::Fill), + ); + } + let popup = container(scrollable(list).height(Length::Shrink)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.17, + g: 0.17, + b: 0.17, + a: 1.0, + })), + border: Border { + color: BORDER, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .padding(2) + .width(200) + .max_height(220.0); + + prop_row_widget( + label, + crate::ui::color_select::floating_below(head.into(), popup.into()), + ) + } + /// One editable row for a block attribute: the tag is the row label and the /// text box edits the value. Routing rides the tag (a runtime string), so /// this uses the dedicated `PropAttr*` messages instead of the geometry @@ -971,6 +1123,159 @@ fn render_bool_row<'a>(label: &'a str, field: &'static str, value: bool) -> Elem prop_row_widget(label, btn.into()) } +// ── Collapsible coordinate groups (Position / Start / Scale …) ──────────── + +/// The X/Y/Z suffix rank of a coordinate row label, with its base ("Position +/// X" → ("Position", 0)). `None` for non-coordinate labels. +fn coord_suffix(label: &str) -> Option<(&str, usize)> { + for (rank, suf) in [" X", " Y", " Z"].iter().enumerate() { + if let Some(base) = label.strip_suffix(suf) { + if !base.is_empty() { + return Some((base, rank)); + } + } + } + None +} + +/// Length of the coordinate group starting at `idx`: consecutive text rows +/// labelled " X", " Y" and optionally " Z". 0/1 = no group. +fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { + let groupable = |p: &crate::scene::model::object::Property| { + matches!(p.value, PropValue::EditText(_) | PropValue::ReadOnly(_)) + }; + let Some((base, 0)) = coord_suffix(&props[idx].label) else { + return 0; + }; + if !groupable(&props[idx]) { + return 0; + } + let mut len = 1; + while idx + len < props.len() && len < 3 { + match coord_suffix(&props[idx + len].label) { + Some((b, r)) if b == base && r == len && groupable(&props[idx + len]) => len += 1, + _ => break, + } + } + if len >= 2 { + len + } else { + 0 + } +} + +fn coord_base(label: &str) -> &str { + coord_suffix(label).map(|(b, _)| b).unwrap_or(label) +} + +/// Short component label for an expanded row ("Position X" → indented "X"). +fn coord_component(label: &str) -> &'static str { + match coord_suffix(label) { + Some((_, 0)) => " X", + Some((_, 1)) => " Y", + _ => " Z", + } +} + +/// Display string of a text-valued property (grouped rows are always +/// EditText / ReadOnly — see `coord_group_len`). +fn prop_text_value(prop: &crate::scene::model::object::Property) -> String { + match &prop.value { + PropValue::EditText(s) | PropValue::ReadOnly(s) => s.clone(), + _ => String::new(), + } +} + +/// The collapsed summary row of a coordinate group. The expand arrow leads +/// the label cell (clicking the cell toggles); the value cell is the same +/// read-only selectable field every other read-only row uses. +fn render_group_row( + base: &str, + key: String, + expanded: bool, + joined: String, +) -> Element<'_, Message> { + let label_btn = button( + container( + row![ + crate::ui::icons::arrow_toggle(expanded, FONT_SZ, LABEL_COLOR), + text(crate::ui::text_util::elide(base, 16)) + .size(FONT_SZ) + .color(LABEL_COLOR), + ] + .spacing(4) + .align_y(iced::Center), + ) + .height(Length::Fill) + .align_y(iced::Center), + ) + .on_press(Message::PropGroupToggle(key)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => Color { + r: 0.24, + g: 0.24, + b: 0.24, + a: 1.0, + }, + _ => LABEL_BG, + })), + text_color: LABEL_COLOR, + border: Border::default(), + ..Default::default() + }) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 4.0, + right: 6.0, + }) + .width(Length::Fill) + .height(Length::Fixed(ROW_H)); + let label_col = container(label_btn) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(LABEL_BG)), + ..Default::default() + }) + .width(Length::FillPortion(5)) + .height(Length::Fixed(ROW_H)) + .align_y(iced::Center); + + // text_input copies the value, so the locally-built `joined` is fine here. + let value_field = text_input("", &joined) + .on_input(|_| Message::Noop) + .size(FONT_SZ) + .style(ro_input_style) + .padding([3, 6]) + .width(Length::Fill); + let value_col = container(value_field) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(VALUE_BG)), + ..Default::default() + }) + .width(Length::FillPortion(6)) + .height(Length::Fixed(ROW_H)) + .align_y(iced::Center) + .padding(Padding { + top: 0.0, + bottom: 0.0, + left: 2.0, + right: 2.0, + }); + + container(row![label_col, value_col]) + .height(Length::Fixed(ROW_H)) + .style(|_: &Theme| container::Style { + border: Border { + color: BORDER, + width: 1.0, + radius: 0.0.into(), + }, + ..Default::default() + }) + .into() +} + fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { // A read-only value is shown as a non-editable but selectable text field: // the user can select the text (which carries the full, un-truncated