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 "<Base> 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 <noreply@anthropic.com>
This commit is contained in:
parent
47f3409588
commit
b5dbef5776
10 changed files with 583 additions and 51 deletions
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<String> = 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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1416,6 +1416,40 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
_ => {}
|
||||
}
|
||||
}
|
||||
} 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<Message> {
|
|||
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<acadrust::Handle> = val
|
||||
.split(',')
|
||||
|
|
@ -1633,6 +1679,65 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
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<String> = 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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ pub enum PropValue {
|
|||
selected: String,
|
||||
options: Vec<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
},
|
||||
/// ACI/RGB/ByLayer/ByBlock color — rendered as a color picker.
|
||||
ColorChoice(AcadColor),
|
||||
/// Color varies across the current multi-selection.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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 "<Base> X / <Base> Y [/ <Base> 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::<Vec<_>>()
|
||||
.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 "<Base> X", "<Base> Y" and optionally "<Base> 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue