feat(attedit): attribute editor dialog for blocks

Double-clicking a block reference that has attributes, or running ATTEDIT
on one, now opens an editor dialog listing every attribute (tag + editable
value) with OK / Cancel. OK writes the values back to the block, with undo
and a repaint; Cancel discards. A block with no attributes still enters
in-place block edit (REFEDIT) on double-click as before. (#192)

ATTEDIT opens the dialog directly when a suitable block is already
selected; otherwise it runs the pick command and the dialog opens once a
block is chosen. This replaces the earlier command-line, per-attribute
prompt flow, which is removed along with its __ATTEDIT__ sentinel path and
the now-unused attedit_set_attrs trait hook; the ATTEDIT command is reduced
to a plain block picker.

The dialog is a tab-scoped in-canvas modal (Plan B): its working copy holds
a document-local handle, so closing that tab or switching away dismisses the
editor rather than risk applying edits to another tab's document.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-01 08:05:25 +03:00
commit 86a59d6c7e
12 changed files with 346 additions and 192 deletions

View file

@ -172,35 +172,17 @@ impl OpenCADStudio {
let i = self.active_tab;
match result {
CmdResult::NeedPoint => {
// If ATTEDIT just completed entity pick, inject attribute data.
// ATTEDIT finished its entity pick: hand the chosen block off to
// the attribute editor dialog and end the command (open_attribute
// _editor reports "no attributes" / "select a block" as needed).
let attedit_handle = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.attedit_pending_handle());
if let Some(ins_handle) = attedit_handle {
if let Some(acadrust::EntityType::Insert(ins)) =
self.tabs[i].scene.document.get_entity(ins_handle)
{
let attrs: Vec<(String, String)> = ins
.attributes
.iter()
.map(|a| (a.tag.clone(), a.get_value().to_string()))
.collect();
if attrs.is_empty() {
self.command_line
.push_error("ATTEDIT This INSERT has no attributes.");
self.tabs[i].active_cmd = None;
return Task::none();
}
if let Some(cmd) = &mut self.tabs[i].active_cmd {
cmd.attedit_set_attrs(attrs);
}
} else {
self.command_line
.push_error("ATTEDIT Please select an INSERT entity with attributes.");
self.tabs[i].active_cmd = None;
return Task::none();
}
self.tabs[i].active_cmd = None;
self.open_attribute_editor(ins_handle);
return Task::none();
}
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
@ -458,28 +440,6 @@ impl OpenCADStudio {
self.refresh_properties();
}
CmdResult::ReplaceEntity(handle, new_entities) => {
// Detect ATTEDIT sentinel.
if new_entities.len() == 1 {
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
let layer = xl.common.layer.clone();
if let Some(encoded) = layer.strip_prefix("__ATTEDIT__") {
let label = self.history_label_from_active_cmd(i, "ATTEDIT");
self.push_undo_snapshot(i, label);
crate::modules::draw::modify::attedit::apply_attedit(
&mut self.tabs[i].scene.document,
handle,
encoded,
);
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.command_line
.push_output("ATTEDIT Attribute values updated.");
return Task::none();
}
}
}
// Detect SPLINEDIT sentinel: a single XLine with a magic layer name.
if new_entities.len() == 1 {
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {

View file

@ -196,13 +196,33 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
// Bare ATTEDIT (and the ATE alias) launch the interactive attribute
// editor; the command-line -ATTEDIT form is handled in the draw family.
// Bare ATTEDIT (and the ATE alias) open the attribute editor dialog.
// If a single block with attributes is already selected it opens on
// that block; otherwise the pick command runs and the editor opens
// once a block is chosen (see `command_driver`).
"ATTEDIT" | "ATE" => {
use crate::modules::draw::modify::attedit::AtteditCommand;
let cmd_obj = AtteditCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
let selected_attr_insert = {
let sel = self.tabs[i].scene.selected_entities();
if sel.len() == 1 {
let (h, e) = sel[0];
match e {
acadrust::EntityType::Insert(ins) if !ins.attributes.is_empty() => {
Some(h)
}
_ => None,
}
} else {
None
}
};
if let Some(handle) = selected_attr_insert {
self.open_attribute_editor(handle);
} else {
use crate::modules::draw::modify::attedit::AtteditCommand;
let cmd_obj = AtteditCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
}
}
// ── REFEDIT — in-place block editing ─────────────────────────────

View file

@ -356,6 +356,14 @@ pub(super) struct OpenCADStudio {
modal_drag_last: Option<Point>,
/// True while the modal title bar is held (a drag is in progress).
modal_dragging: bool,
// ── Attribute editor dialog (ATTEDIT / double-click a block) ───────────
/// INSERT whose attributes the editor modal is editing (`None` = closed).
attr_editor_handle: Option<acadrust::Handle>,
/// Block name shown in the editor's title bar.
attr_editor_block: String,
/// Working copy of the block's attributes as `(tag, value)`, in the same
/// order as `Insert::attributes`. Edited live; written back on OK.
attr_editor_fields: Vec<(String, String)>,
/// 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`].
@ -944,6 +952,7 @@ pub enum ModalKind {
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
AssocPrompt,
PointStyle,
AttributeEditor,
}
/// Identifies a DimStyle field that can be edited in the dialog.
@ -1442,6 +1451,13 @@ pub enum Message {
AboutOpen,
/// Close whatever in-canvas modal dialog is open (Plan B).
CloseModal,
// ── 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.
AttrEditorInput { idx: usize, value: String },
/// Apply every attribute edit to the block and close the dialog (OK).
AttrEditorOk,
/// Title-bar pressed: begin dragging the active modal.
ModalGrab,
/// Cursor moved while dragging the modal title bar.
@ -1914,6 +1930,9 @@ impl OpenCADStudio {
modal_offset: iced::Vector::ZERO,
modal_drag_last: None,
modal_dragging: false,
attr_editor_handle: None,
attr_editor_block: String::new(),
attr_editor_fields: Vec::new(),
disabled_plugins: rustc_hash::FxHashSet::default(),
external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(),

View file

@ -27,6 +27,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
return Task::none();
}
// Closing a tab shifts indices / the active tab. The attribute
// editor holds a document-local handle into one tab, so drop it
// now rather than risk it applying to a different tab's document.
self.cancel_attr_editor();
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
@ -1204,4 +1208,102 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.refresh_properties();
Task::none()
}
/// Open the attribute editor dialog for the given INSERT, loading a working
/// copy of its attribute values. No-op (with a hint) when the block has no
/// attributes. Entry points: double-clicking such a block, or ATTEDIT.
pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) {
let i = self.active_tab;
let loaded = match self.tabs[i].scene.document.get_entity(handle) {
Some(acadrust::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
let block = ins.block_name.clone();
let fields = ins
.attributes
.iter()
.map(|a| (a.tag.clone(), a.get_value().to_string()))
.collect::<Vec<_>>();
Some((block, fields))
}
Some(acadrust::EntityType::Insert(_)) => {
self.command_line
.push_error("ATTEDIT This block has no attributes.");
None
}
_ => {
self.command_line
.push_error("ATTEDIT Select a block with attributes.");
None
}
};
if let Some((block, fields)) = loaded {
self.attr_editor_block = block;
self.attr_editor_fields = fields;
self.attr_editor_handle = Some(handle);
self.active_modal = Some(crate::app::ModalKind::AttributeEditor);
self.modal_offset = iced::Vector::ZERO;
}
}
/// 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.
pub(super) fn cancel_attr_editor(&mut self) {
if self.active_modal == Some(crate::app::ModalKind::AttributeEditor) {
self.active_modal = None;
self.modal_offset = iced::Vector::ZERO;
}
self.attr_editor_handle = None;
self.attr_editor_block.clear();
self.attr_editor_fields.clear();
}
/// 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<Message> {
let i = self.active_tab;
let Some(handle) = self.attr_editor_handle.take() else {
self.active_modal = None;
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;
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 changed {
self.invalidate_property_targets(i, &[handle]);
self.tabs[i].dirty = true;
} else {
// Nothing changed — drop the snapshot pushed a moment ago.
let _ = self.tabs[i].history.undo_stack.pop();
}
self.refresh_properties();
Task::none()
}
}

View file

@ -104,6 +104,12 @@ impl OpenCADStudio {
// Dismissing these via ✕ is the cancel/decline path.
Some(Unsaved) => self.pending_close = None,
Some(AssocPrompt) => self.mark_assoc_prompted(),
// Cancel: drop the working copy without touching the block.
Some(AttributeEditor) => {
self.attr_editor_handle = None;
self.attr_editor_block.clear();
self.attr_editor_fields.clear();
}
_ => {}
}
self.active_modal = None;
@ -645,6 +651,11 @@ impl OpenCADStudio {
Message::TabSwitch(idx) => {
if idx < self.tabs.len() {
if idx != self.active_tab {
// The attribute editor is tab-scoped; leaving its tab
// drops it (its handle is that document's, not this one's).
self.cancel_attr_editor();
}
self.active_tab = idx;
self.sync_ribbon_layers();
self.sync_ribbon_styles();
@ -2253,6 +2264,17 @@ impl OpenCADStudio {
self.close_active_modal();
Task::none()
}
Message::AttrEditorOpen(handle) => {
self.open_attribute_editor(handle);
Task::none()
}
Message::AttrEditorInput { idx, value } => {
if let Some(field) = self.attr_editor_fields.get_mut(idx) {
field.1 = value;
}
Task::none()
}
Message::AttrEditorOk => self.on_attr_editor_ok(),
Message::ModalGrab => {
// Start a drag; the first ModalDragMove seeds the reference.
self.modal_dragging = true;

View file

@ -2437,9 +2437,18 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if is_editable_text {
return self.begin_text_edit(handle);
}
// Double-clicking a block reference enters in-place
// block edit (REFEDIT), so its geometry can be edited
// and the change reflects in every instance. (#136)
// Double-clicking a block with attributes opens the
// attribute editor (edit its values); a block with
// no attributes enters in-place block edit (REFEDIT),
// so its geometry can be edited and the change
// reflects in every instance. (#136, #192)
let insert_has_attrs = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty()
);
if insert_has_attrs {
return Task::done(Message::AttrEditorOpen(handle));
}
let is_insert = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(_))

View file

@ -1363,6 +1363,7 @@ impl OpenCADStudio {
OverwriteWarning => (420, 180),
SaveDialog => (560, 480),
PointStyle => (360, 470),
AttributeEditor => (460, 440),
};
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
}

View file

@ -511,6 +511,14 @@ 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::SaveDialog => sized(
save_as_dialog_window(
&self.save_dialog_filename,