forked from 1b-agent/opencadstudio-upstream
feat(attedit): enhanced attribute editor with 3 tabs
Grow the attribute editor into a three-tab enhanced editor: - Attribute: the tag/prompt/value list; click a row to select it, edit its value below. - Text Options: the selected attribute's Text Style, Justification, Height, Rotation, Width Factor, Oblique Angle, Backwards, Upside down. - Properties: the selected attribute's Layer, Linetype, Color, Lineweight (the colour list also surfaces a non-standard current colour so it displays and round-trips). Applying writes value, text-formatting and common-property edits back to each attribute positionally (guarded on block name + count), with undo and a repaint. OK/Cancel are replaced by a single Apply button in a top toolbar that commits but keeps the dialog open, matching the other modal windows — the frame ✕ closes and discards un-applied edits. The frame adopts the shared style-window palette, tab styling and sizing. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
0eb347bf47
commit
0b60bcc075
6 files changed
+722
-170
No files matched your search
+33
-9
@@ -361,11 +361,14 @@ pub(super) struct OpenCADStudio {
|
||||
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, prompt, value)`, in the
|
||||
/// same order as `Insert::attributes`. The prompt is read from the block's
|
||||
/// matching ATTDEF (blank if none). Only the value is edited; the whole row
|
||||
/// is written back on OK.
|
||||
attr_editor_fields: Vec<(String, String, String)>,
|
||||
/// Working copy of the block's attributes, in the same order as
|
||||
/// `Insert::attributes`. Each row carries the value plus the text-option and
|
||||
/// common-property fields the editor edits; written back on OK.
|
||||
attr_editor_rows: Vec<crate::ui::window::attribute_editor::AttrRow>,
|
||||
/// Which editor tab is showing.
|
||||
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab,
|
||||
/// Highlighted attribute row driving the Text Options / Properties tabs.
|
||||
attr_editor_selected: usize,
|
||||
/// 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`].
|
||||
@@ -1456,10 +1459,29 @@ pub enum Message {
|
||||
// ── 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.
|
||||
/// Switch the editor's active tab.
|
||||
AttrEditorTab(crate::ui::window::attribute_editor::AttrTab),
|
||||
/// Select the row the Text Options / Properties tabs act on.
|
||||
AttrEditorSelect(usize),
|
||||
/// Live edit of the attribute value at row `idx` (Attribute tab).
|
||||
AttrEditorInput { idx: usize, value: String },
|
||||
/// Apply every attribute edit to the block and close the dialog (OK).
|
||||
AttrEditorOk,
|
||||
// Text Options — all act on the selected row:
|
||||
AttrEditorTextStyle(String),
|
||||
AttrEditorJustify(String),
|
||||
AttrEditorHeight(String),
|
||||
AttrEditorRotation(String),
|
||||
AttrEditorWidth(String),
|
||||
AttrEditorOblique(String),
|
||||
AttrEditorBackwards(bool),
|
||||
AttrEditorUpsideDown(bool),
|
||||
// Properties — all act on the selected row:
|
||||
AttrEditorLayer(String),
|
||||
AttrEditorLinetype(String),
|
||||
AttrEditorColor(String),
|
||||
AttrEditorLineweight(acadrust::types::LineWeight),
|
||||
/// Commit every attribute edit to the block, keeping the dialog open
|
||||
/// (Apply); the frame ✕ closes and discards any un-applied edits.
|
||||
AttrEditorApply,
|
||||
/// Title-bar pressed: begin dragging the active modal.
|
||||
ModalGrab,
|
||||
/// Cursor moved while dragging the modal title bar.
|
||||
@@ -1934,7 +1956,9 @@ impl OpenCADStudio {
|
||||
modal_dragging: false,
|
||||
attr_editor_handle: None,
|
||||
attr_editor_block: String::new(),
|
||||
attr_editor_fields: Vec::new(),
|
||||
attr_editor_rows: Vec::new(),
|
||||
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute,
|
||||
attr_editor_selected: 0,
|
||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||
external_plugins: Vec::new(),
|
||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||
|
||||
+135
-37
@@ -15,6 +15,7 @@ use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::PropertiesPanel;
|
||||
use crate::ui::window::attribute_editor::{AttrRow, AttrTab};
|
||||
use acadrust::types::Color as AcadColor;
|
||||
use acadrust::{EntityType as AcadEntityType, Handle};
|
||||
use iced::time::Instant;
|
||||
@@ -1215,22 +1216,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
pub(crate) fn open_attribute_editor(&mut self, handle: acadrust::Handle) {
|
||||
let i = self.active_tab;
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
// Ok((block, fields)) to open; Err(msg) to report and stay closed. The
|
||||
// Ok((block, rows)) to open; Err(msg) to report and stay closed. The
|
||||
// borrow of `doc` ends with this match, before any `self` mutation.
|
||||
let result = match doc.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Insert(ins)) if !ins.attributes.is_empty() => {
|
||||
// The prompt text lives on the block's ATTDEFs, not on the
|
||||
// attribute instances — map tag → prompt from the definition.
|
||||
let prompts = block_attr_prompts(doc, &ins.block_name);
|
||||
let fields = ins
|
||||
let rows = ins
|
||||
.attributes
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let prompt = prompts.get(&a.tag).cloned().unwrap_or_default();
|
||||
(a.tag.clone(), prompt, a.get_value().to_string())
|
||||
})
|
||||
.map(|a| attr_row_from_entity(a, &prompts))
|
||||
.collect::<Vec<_>>();
|
||||
Ok((ins.block_name.clone(), fields))
|
||||
Ok((ins.block_name.clone(), rows))
|
||||
}
|
||||
Some(acadrust::EntityType::Insert(_)) => {
|
||||
Err("ATTEDIT This block has no attributes.")
|
||||
@@ -1238,9 +1236,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
_ => Err("ATTEDIT Select a block with attributes."),
|
||||
};
|
||||
match result {
|
||||
Ok((block, fields)) => {
|
||||
Ok((block, rows)) => {
|
||||
self.attr_editor_block = block;
|
||||
self.attr_editor_fields = fields;
|
||||
self.attr_editor_rows = rows;
|
||||
self.attr_editor_selected = 0;
|
||||
self.attr_editor_tab = AttrTab::Attribute;
|
||||
self.attr_editor_handle = Some(handle);
|
||||
self.active_modal = Some(crate::app::ModalKind::AttributeEditor);
|
||||
self.modal_offset = iced::Vector::ZERO;
|
||||
@@ -1249,6 +1249,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The currently-selected editor row, for the Text Options / Properties
|
||||
/// edit handlers (which all act on that one attribute).
|
||||
pub(super) fn attr_row_selected_mut(&mut self) -> Option<&mut AttrRow> {
|
||||
let sel = self.attr_editor_selected;
|
||||
self.attr_editor_rows.get_mut(sel)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -1259,45 +1266,35 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
self.attr_editor_handle = None;
|
||||
self.attr_editor_block.clear();
|
||||
self.attr_editor_fields.clear();
|
||||
self.attr_editor_rows.clear();
|
||||
self.attr_editor_selected = 0;
|
||||
self.attr_editor_tab = AttrTab::Attribute;
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
/// Commit every edited attribute (value, text options, common properties)
|
||||
/// back to the block, keeping the dialog open (Apply). Closing is the frame
|
||||
/// ✕ (`CloseModal`), which discards any un-applied edits — matching the
|
||||
/// other modal windows. Applied positionally so blocks with duplicate tags
|
||||
/// stay correct; guarded on block name + count so an edit can never land on
|
||||
/// a different entity if the selection changed under the open dialog.
|
||||
pub(super) fn on_attr_editor_apply(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let Some(handle) = self.attr_editor_handle.take() else {
|
||||
self.active_modal = None;
|
||||
let Some(handle) = self.attr_editor_handle else {
|
||||
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;
|
||||
// Snapshot the working copy so the document can be mutated while the
|
||||
// dialog's rows stay put (it remains open for further edits).
|
||||
let rows = self.attr_editor_rows.clone();
|
||||
let block = self.attr_editor_block.clone();
|
||||
|
||||
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 ins.block_name == block && rows.len() == ins.attributes.len() {
|
||||
for (attr, row) in ins.attributes.iter_mut().zip(rows.iter()) {
|
||||
changed |= apply_attr_row(attr, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1313,6 +1310,107 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the editor's working copy of one attribute. Angles are shown in
|
||||
/// degrees; numeric fields become strings so the user can type freely.
|
||||
fn attr_row_from_entity(
|
||||
a: &acadrust::entities::AttributeEntity,
|
||||
prompts: &rustc_hash::FxHashMap<String, String>,
|
||||
) -> AttrRow {
|
||||
let fmt = |v: f64| format!("{v}");
|
||||
AttrRow {
|
||||
tag: a.tag.clone(),
|
||||
prompt: prompts.get(&a.tag).cloned().unwrap_or_default(),
|
||||
value: a.get_value().to_string(),
|
||||
text_style: a.text_style.clone(),
|
||||
height: fmt(a.height),
|
||||
rotation: fmt(a.rotation.to_degrees()),
|
||||
width_factor: fmt(a.width_factor),
|
||||
oblique: fmt(a.oblique_angle.to_degrees()),
|
||||
h_align: a.horizontal_alignment,
|
||||
v_align: a.vertical_alignment,
|
||||
backwards: a.text_generation_flags & 0x2 != 0,
|
||||
upside_down: a.text_generation_flags & 0x4 != 0,
|
||||
layer: a.common.layer.clone(),
|
||||
color: a.common.color,
|
||||
linetype: a.common.linetype.clone(),
|
||||
line_weight: a.common.line_weight,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one edited row back onto its attribute; returns whether anything
|
||||
/// changed. Numeric fields are parsed (angles from degrees); an unparseable
|
||||
/// field is left as-is.
|
||||
fn apply_attr_row(a: &mut acadrust::entities::AttributeEntity, row: &AttrRow) -> bool {
|
||||
let mut ch = false;
|
||||
if a.get_value() != row.value {
|
||||
a.set_value(row.value.clone());
|
||||
ch = true;
|
||||
}
|
||||
if a.text_style != row.text_style {
|
||||
a.text_style = row.text_style.clone();
|
||||
ch = true;
|
||||
}
|
||||
if let Ok(h) = row.height.trim().parse::<f64>() {
|
||||
if a.height != h {
|
||||
a.height = h;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if let Ok(deg) = row.rotation.trim().parse::<f64>() {
|
||||
let rad = deg.to_radians();
|
||||
if (a.rotation - rad).abs() > 1e-12 {
|
||||
a.rotation = rad;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if let Ok(w) = row.width_factor.trim().parse::<f64>() {
|
||||
if a.width_factor != w {
|
||||
a.width_factor = w;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if let Ok(deg) = row.oblique.trim().parse::<f64>() {
|
||||
let rad = deg.to_radians();
|
||||
if (a.oblique_angle - rad).abs() > 1e-12 {
|
||||
a.oblique_angle = rad;
|
||||
ch = true;
|
||||
}
|
||||
}
|
||||
if a.horizontal_alignment != row.h_align {
|
||||
a.horizontal_alignment = row.h_align;
|
||||
ch = true;
|
||||
}
|
||||
if a.vertical_alignment != row.v_align {
|
||||
a.vertical_alignment = row.v_align;
|
||||
ch = true;
|
||||
}
|
||||
// Text generation flags: bit 0x2 = backwards, bit 0x4 = upside down.
|
||||
let new_flags = (a.text_generation_flags & !0x6)
|
||||
| if row.backwards { 0x2 } else { 0 }
|
||||
| if row.upside_down { 0x4 } else { 0 };
|
||||
if new_flags != a.text_generation_flags {
|
||||
a.text_generation_flags = new_flags;
|
||||
ch = true;
|
||||
}
|
||||
if a.common.layer != row.layer {
|
||||
a.common.layer = row.layer.clone();
|
||||
ch = true;
|
||||
}
|
||||
if a.common.color != row.color {
|
||||
a.common.color = row.color;
|
||||
ch = true;
|
||||
}
|
||||
if a.common.linetype != row.linetype {
|
||||
a.common.linetype = row.linetype.clone();
|
||||
ch = true;
|
||||
}
|
||||
if a.common.line_weight != row.line_weight {
|
||||
a.common.line_weight = row.line_weight;
|
||||
ch = true;
|
||||
}
|
||||
ch
|
||||
}
|
||||
|
||||
/// Map each attribute tag to the prompt text declared on the block's matching
|
||||
/// ATTDEF. Attribute instances (ATTRIB) carry only tag + value; the prompt is
|
||||
/// defined once on the block definition. Tags with no definition are absent.
|
||||
|
||||
+96
-5
@@ -108,7 +108,9 @@ impl OpenCADStudio {
|
||||
Some(AttributeEditor) => {
|
||||
self.attr_editor_handle = None;
|
||||
self.attr_editor_block.clear();
|
||||
self.attr_editor_fields.clear();
|
||||
self.attr_editor_rows.clear();
|
||||
self.attr_editor_selected = 0;
|
||||
self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2268,13 +2270,102 @@ impl OpenCADStudio {
|
||||
self.open_attribute_editor(handle);
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorInput { idx, value } => {
|
||||
if let Some(field) = self.attr_editor_fields.get_mut(idx) {
|
||||
field.2 = value;
|
||||
Message::AttrEditorTab(t) => {
|
||||
self.attr_editor_tab = t;
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorSelect(idx) => {
|
||||
if idx < self.attr_editor_rows.len() {
|
||||
self.attr_editor_selected = idx;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorOk => self.on_attr_editor_ok(),
|
||||
Message::AttrEditorInput { idx, value } => {
|
||||
if let Some(r) = self.attr_editor_rows.get_mut(idx) {
|
||||
r.value = value;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorTextStyle(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.text_style = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorJustify(label) => {
|
||||
if let Some((h, v)) =
|
||||
crate::ui::window::attribute_editor::justify_from_label(&label)
|
||||
{
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.h_align = h;
|
||||
r.v_align = v;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorHeight(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.height = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorRotation(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.rotation = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorWidth(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.width_factor = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorOblique(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.oblique = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorBackwards(b) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.backwards = b;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorUpsideDown(b) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.upside_down = b;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorLayer(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.layer = s;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorLinetype(s) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.linetype = if s == "ByLayer" { String::new() } else { s };
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorColor(label) => {
|
||||
if let Some(c) = crate::ui::window::attribute_editor::color_from_label(&label) {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.color = c;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorLineweight(lw) => {
|
||||
if let Some(r) = self.attr_row_selected_mut() {
|
||||
r.line_weight = lw;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorApply => self.on_attr_editor_apply(),
|
||||
Message::ModalGrab => {
|
||||
// Start a drag; the first ModalDragMove seeds the reference.
|
||||
self.modal_dragging = true;
|
||||
|
||||
+1
-1
@@ -1363,7 +1363,7 @@ impl OpenCADStudio {
|
||||
OverwriteWarning => (420, 180),
|
||||
SaveDialog => (560, 480),
|
||||
PointStyle => (360, 470),
|
||||
AttributeEditor => (460, 440),
|
||||
AttributeEditor => (640, 500),
|
||||
};
|
||||
Some((w as f32 + EXTRA_W, h as f32 + EXTRA_H))
|
||||
}
|
||||
|
||||
+30
-8
@@ -511,14 +511,36 @@ 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::AttributeEditor => {
|
||||
let doc = &self.tabs[self.active_tab].scene.document;
|
||||
let layers: Vec<String> = doc.layers.iter().map(|l| l.name.clone()).collect();
|
||||
let mut linetypes: Vec<String> = vec!["ByLayer".to_string()];
|
||||
linetypes.extend(
|
||||
doc.line_types
|
||||
.iter()
|
||||
.map(|lt| lt.name.clone())
|
||||
.filter(|n| !n.is_empty() && n != "ByLayer"),
|
||||
);
|
||||
let styles: Vec<String> = doc
|
||||
.text_styles
|
||||
.iter()
|
||||
.map(|s| s.name.trim().to_string())
|
||||
.filter(|n| !n.is_empty())
|
||||
.collect();
|
||||
sized(
|
||||
crate::ui::window::attribute_editor::view_window(
|
||||
&self.attr_editor_block,
|
||||
&self.attr_editor_rows,
|
||||
self.attr_editor_selected,
|
||||
self.attr_editor_tab,
|
||||
layers,
|
||||
linetypes,
|
||||
styles,
|
||||
),
|
||||
640,
|
||||
500,
|
||||
)
|
||||
}
|
||||
super::super::ModalKind::SaveDialog => sized(
|
||||
save_as_dialog_window(
|
||||
&self.save_dialog_filename,
|
||||
|
||||
Reference in new issue
Block a user