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

@ -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()
}
}