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:
parent
5f2e920b75
commit
86a59d6c7e
12 changed files with 346 additions and 192 deletions
|
|
@ -172,36 +172,18 @@ impl OpenCADStudio {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
match result {
|
match result {
|
||||||
CmdResult::NeedPoint => {
|
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]
|
let attedit_handle = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|c| c.attedit_pending_handle());
|
.and_then(|c| c.attedit_pending_handle());
|
||||||
if let Some(ins_handle) = attedit_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;
|
self.tabs[i].active_cmd = None;
|
||||||
|
self.open_attribute_editor(ins_handle);
|
||||||
return Task::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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
|
||||||
if let Some(p) = prompt {
|
if let Some(p) = prompt {
|
||||||
self.command_line.push_info(&p);
|
self.command_line.push_info(&p);
|
||||||
|
|
@ -458,28 +440,6 @@ impl OpenCADStudio {
|
||||||
self.refresh_properties();
|
self.refresh_properties();
|
||||||
}
|
}
|
||||||
CmdResult::ReplaceEntity(handle, new_entities) => {
|
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.
|
// Detect SPLINEDIT sentinel: a single XLine with a magic layer name.
|
||||||
if new_entities.len() == 1 {
|
if new_entities.len() == 1 {
|
||||||
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
|
if let acadrust::EntityType::XLine(ref xl) = new_entities[0] {
|
||||||
|
|
|
||||||
|
|
@ -196,14 +196,34 @@ impl OpenCADStudio {
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bare ATTEDIT (and the ATE alias) launch the interactive attribute
|
// Bare ATTEDIT (and the ATE alias) open the attribute editor dialog.
|
||||||
// editor; the command-line -ATTEDIT form is handled in the draw family.
|
// 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" => {
|
"ATTEDIT" | "ATE" => {
|
||||||
|
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;
|
use crate::modules::draw::modify::attedit::AtteditCommand;
|
||||||
let cmd_obj = AtteditCommand::new();
|
let cmd_obj = AtteditCommand::new();
|
||||||
self.command_line.push_info(&cmd_obj.prompt());
|
self.command_line.push_info(&cmd_obj.prompt());
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── REFEDIT — in-place block editing ─────────────────────────────
|
// ── REFEDIT — in-place block editing ─────────────────────────────
|
||||||
"REFEDIT" => {
|
"REFEDIT" => {
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,14 @@ pub(super) struct OpenCADStudio {
|
||||||
modal_drag_last: Option<Point>,
|
modal_drag_last: Option<Point>,
|
||||||
/// True while the modal title bar is held (a drag is in progress).
|
/// True while the modal title bar is held (a drag is in progress).
|
||||||
modal_dragging: bool,
|
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
|
/// Plugin ids the user turned off in the Plugin Manager. Disabled plugins
|
||||||
/// keep their manifest listed but drop their ribbon tab and command
|
/// keep their manifest listed but drop their ribbon tab and command
|
||||||
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
||||||
|
|
@ -944,6 +952,7 @@ pub enum ModalKind {
|
||||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||||
AssocPrompt,
|
AssocPrompt,
|
||||||
PointStyle,
|
PointStyle,
|
||||||
|
AttributeEditor,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identifies a DimStyle field that can be edited in the dialog.
|
/// Identifies a DimStyle field that can be edited in the dialog.
|
||||||
|
|
@ -1442,6 +1451,13 @@ pub enum Message {
|
||||||
AboutOpen,
|
AboutOpen,
|
||||||
/// Close whatever in-canvas modal dialog is open (Plan B).
|
/// Close whatever in-canvas modal dialog is open (Plan B).
|
||||||
CloseModal,
|
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.
|
/// Title-bar pressed: begin dragging the active modal.
|
||||||
ModalGrab,
|
ModalGrab,
|
||||||
/// Cursor moved while dragging the modal title bar.
|
/// Cursor moved while dragging the modal title bar.
|
||||||
|
|
@ -1914,6 +1930,9 @@ impl OpenCADStudio {
|
||||||
modal_offset: iced::Vector::ZERO,
|
modal_offset: iced::Vector::ZERO,
|
||||||
modal_drag_last: None,
|
modal_drag_last: None,
|
||||||
modal_dragging: false,
|
modal_dragging: false,
|
||||||
|
attr_editor_handle: None,
|
||||||
|
attr_editor_block: String::new(),
|
||||||
|
attr_editor_fields: Vec::new(),
|
||||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||||
external_plugins: Vec::new(),
|
external_plugins: Vec::new(),
|
||||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
|
||||||
return Task::none();
|
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) {
|
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
|
||||||
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
self.pending_close = Some(crate::app::PendingClose::Tab(idx));
|
||||||
return self.open_unsaved_dialog_window();
|
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();
|
self.refresh_properties();
|
||||||
Task::none()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,12 @@ impl OpenCADStudio {
|
||||||
// Dismissing these via ✕ is the cancel/decline path.
|
// Dismissing these via ✕ is the cancel/decline path.
|
||||||
Some(Unsaved) => self.pending_close = None,
|
Some(Unsaved) => self.pending_close = None,
|
||||||
Some(AssocPrompt) => self.mark_assoc_prompted(),
|
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;
|
self.active_modal = None;
|
||||||
|
|
@ -645,6 +651,11 @@ impl OpenCADStudio {
|
||||||
|
|
||||||
Message::TabSwitch(idx) => {
|
Message::TabSwitch(idx) => {
|
||||||
if idx < self.tabs.len() {
|
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.active_tab = idx;
|
||||||
self.sync_ribbon_layers();
|
self.sync_ribbon_layers();
|
||||||
self.sync_ribbon_styles();
|
self.sync_ribbon_styles();
|
||||||
|
|
@ -2253,6 +2264,17 @@ impl OpenCADStudio {
|
||||||
self.close_active_modal();
|
self.close_active_modal();
|
||||||
Task::none()
|
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 => {
|
Message::ModalGrab => {
|
||||||
// Start a drag; the first ModalDragMove seeds the reference.
|
// Start a drag; the first ModalDragMove seeds the reference.
|
||||||
self.modal_dragging = true;
|
self.modal_dragging = true;
|
||||||
|
|
|
||||||
|
|
@ -2437,9 +2437,18 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
|
||||||
if is_editable_text {
|
if is_editable_text {
|
||||||
return self.begin_text_edit(handle);
|
return self.begin_text_edit(handle);
|
||||||
}
|
}
|
||||||
// Double-clicking a block reference enters in-place
|
// Double-clicking a block with attributes opens the
|
||||||
// block edit (REFEDIT), so its geometry can be edited
|
// attribute editor (edit its values); a block with
|
||||||
// and the change reflects in every instance. (#136)
|
// 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!(
|
let is_insert = matches!(
|
||||||
self.tabs[i].scene.document.get_entity(handle),
|
self.tabs[i].scene.document.get_entity(handle),
|
||||||
Some(AcadEntityType::Insert(_))
|
Some(AcadEntityType::Insert(_))
|
||||||
|
|
|
||||||
|
|
@ -1363,6 +1363,7 @@ impl OpenCADStudio {
|
||||||
OverwriteWarning => (420, 180),
|
OverwriteWarning => (420, 180),
|
||||||
SaveDialog => (560, 480),
|
SaveDialog => (560, 480),
|
||||||
PointStyle => (360, 470),
|
PointStyle => (360, 470),
|
||||||
|
AttributeEditor => (460, 440),
|
||||||
};
|
};
|
||||||
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -511,6 +511,14 @@ impl OpenCADStudio {
|
||||||
360,
|
360,
|
||||||
470,
|
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(
|
super::super::ModalKind::SaveDialog => sized(
|
||||||
save_as_dialog_window(
|
save_as_dialog_window(
|
||||||
&self.save_dialog_filename,
|
&self.save_dialog_filename,
|
||||||
|
|
|
||||||
|
|
@ -667,15 +667,12 @@ pub trait CadCommand: Send {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If this command needs attribute data injected (ATTEDIT), returns the
|
/// The block reference an ATTEDIT pick has resolved to, awaiting the
|
||||||
/// INSERT handle awaiting attr initialization; else None.
|
/// attribute editor dialog; else None.
|
||||||
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
|
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject attribute (tag, value) pairs into the command after entity pick.
|
|
||||||
fn attedit_set_attrs(&mut self, _attrs: Vec<(String, String)>) {}
|
|
||||||
|
|
||||||
/// Inject attribute definitions (tag, prompt, default_value) for ATTREQ
|
/// Inject attribute definitions (tag, prompt, default_value) for ATTREQ
|
||||||
/// attr-filling after INSERT point is picked.
|
/// attr-filling after INSERT point is picked.
|
||||||
fn attreq_set_attdefs(&mut self, _attdefs: Vec<(String, String, String)>) {}
|
fn attreq_set_attdefs(&mut self, _attdefs: Vec<(String, String, String)>) {}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,25 @@
|
||||||
// ATTEDIT command — edit attribute values of a selected INSERT entity.
|
// ATTEDIT command — pick a block reference with attributes, then open the
|
||||||
|
// attribute editor dialog on it.
|
||||||
//
|
//
|
||||||
// Workflow:
|
// This command only performs the entity pick. Once a block is chosen it
|
||||||
// Step 1: pick an INSERT entity that has attributes.
|
// reports the handle through `attedit_pending_handle`; the command host
|
||||||
// Step 2+: for each attribute tag, show "TAG = <value>" and accept new
|
// (`command_driver`) ends the command and opens the editor dialog. When a
|
||||||
// text via text input. Enter with empty string keeps the old value.
|
// suitable block is already selected, the ATTEDIT dispatch opens the dialog
|
||||||
// After all attributes are processed, commit via ReplaceMany.
|
// directly and never starts this command (see `app::commands::inquiry`).
|
||||||
|
|
||||||
use acadrust::EntityType;
|
|
||||||
use glam::DVec3;
|
use glam::DVec3;
|
||||||
|
|
||||||
use crate::command::{CadCommand, CmdResult};
|
use crate::command::{CadCommand, CmdResult};
|
||||||
use crate::scene::model::wire_model::WireModel;
|
use crate::scene::model::wire_model::WireModel;
|
||||||
|
|
||||||
pub struct AtteditCommand {
|
pub struct AtteditCommand {
|
||||||
step: Step,
|
/// The picked block reference, once the user clicks one.
|
||||||
}
|
picked: Option<acadrust::Handle>,
|
||||||
|
|
||||||
enum Step {
|
|
||||||
/// Waiting for the user to pick an INSERT entity.
|
|
||||||
SelectInsert,
|
|
||||||
/// Editing the attribute at index `idx` of the collected insert data.
|
|
||||||
EditAttr {
|
|
||||||
handle: acadrust::Handle,
|
|
||||||
/// (tag, current_value) pairs collected from the insert.
|
|
||||||
attrs: Vec<(String, String)>,
|
|
||||||
/// Index of the attribute currently being edited.
|
|
||||||
idx: usize,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AtteditCommand {
|
impl AtteditCommand {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self { picked: None }
|
||||||
step: Step::SelectInsert,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,77 +29,23 @@ impl CadCommand for AtteditCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(&self) -> String {
|
fn prompt(&self) -> String {
|
||||||
match &self.step {
|
"ATTEDIT Select block with attributes:".to_string()
|
||||||
Step::SelectInsert => "ATTEDIT Select block with attributes:".to_string(),
|
|
||||||
Step::EditAttr { attrs, idx, .. } => {
|
|
||||||
let (tag, val) = &attrs[*idx];
|
|
||||||
format!(
|
|
||||||
"ATTEDIT {} = <{}> (Enter to keep, type new value):",
|
|
||||||
tag, val
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_entity_pick(&self) -> bool {
|
fn needs_entity_pick(&self) -> bool {
|
||||||
matches!(self.step, Step::SelectInsert)
|
self.picked.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult {
|
fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult {
|
||||||
if handle.is_null() {
|
if handle.is_null() {
|
||||||
return CmdResult::NeedPoint;
|
return CmdResult::NeedPoint;
|
||||||
}
|
}
|
||||||
// We can't inspect the document here — store the handle and let the
|
// Record the pick and yield: the host reads `attedit_pending_handle`
|
||||||
// command host inject the attribute list via `init_with_attrs`.
|
// and opens the editor dialog for this block.
|
||||||
// Instead, signal the host to call prepare_attedit().
|
self.picked = Some(handle);
|
||||||
self.step = Step::EditAttr {
|
|
||||||
handle,
|
|
||||||
attrs: vec![], // will be filled by init_with_attrs() in cmd_result.rs
|
|
||||||
idx: 0,
|
|
||||||
};
|
|
||||||
CmdResult::NeedPoint
|
CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wants_text_input(&self) -> bool {
|
|
||||||
if let Step::EditAttr { ref attrs, idx, .. } = self.step {
|
|
||||||
!attrs.is_empty() && idx < attrs.len()
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn wants_text_with_spaces(&self) -> bool {
|
|
||||||
// Editing an attribute value — same free-form semantics as the
|
|
||||||
// text-content prompts in TEXT / MTEXT / DDEDIT.
|
|
||||||
self.wants_text_input()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
|
||||||
let Step::EditAttr { handle, attrs, idx } = &mut self.step else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let handle = *handle;
|
|
||||||
|
|
||||||
// Update the current attribute value if the user typed something.
|
|
||||||
if !text.trim().is_empty() {
|
|
||||||
attrs[*idx].1 = text.trim().to_string();
|
|
||||||
}
|
|
||||||
*idx += 1;
|
|
||||||
|
|
||||||
if *idx >= attrs.len() {
|
|
||||||
// All attributes done — build new Insert entity.
|
|
||||||
// We signal via a special CmdResult; the host will call apply_attedit().
|
|
||||||
let pairs = attrs.clone();
|
|
||||||
return Some(CmdResult::ReplaceEntity(
|
|
||||||
handle,
|
|
||||||
vec![make_attedit_sentinel(handle, &pairs)],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// More attributes to edit.
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
|
||||||
CmdResult::NeedPoint
|
CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
@ -125,55 +57,7 @@ impl CadCommand for AtteditCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
|
fn attedit_pending_handle(&self) -> Option<acadrust::Handle> {
|
||||||
if let Step::EditAttr { handle, attrs, .. } = &self.step {
|
self.picked
|
||||||
if attrs.is_empty() {
|
|
||||||
return Some(*handle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attedit_set_attrs(&mut self, new_attrs: Vec<(String, String)>) {
|
|
||||||
if let Step::EditAttr { attrs, idx, .. } = &mut self.step {
|
|
||||||
*attrs = new_attrs;
|
|
||||||
*idx = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Make a sentinel entity carrying the edited attribute values.
|
|
||||||
/// Encodes all (tag=value) pairs in the layer field as "tag1\x01val1\x02tag2\x01val2...".
|
|
||||||
fn make_attedit_sentinel(_handle: acadrust::Handle, pairs: &[(String, String)]) -> EntityType {
|
|
||||||
let encoded: String = pairs
|
|
||||||
.iter()
|
|
||||||
.map(|(t, v)| format!("{}\x01{}", t, v))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\x02");
|
|
||||||
let mut xl = acadrust::entities::XLine::new(
|
|
||||||
acadrust::types::Vector3::zero(),
|
|
||||||
acadrust::types::Vector3::new(1.0, 0.0, 0.0),
|
|
||||||
);
|
|
||||||
xl.common.layer = format!("__ATTEDIT__{}", encoded);
|
|
||||||
EntityType::XLine(xl)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply edited attribute values to an INSERT entity in the document.
|
|
||||||
/// Called from `cmd_result.rs` when the sentinel is detected.
|
|
||||||
pub fn apply_attedit(doc: &mut acadrust::CadDocument, handle: acadrust::Handle, encoded: &str) {
|
|
||||||
let Some(EntityType::Insert(ins)) = doc.get_entity_mut(handle) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for pair in encoded.split('\x02') {
|
|
||||||
let mut parts = pair.splitn(2, '\x01');
|
|
||||||
let Some(tag) = parts.next() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(val) = parts.next() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(attrib) = ins.attributes.iter_mut().find(|a| a.tag == tag) {
|
|
||||||
attrib.set_value(val);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
131
src/ui/window/attribute_editor.rs
Normal file
131
src/ui/window/attribute_editor.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
//! Attribute editor dialog — edit the attribute values of a single block
|
||||||
|
//! reference (INSERT). Opened by double-clicking a block that carries
|
||||||
|
//! attributes, or by the ATTEDIT command with such a block selected.
|
||||||
|
//!
|
||||||
|
//! Each attribute is one row: its tag (read-only) and an editable value box.
|
||||||
|
//! OK writes every value back to the block; Cancel (the ✕ or button) discards.
|
||||||
|
//! The heavy lifting — applying edits, undo, repaint — lives in the update
|
||||||
|
//! handler (`Message::AttrEditorOk`); this module is pure layout.
|
||||||
|
|
||||||
|
use crate::app::Message;
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
|
||||||
|
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||||
|
|
||||||
|
const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 };
|
||||||
|
const WHITE: Color = Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 };
|
||||||
|
const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 };
|
||||||
|
const ACCENT: Color = Color { r: 0.30, g: 0.62, b: 0.95, a: 1.0 };
|
||||||
|
const FIELD_BG: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 };
|
||||||
|
const BORDER: Color = Color { r: 0.32, g: 0.32, b: 0.32, a: 1.0 };
|
||||||
|
|
||||||
|
fn field_style(_t: &Theme, _s: text_input::Status) -> text_input::Style {
|
||||||
|
text_input::Style {
|
||||||
|
background: Background::Color(FIELD_BG),
|
||||||
|
border: Border { color: BORDER, width: 1.0, radius: 3.0.into() },
|
||||||
|
icon: WHITE,
|
||||||
|
placeholder: DIM,
|
||||||
|
value: WHITE,
|
||||||
|
selection: ACCENT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the attribute editor dialog body. `block` is the reference's block
|
||||||
|
/// name (shown as a subtitle); `fields` are the `(tag, value)` pairs in
|
||||||
|
/// attribute order — the row index is the routing key back to
|
||||||
|
/// `Message::AttrEditorInput`.
|
||||||
|
pub fn view_window<'a>(block: &'a str, fields: &'a [(String, String)]) -> Element<'a, Message> {
|
||||||
|
let mut list = column![].spacing(6);
|
||||||
|
for (idx, (tag, value)) in fields.iter().enumerate() {
|
||||||
|
let value_box = text_input("", value)
|
||||||
|
.on_input(move |v| Message::AttrEditorInput { idx, value: v })
|
||||||
|
.on_submit(Message::AttrEditorOk)
|
||||||
|
.style(field_style)
|
||||||
|
.size(13)
|
||||||
|
.padding([4, 6])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let attr_row = row![
|
||||||
|
container(text(tag.as_str()).size(13).color(WHITE))
|
||||||
|
.width(170)
|
||||||
|
.padding([4, 6]),
|
||||||
|
value_box,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Center);
|
||||||
|
list = list.push(attr_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Element<'_, Message> = if fields.is_empty() {
|
||||||
|
text("This block has no attributes.")
|
||||||
|
.size(13)
|
||||||
|
.color(DIM)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
scrollable(list).height(Length::Fill).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = button(text("OK").size(13).color(WHITE))
|
||||||
|
.padding([5, 22])
|
||||||
|
.on_press(Message::AttrEditorOk)
|
||||||
|
.style(|_: &Theme, status| {
|
||||||
|
let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||||
|
Color { r: 0.32, g: 0.55, b: 0.85, a: 1.0 }
|
||||||
|
} else {
|
||||||
|
ACCENT
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: WHITE,
|
||||||
|
border: Border { radius: 4.0.into(), ..Default::default() },
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cancel = button(text("Cancel").size(13).color(WHITE))
|
||||||
|
.padding([5, 18])
|
||||||
|
.on_press(Message::CloseModal)
|
||||||
|
.style(|_: &Theme, status| {
|
||||||
|
let bg = if matches!(status, button::Status::Hovered | button::Status::Pressed) {
|
||||||
|
Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 }
|
||||||
|
} else {
|
||||||
|
Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0 }
|
||||||
|
};
|
||||||
|
button::Style {
|
||||||
|
background: Some(Background::Color(bg)),
|
||||||
|
text_color: WHITE,
|
||||||
|
border: Border { color: BORDER, width: 1.0, radius: 4.0.into() },
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let header = column![
|
||||||
|
text("Edit Attributes").size(18).color(WHITE),
|
||||||
|
text(format!("Block: {block}")).size(12).color(DIM),
|
||||||
|
]
|
||||||
|
.spacing(2);
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
Space::new().height(10),
|
||||||
|
body,
|
||||||
|
Space::new().height(12),
|
||||||
|
row![
|
||||||
|
Space::new().width(Length::Fill),
|
||||||
|
cancel,
|
||||||
|
Space::new().width(8),
|
||||||
|
ok
|
||||||
|
]
|
||||||
|
.align_y(iced::Center),
|
||||||
|
]
|
||||||
|
.padding(4),
|
||||||
|
)
|
||||||
|
.style(|_: &Theme| container::Style {
|
||||||
|
background: Some(Background::Color(BG)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.padding(14)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
@ -6,3 +6,4 @@ pub mod shortcuts;
|
||||||
pub mod layers;
|
pub mod layers;
|
||||||
pub mod update_notice;
|
pub mod update_notice;
|
||||||
pub mod open_progress;
|
pub mod open_progress;
|
||||||
|
pub mod attribute_editor;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue