feat(properties): show & edit block attributes in panel

Selecting an INSERT with attributes now adds an editable "Attributes"
section to the Properties panel — each tag is a row whose value can be
changed inline (Enter commits, undo works, the attribute text repaints).
Previously attribute values could only be reached through ATTEDIT, which
didn't let them be changed (#192).

Attribute tags are runtime strings, so they can't ride the geometry
edit path whose field key is `&'static str`; a dedicated PropValue::
AttrText variant plus PropAttrInput/PropAttrCommit messages carry the
tag. Values are stored verbatim (no expression evaluation, since
attribute text is free-form).

Also guard the panel's in-progress edit buffer: it now only carries
across a rebuild when the selection is unchanged (a commit-triggered
refresh), so a typed-but-uncommitted value can't display or commit onto
a different entity — e.g. two title blocks sharing a REV1 tag. This
closes a latent stale-buffer leak that affected geometry fields too.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-01 07:42:50 +03:00
1 parent 3da81571a1
commit 5f2e920b75
7 files changed
+133 -1

No files matched your search

+5
View File
@@ -1385,6 +1385,11 @@ pub enum Message {
},
/// User committed a geometry/common field edit (Enter pressed).
PropGeomCommit(&'static str),
/// User is typing in a block-attribute value field (live buffer update),
/// keyed by the attribute tag.
PropAttrInput { tag: String, value: String },
/// User committed a block-attribute value edit (Enter pressed).
PropAttrCommit(String),
/// Toggle the inline color picker dropdown open/closed.
PropColorPickerToggle,
/// Toggle the full ACI colour palette expansion.
+37 -1
View File
@@ -15,6 +15,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);
// 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);
let selected_group = self.tabs[i].properties.selected_group.clone();
// Seed the per-thread unit context from the document header so the
@@ -207,6 +210,30 @@ impl OpenCADStudio {
}
}
// Block attributes: expose each attribute tag as an
// editable value row so a block reference's attributes can
// be read and changed directly from the panel.
if let acadrust::EntityType::Insert(ins) = entity {
if !ins.attributes.is_empty() {
let props = ins
.attributes
.iter()
.map(|a| crate::scene::model::object::Property {
label: a.tag.clone(),
field: "attr",
value: crate::scene::model::object::PropValue::AttrText {
tag: a.tag.clone(),
value: a.get_value().to_string(),
},
})
.collect();
sections.push(crate::scene::model::object::PropSection {
title: "Attributes".to_string(),
props,
});
}
}
if !group_names.is_empty() {
let label = group_names.join(", ");
if let Some(general) = sections.first_mut() {
@@ -310,7 +337,16 @@ impl OpenCADStudio {
}
};
panel.color_palette_open = color_palette_open;
panel.edit_buf = edit_buf;
let new_handles: Vec<acadrust::Handle> = selected.iter().map(|(h, _)| *h).collect();
// Carry the in-progress edits only when the selection is unchanged
// (a commit-triggered rebuild); a genuine selection change starts
// with a clean buffer so no stale value leaks onto the new entity.
panel.edit_buf = if prev_handles == new_handles {
edit_buf
} else {
Default::default()
};
panel.source_handles = new_handles;
panel
};
+31
View File
@@ -1173,4 +1173,35 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
Task::none()
}
/// 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.
/// The value is stored verbatim (attribute values are free-form text, so
/// it is not run through the expression evaluator like numeric fields).
pub(super) fn on_prop_attr_commit(&mut self, tag: String) -> Task<Message> {
let i = self.active_tab;
let key = crate::ui::properties::attr_edit_key(&tag);
let handles = self.property_target_handles(i);
if handles.is_empty() {
return Task::none();
}
let Some(val) = self.tabs[i].properties.edit_buf.remove(&key) else {
return Task::none();
};
self.push_undo_snapshot(i, "ATTEDIT");
for &handle in &handles {
if let Some(acadrust::EntityType::Insert(ins)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
attr.set_value(val.clone());
}
}
}
self.invalidate_property_targets(i, &handles);
self.tabs[i].dirty = true;
self.refresh_properties();
Task::none()
}
}
+10
View File
@@ -2001,6 +2001,16 @@ impl OpenCADStudio {
Message::PropGeomCommit(field) => self.on_prop_geom_commit(field),
Message::PropAttrInput { tag, value } => {
self.tabs[self.active_tab]
.properties
.edit_buf
.insert(crate::ui::properties::attr_edit_key(&tag), value);
Task::none()
}
Message::PropAttrCommit(tag) => self.on_prop_attr_commit(tag),
Message::PropColorPickerToggle => {
let i = self.active_tab;
self.tabs[i].properties.color_picker_open =
+4
View File
@@ -31,6 +31,10 @@ pub enum PropValue {
BoolToggle { field: &'static str, value: bool },
/// Hatch pattern name — rendered as a combo_box from the catalog.
HatchPatternChoice(String),
/// Block attribute value keyed by its (dynamic, runtime) tag — rendered as
/// an editable text_input. Unlike the other rows the routing key is the
/// tag carried here, not the row's `&'static str` field.
AttrText { tag: String, value: String },
}
/// A single property row in the Properties panel.
+1
View File
@@ -285,6 +285,7 @@ impl Scene {
PropValue::LinetypeChoice(s) => s,
PropValue::HatchPatternChoice(s) => s,
PropValue::BoolToggle { value, .. } => value.to_string(),
PropValue::AttrText { value, .. } => value,
PropValue::ColorVaries | PropValue::LwVaries => return None,
})
}
+45
View File
@@ -109,6 +109,13 @@ pub fn lw_options() -> Vec<LwItem> {
.collect()
}
/// Edit-buffer key for a block attribute value, keyed by its tag. Kept in one
/// place so the live-input handler and the row renderer agree. The `\x01`
/// sentinel guarantees no collision with a geometry field's `&'static str` key.
pub fn attr_edit_key(tag: &str) -> String {
format!("\x01attr\x01{tag}")
}
// ── PropertiesPanel ───────────────────────────────────────────────────────
#[derive(Clone)]
@@ -127,6 +134,13 @@ pub struct PropertiesPanel {
pub hatch_pattern_combo: combo_box::State<String>,
/// In-progress text edits keyed by `field` name.
pub edit_buf: HashMap<String, String>,
/// Entity handles this panel was built for. `refresh_properties` compares
/// it against the new selection to decide whether an uncommitted `edit_buf`
/// may carry over: same selection → keep (survive a commit-triggered
/// rebuild); different selection → drop, so a stale value can't display or
/// commit onto a different entity (e.g. two title blocks sharing a `REV1`
/// tag).
pub source_handles: Vec<Handle>,
/// Whether the quick color picker dropdown is open.
pub color_picker_open: bool,
/// Whether the full 16×16 ACI palette is expanded inside the color picker.
@@ -148,6 +162,7 @@ impl Default for PropertiesPanel {
linetype_combo: combo_box::State::new(vec![]),
hatch_pattern_combo: combo_box::State::new(crate::scene::model::hatch_patterns::names()),
edit_buf: HashMap::default(),
source_handles: vec![],
color_picker_open: false,
color_palette_open: false,
}
@@ -341,6 +356,9 @@ impl PropertiesPanel {
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));
}
}
}
@@ -586,6 +604,33 @@ impl PropertiesPanel {
prop_row_widget(label, ti.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
/// path whose field key is `&'static str`. The row label is the tag itself.
fn render_attr_row<'a>(&'a self, tag: &'a str, entity_val: &'a str) -> Element<'a, Message> {
let key = attr_edit_key(tag);
let display = self
.edit_buf
.get(&key)
.map(|s| s.as_str())
.unwrap_or(entity_val);
let tag_for_input = tag.to_string();
let ti = text_input("", display)
.on_input(move |v| Message::PropAttrInput {
tag: tag_for_input.clone(),
value: v,
})
.on_submit(Message::PropAttrCommit(tag.to_string()))
.size(FONT_SZ)
.style(text_input_style)
.padding([3, 6])
.width(Length::Fill);
prop_row_widget(tag, ti.into())
}
fn render_hatch_pattern_row<'a>(
&'a self,
label: &'a str,