Browse / github.com/storytold/cadcraft / patch
feat(ui): an Attributes panel for the selected block reference
feat(ui): an Attributes panel for the selected block reference > **Stacked on #1213.** The first four commits of this branch (the Enhanced Attribute Editor, `cadcraft_engine::attributes()` and the stricter `attedit`) are #1213's, unchanged. They drop out of this PR's diff once #1213 is merged; what is reviewed here is everything after them. ## What Selecting one or more block references that have attributes opens an **Attributes** panel in the palette column, right after Layers. It closes when the selection changes; closing it by hand sticks until the selection changes, so it never fights the user. Each attribute is a tag / value row, edited in place a
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- lforgiarini (imported from pull request #2058, open)
- Base
1b6861698cc39c24e9a29eebfd487a0f99bbca95- Size
- +1183 −24 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b387c71af879a17b08f51ed2
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/blocks.rs crates/engine/src/cmd/blocks/attedit.rs crates/engine/src/cmd/blocks/refedit.rs crates/engine/src/lib.rs crates/engine/tests/it/attedit_prompts.rs crates/engine/tests/it/dynamic_block_data.rs crates/ui-egui/src/attedit.rs crates/ui-egui/src/context_menu.rs crates/ui-egui/src/dialogs.rs crates/ui-egui/src/docking.rs crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/palettes.rs crates/ui-egui/tests/attedit_dialog.rs docs/gaps.md docs/ui-parity.md
@@ -17,7 +17,7 @@ use super::{CommandSpec, bad, bool_or, f64_or, find_command, ok, point_param, po use super::has_doc; use crate::{Accept, EngineError, Input, Interactive, Prompt, Result, Session, Step}; -mod attedit;+pub(crate) mod attedit; mod burst; mod dyngrip; mod extract;@@ -19,7 +19,7 @@ pub(super) fn attedit_spec() -> CommandSpec { CommandSpec::new("attedit", "Single...", run) .menu(&["Modify", "Object", "Attribute", "Single..."]) .alias(&["ate", "eattedit"])- .params("{handle, values: {TAG: value} (tags in any case)} → {changed, unknownTags?}")+ .params("{handle | handles (every block reference gets the values), values: {TAG: value} (tags in any case; constant attributes are left alone)} → {changed, unknownTags?, constantTags?}") .interactive(|_| Ok(Box::new(AtteditM::default()))) } @@ -59,21 +59,114 @@ pub(super) fn with_unknown_tags(s: &Session, h: Handle, values: &Map<String, Val r } +/// `r` with `unknownTags`: the keys of `values` that none of the block references `hs` has an+/// attribute for (left out when there are none).+fn with_unknown_tags_of_all(s: &Session, hs: &[Handle], values: &Map<String, Value>, mut r: Value) -> Value {+ let Ok(d) = s.doc() else { return r };+ let known = |k: &str| {+ hs.iter()+ .any(|h| matches!(d.entity(*h).map(|e| &e.kind), Some(EntityKind::Insert(i)) if i.attribs.iter().any(|a| a.tag.eq_ignore_ascii_case(k))))+ };+ let unknown: Vec<&String> = values.keys().filter(|k| !known(k)).collect();+ if !unknown.is_empty()+ && let Some(o) = r.as_object_mut()+ {+ o.insert("unknownTags".into(), json!(unknown));+ }+ r+}++/// ATTEDIT on `handle` or `handles`: every block reference gets the values. The whole request is+/// checked before the first edit, so a refused one changes nothing: each target must be a block+/// reference on an unlocked layer and no value may be too long. Constant attributes are left alone+/// and named in `constantTags`. fn run(s: &mut Session, p: &Value) -> Result<Value> {- let h = targets(s, p)?.first().copied().ok_or_else(|| bad("attedit", "`handle` is required"))?;+ let hs = targets(s, p)?;+ if hs.is_empty() {+ return Err(bad("attedit", "`handle` or `handles` is required"));+ } let vals = p.get("values").and_then(Value::as_object).cloned().unwrap_or_default();+ let mut constant: Vec<String> = Vec::new();+ for h in &hs {+ let Some(entity) = s.doc()?.entity(*h) else {+ return Err(bad("attedit", format!("no object has handle {}", h.hex())));+ };+ let EntityKind::Insert(ins) = &entity.kind else {+ return Err(bad("attedit", format!("{} is not a block reference", h.hex())));+ };+ if super::super::curves::is_locked(s, *h) {+ return Err(bad("attedit", format!("the reference {} is on a locked layer", h.hex())));+ }+ if let Some(a) = ins.attribs.iter().find(|a| value_for(&vals, &a.tag).is_some_and(|v| v.len() > MAX_VALUE)) {+ return Err(bad("attedit", format!("the value for {} is longer than {MAX_VALUE} bytes", a.tag)));+ }+ for a in ins.attribs.iter().filter(|a| a.constant && value_for(&vals, &a.tag).is_some()) {+ if !constant.iter().any(|t| t.eq_ignore_ascii_case(&a.tag)) {+ constant.push(a.tag.clone());+ }+ }+ } let mut n = 0;- s.doc_mut()?.modify_entity(h, |e| {- if let EntityKind::Insert(i) = &mut e.kind {- for a in &mut i.attribs {- if let Some(v) = value_for(&vals, &a.tag) {- a.text.value = v;- n += 1;+ for h in &hs {+ s.doc_mut()?.modify_entity(*h, |e| {+ if let EntityKind::Insert(i) = &mut e.kind {+ for a in i.attribs.iter_mut().filter(|a| !a.constant) {+ if let Some(v) = value_for(&vals, &a.tag) {+ a.text.value = v;+ n += 1;+ } } }- }- })?;- Ok(with_unknown_tags(s, h, &vals, json!({ "changed": n })))+ })?;+ }+ let mut r = with_unknown_tags_of_all(s, &hs, &vals, json!({ "changed": n }));+ if !constant.is_empty()+ && let Some(o) = r.as_object_mut()+ {+ o.insert("constantTags".into(), json!(constant));+ }+ Ok(r)+}++/// One attribute of a block reference, as the Properties palette and the Edit Attributes dialog+/// list it.+#[derive(Clone, Debug, PartialEq)]+pub struct AttributeRow {+ pub tag: String,+ /// The definition's prompt (the tag when it has none).+ pub prompt: String,+ pub value: String,+ /// Not constant: the value can be changed.+ pub editable: bool,+ pub invisible: bool,+ /// A multiline attribute: its value may hold line breaks.+ pub multiline: bool,+}++/// The attributes of block reference `h`, in order (empty for other objects). The prompt comes+/// from the attribute, else from its definition in the reference's block.+pub fn attributes(d: &cadcraft_doc::Drawing, h: Handle) -> Vec<AttributeRow> {+ let Some(EntityKind::Insert(ins)) = d.entity(h).map(|e| &e.kind) else { return Vec::new() };+ let block = d.block(&ins.block);+ let def_prompt = |tag: &str| {+ block.and_then(|b| {+ b.entities.iter().find_map(|e| match &e.kind {+ EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(tag) && !ad.prompt.is_empty() => Some(ad.prompt.clone()),+ _ => None,+ })+ })+ };+ ins.attribs+ .iter()+ .map(|a| AttributeRow {+ tag: a.tag.clone(),+ prompt: if a.prompt.is_empty() { def_prompt(&a.tag).unwrap_or_else(|| a.tag.clone()) } else { a.prompt.clone() },+ value: a.text.value.clone(),+ editable: !a.constant,+ invisible: a.invisible,+ multiline: a.multiline.is_some(),+ })+ .collect() } /// A block name, tag or value specification: comma-separated wildcard patterns, any case. For@@ -231,6 +324,14 @@ impl Interactive for AtteditM { fn name(&self) -> &'static str { "ATTEDIT" }+ /// A selected block reference is taken at once (Edit Attributes from the shortcut menu).+ fn begin(&mut self, s: &mut Session) -> Result<Step> {+ let pre = s.selection();+ if s.settings.pickfirst && !pre.is_empty() && self.pick(s, &pre).is_ok() {+ s.set_selection(Vec::new());+ }+ Ok(Step::Continue)+ } fn prompt(&self, _s: &Session) -> Prompt { match self.attribs.get(self.values.len()) { Some((_, tag, cur)) if self.block.is_some() => Prompt::new(format!("Enter value for {tag}"), Accept::TEXT).default(cur.clone()),@@ -119,6 +119,16 @@ fn run_refedit(s: &mut Session, p: &Value) -> Result<Value> { ), )); }+ // A dynamic block reference shows an anonymous copy (`*U12`) made for its values.+ if let Some(r) = cadcraft_doc::dynblock::reference(d, ih) {+ return Err(bad(+ "refedit",+ format!(+ "\"{}\" is a dynamic block: edit it in the Block Editor (BEDIT), or its attributes with Edit Attributes (ATTEDIT); editing dynamic blocks in place is not available yet",+ r.definition+ ),+ ));+ } if blk.name.starts_with('*') || blk.array.is_some() { return Err(bad("refedit", format!("\"{}\" is generated geometry and can't be edited in place", blk.name))); }@@ -45,6 +45,7 @@ use serde_json::Value; pub use cadcraft_doc as doc; pub use cadcraft_geom as geom; pub use cadcraft_render as render;+pub use cmd::blocks::attedit::{AttributeRow, attributes}; pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};@@ -109,3 +109,103 @@ fn attribute_tags_match_in_any_case_and_unknown_tags_are_reported() { let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "four"}})).unwrap(); assert_eq!(r, json!({"changed": 1})); }++/// ATTEDIT with several handles writes the values into each reference, reports the tags none of them has,+/// is one undo step, and refuses an empty list.+#[test]+fn attedit_takes_a_selected_block_reference_at_once() {+ let mut s = with_block();+ let h = handle(&insert(&mut s, 100.0, json!({})));+ s.set_selection(vec![h]);+ s.cmdline("attedit").unwrap();+ assert!(s.prompt_text().contains("Enter value for T <one>"), "{}", s.prompt_text());+ type_lines(&mut s, &["picked", ""]);+ assert!(s.running.is_none(), "{}", s.prompt_text());+ assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("picked".into(), "u".into()));+}++#[test]+fn attedit_leaves_constant_attributes_and_locked_references_alone() {+ let mut s = with_block();+ let h = handle(&insert(&mut s, 100.0, json!({})));+ s.doc_mut()+ .unwrap()+ .modify_entity(h, |e| {+ if let EntityKind::Insert(i) = &mut e.kind {+ i.attribs[1].constant = true;+ }+ })+ .unwrap();+ let rows = cadcraft_engine::attributes(s.doc().unwrap(), h);+ assert_eq!(rows.iter().map(|r| (r.tag.as_str(), r.editable)).collect::<Vec<_>>(), [("T", true), ("U", false)]);++ let r = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "t2", "U": "u2"}})).unwrap();+ assert_eq!((r["changed"].clone(), r["constantTags"].clone()), (json!(1), json!(["U"])));+ assert_eq!((value(&s, h, "T"), value(&s, h, "U")), ("t2".into(), "u".into()));+ // One undo step.+ s.undo().unwrap();+ assert_eq!(value(&s, h, "T"), "one");++ // Not a block reference, a value too long, a locked layer.+ let line = s.execute("line", &json!({"points": [[0, 50], [10, 50]]})).unwrap()["handles"][0].clone();+ assert!(s.execute("attedit", &json!({"handle": line, "values": {"T": "x"}})).is_err());+ assert!(s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "x".repeat(70_000)}})).is_err());+ s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();+ let e = s.execute("attedit", &json!({"handle": h.hex(), "values": {"T": "x"}})).unwrap_err().to_string();+ assert!(e.contains("locked"), "{e}");+ assert_eq!(value(&s, h, "T"), "one");+}++#[test]+fn attedit_with_several_handles_edits_each_in_one_undo_step() {+ let mut s = with_block();+ let (a, b) = (handle(&insert(&mut s, 100.0, json!({"t": "one"}))), handle(&insert(&mut s, 200.0, json!({"t": "two"}))));+ let r = s.execute("attedit", &json!({"handles": [a.hex(), b.hex()], "values": {"T": "same", "nope": "x"}})).unwrap();+ assert_eq!(r["changed"], 2);+ assert_eq!(r["unknownTags"], json!(["nope"]));+ assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("same".into(), "same".into()));+ s.execute("undo", &json!({})).unwrap();+ assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("one".into(), "two".into()), "one undo restores both");+ assert!(s.execute("attedit", &json!({"handles": [], "values": {"T": "x"}})).is_err(), "no targets is an error");+}++/// A handle no object has is refused before anything is edited, even when it comes after valid ones.+#[test]+fn attedit_with_a_missing_handle_changes_nothing() {+ let mut s = with_block();+ let a = handle(&insert(&mut s, 100.0, json!({"t": "one"})));+ let undo_before = s.state().unwrap().undo.len();+ let r = s.execute("attedit", &json!({"handles": [a.hex(), "FFFFF"], "values": {"T": "changed"}}));+ assert!(r.is_err(), "a missing handle is an error: {r:?}");+ assert_eq!(value(&s, a, "T"), "one", "the valid reference was not edited");+ assert_eq!(s.state().unwrap().undo.len(), undo_before, "no undo step for a refused request");+}++/// With several handles the rules hold for every target: a constant attribute is named once and left+/// alone, and one target on a locked layer refuses the whole request before anything changes.+#[test]+fn attedit_with_several_handles_keeps_the_rules_for_every_target() {+ let mut s = with_block();+ let a = handle(&insert(&mut s, 100.0, json!({"t": "one"})));+ s.execute("layer.new", &json!({"name": "L2", "current": true})).unwrap();+ let b = handle(&insert(&mut s, 200.0, json!({"t": "two"})));+ for h in [a, b] {+ s.doc_mut()+ .unwrap()+ .modify_entity(h, |e| {+ if let EntityKind::Insert(i) = &mut e.kind {+ i.attribs[1].constant = true;+ }+ })+ .unwrap();+ }+ let r = s.execute("attedit", &json!({"handles": [a.hex(), b.hex()], "values": {"T": "same", "U": "x"}})).unwrap();+ assert_eq!((r["changed"].clone(), r["constantTags"].clone()), (json!(2), json!(["U"])));+ assert_eq!((value(&s, a, "U"), value(&s, b, "U")), ("u".into(), "u".into()), "constant attributes keep their value");++ // `a` is on layer 0, locked: `b` is not, and still is not edited.+ s.execute("layer.set", &json!({"name": "0", "locked": true})).unwrap();+ let e = s.execute("attedit", &json!({"handles": [b.hex(), a.hex()], "values": {"T": "later"}})).unwrap_err().to_string();+ assert!(e.contains("locked"), "{e}");+ assert_eq!((value(&s, a, "T"), value(&s, b, "T")), ("same".into(), "same".into()));+}@@ -14,10 +14,19 @@ fn obj(handle: u64, kind: &str, tags: &[(i32, &str)]) -> ForeignObject { /// A session whose block "Door" has a visibility parameter with two states. fn session() -> Session {+ session_with(false)+}++/// [`session`], with an attribute NO (default "1") in Door when `attribute`.+fn session_with(attribute: bool) -> Session { let mut s = Session::new();- let l = s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone();- s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": [l] })).unwrap();- let line = s.doc().unwrap().block("Door").unwrap().entities.handles()[0].hex();+ let mut hs = vec![s.execute("line", &json!({ "points": [[0, 0], [1, 0]] })).unwrap()["handles"][0].clone()];+ if attribute {+ hs.push(s.execute("attdef", &json!({ "tag": "NO", "prompt": "Door number", "default": "1", "at": [0, 1] })).unwrap()["handle"].clone());+ }+ s.execute("block", &json!({ "name": "Door", "base": [0, 0], "handles": hs })).unwrap();+ let b = s.doc().unwrap().block("Door").unwrap().clone();+ let line = b.entities.iter().find(|e| matches!(e.kind, cadcraft_engine::doc::EntityKind::Line(_))).unwrap().handle.hex(); let mut f = Foreign::default(); f.records.insert(("BLOCK_RECORD".into(), "DOOR".into()), ForeignExtra { xdict: Some(Handle(0xF00)), ..Default::default() }); f.objects.push(obj(0xF00, "DICTIONARY", &[(100, "AcDbDictionary"), (3, "ACAD_ENHANCEDBLOCK"), (360, "F01")]));@@ -138,3 +147,30 @@ fn a_visibility_state_is_changed_in_one_undo_step() { assert!(s.execute("blocks.reference.set", &p).is_err(), "{p}"); } }++#[test]+fn a_dynamic_block_references_attributes_are_edited_and_refedit_says_why_not() {+ let mut s = session_with(true);+ let insert = s.doc().unwrap().model.handles()[0];+ let value = |s: &Session| match s.doc().unwrap().entity(insert).map(|e| e.kind.clone()) {+ Some(cadcraft_engine::doc::EntityKind::Insert(i)) => (i.block, i.attribs.iter().map(|a| a.text.value.clone()).collect::<Vec<_>>()),+ other => panic!("{other:?}"),+ };+ assert_eq!(value(&s).1, ["1"]);+ // A visibility state change makes the reference show an anonymous copy (*U).+ s.execute("blocks.reference.set", &json!({ "handle": insert.hex(), "name": "Visibility1", "value": "Closed" })).unwrap();+ assert!(value(&s).0.starts_with("*U"));+ let rows = cadcraft_engine::attributes(s.doc().unwrap(), insert);+ assert_eq!((rows[0].tag.as_str(), rows[0].prompt.as_str(), rows[0].editable), ("NO", "Door number", true));++ let r = s.execute("attedit", &json!({ "handle": insert.hex(), "values": { "no": "12" } })).unwrap();+ assert_eq!(r["changed"], 1);+ assert_eq!(value(&s).1, ["12"]);+ // The value stays through another change of state.+ s.execute("blocks.reference.set", &json!({ "handle": insert.hex(), "name": "Visibility1", "value": "Open" })).unwrap();+ assert_eq!(value(&s).1, ["12"]);++ // REFEDIT names the dynamic block and where to edit it instead of "*U… is generated geometry".+ let e = s.execute("refedit", &json!({ "handles": [insert.hex()] })).unwrap_err().to_string();+ assert!(e.contains("\"Door\" is a dynamic block") && e.contains("BEDIT"), "{e}");+}new file mode 100644@@ -0,0 +1,250 @@+//! The Edit Attributes dialog (Enhanced Attribute Editor): ATTEDIT / EATTEDIT typed or started+//! from a menu while one block reference with attributes is selected. It lists each attribute's+//! tag, prompt and value; Apply and OK run `attedit {handle, values}` with the values changed (one+//! undo step each). With nothing suitable selected ATTEDIT asks at the command line instead, and+//! JSON calls never open the dialog.++use cadcraft_engine::doc::{Drawing, EntityKind, Handle};+use egui::{RichText, vec2};+use serde_json::{Map, Value, json};++use crate::CadApp;+use crate::theme::Tokens;++/// One attribute row: what the drawing had when the dialog opened, and what is typed now.+#[derive(Clone, Debug)]+pub struct Row {+ pub tag: String,+ pub prompt: String,+ pub value: String,+ pub editable: bool,+ pub invisible: bool,+ pub multiline: bool,+ pub typed: String,+}++/// The dialog's state while it is open.+#[derive(Default)]+pub struct State {+ /// The block reference edited.+ pub handle: Option<Handle>,+ /// The drawing it is in (`DocState::uid`): handles repeat across drawings, and the dialog+ /// doesn't stop another drawing's tab from being chosen.+ pub doc: Option<u64>,+ /// Its block's name (the dynamic block's for a dynamic block reference).+ pub block: String,+ pub rows: Vec<Row>,+ /// The row that is current: shown in bold, and its value box takes the focus when the+ /// dialog opens (the attribute double-clicked).+ pub current: Option<usize>,+ /// The current row's value box still has to take the focus.+ pub focus: bool,+ /// Why the last Apply failed.+ pub error: Option<String>,+}++/// The block reference the dialog edits: the selection when it is exactly one block reference+/// with an attribute that can be changed.+fn selected_reference(app: &CadApp) -> Option<Handle> {+ let st = app.session.state().ok()?;+ let [h] = st.selection.as_slice() else { return None };+ matches!(st.doc.entity(*h).map(|e| &e.kind), Some(EntityKind::Insert(_))).then_some(*h)?;+ cadcraft_engine::attributes(&st.doc, *h).iter().any(|a| a.editable).then_some(*h)+}++/// The name shown for block reference `h`: its dynamic block's, not its anonymous copy's.+fn block_name(d: &Drawing, h: Handle) -> String {+ if let Some(r) = cadcraft_engine::doc::dynblock::reference(d, h) {+ return r.definition;+ }+ match d.entity(h).map(|e| &e.kind) {+ Some(EntityKind::Insert(i)) => i.block.clone(),+ _ => String::new(),+ }+}++/// Load the rows of block reference `h`.+fn load(app: &mut CadApp, h: Handle) {+ let Ok(st) = app.session.state() else { return };+ let rows = cadcraft_engine::attributes(&st.doc, h)+ .into_iter()+ .map(|a| Row {+ typed: a.value.clone(),+ tag: a.tag,+ prompt: a.prompt,+ value: a.value,+ editable: a.editable,+ invisible: a.invisible,+ multiline: a.multiline,+ })+ .collect();+ let current = app.attedit.current;+ app.attedit = State { handle: Some(h), doc: Some(st.uid), block: block_name(&st.doc, h), rows, current, focus: false, error: None };+}++/// Open the dialog on block reference `h` of the active drawing, with attribute `current` (an+/// index into its attributes, in `cadcraft_engine::attributes` order) current; without one, or+/// when that attribute can't be changed, the first one that can. False, and nothing opens, when+/// `h` is not a block reference with an attribute that can be changed.+pub fn open(app: &mut CadApp, h: Handle, current: Option<usize>) -> bool {+ let Ok(st) = app.session.state() else { return false };+ if !matches!(st.doc.entity(h).map(|e| &e.kind), Some(EntityKind::Insert(_))) {+ return false;+ }+ let attribs = cadcraft_engine::attributes(&st.doc, h);+ let Some(first) = attribs.iter().position(|a| a.editable) else { return false };+ let current = current.filter(|i| attribs.get(*i).is_some_and(|a| a.editable)).unwrap_or(first);+ app.attedit.current = Some(current);+ load(app, h);+ app.attedit.focus = true;+ app.ui.dialog = Some("attedit".into());+ true+}++/// The block reference the dialog edits, while the drawing it was opened for is the active one+/// and still has it.+fn edited(app: &CadApp) -> Option<Handle> {+ let h = app.attedit.handle?;+ let st = app.session.state().ok()?;+ (Some(st.uid) == app.attedit.doc && st.doc.entity(h).is_some()).then_some(h)+}++/// ATTEDIT / EATTEDIT typed or from a menu without parameters opens the dialog when one block+/// reference with attributes is selected; otherwise the command runs at the command line.+pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value, String>> {+ if !matches!(id, "attedit" | "ate" | "eattedit") || !params.is_null() {+ return None;+ }+ let h = selected_reference(app)?;+ app.session.echo(format!("Command: {}", id.to_ascii_uppercase()));+ open(app, h, None).then_some(Ok(Value::Null))+}++/// The values typed that differ from the drawing's, as `attedit`'s `values`.+fn changed(rows: &[Row]) -> Map<String, Value> {+ rows.iter().filter(|r| r.editable && r.typed != r.value).map(|r| (r.tag.clone(), Value::String(r.typed.clone()))).collect()+}++/// Apply: run `attedit` with the changed values; true when it went through (or nothing changed).+pub fn apply(app: &mut CadApp) -> bool {+ let Some(h) = edited(app) else { return false };+ let values = changed(&app.attedit.rows);+ if values.is_empty() {+ return true;+ }+ match app.run("attedit", json!({ "handle": h.hex(), "values": values })) {+ Ok(_) => {+ load(app, h);+ true+ }+ Err(e) => {+ app.attedit.error = Some(e);+ false+ }+ }+}++pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+ // The reference went away (undo, erase from the command line) or another drawing is active:+ // nothing to edit.+ if edited(app).is_none() {+ *open = false;+ return;+ }+ let t = Tokens::get();+ let mut choice: Option<&str> = None;+ let st = &mut app.attedit;+ egui::Window::new(crate::tl!("Enhanced Attribute Editor"))+ .id(egui::Id::unique("Enhanced Attribute Editor"))+ .open(open)+ .collapsible(false)+ .resizable(true)+ .default_width(460.0)+ .pivot(egui::Align2::CENTER_CENTER)+ .default_pos(ctx.content_rect().center())+ .show(ctx, |ui| {+ ui.horizontal(|ui| {+ ui.label(RichText::new(crate::tl!("Block:")).color(t.text_dim));+ ui.label(&st.block);+ });+ ui.add_space(6.0);+ egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {+ egui::Grid::new("attedit-rows").num_columns(3).striped(true).spacing(vec2(12.0, 4.0)).show(ui, |ui| {+ ui.label(RichText::new(crate::tl!("Tag")).color(t.text_dim));+ ui.label(RichText::new(crate::tl!("Prompt")).color(t.text_dim));+ ui.label(RichText::new(crate::tl!("Value")).color(t.text_dim));+ ui.end_row();+ for (i, r) in st.rows.iter_mut().enumerate() {+ let current = st.current == Some(i);+ let tag = if r.invisible { format!("{} ({})", r.tag, crate::i18n::t("invisible")) } else { r.tag.clone() };+ ui.label(if current { RichText::new(tag).strong() } else { RichText::new(tag) });+ ui.label(&r.prompt);+ let edit = if r.multiline {+ egui::TextEdit::multiline(&mut r.typed).desired_rows(2)+ } else {+ egui::TextEdit::singleline(&mut r.typed)+ };+ let resp = ui.add_enabled(r.editable, edit.desired_width(220.0));+ if resp.has_focus() {+ st.current = Some(i);+ }+ if current && st.focus {+ resp.request_focus();+ resp.scroll_to_me(Some(egui::Align::Center));+ st.focus = false;+ }+ if !r.editable {+ let _ = resp.on_disabled_hover_text(crate::tl!("Constant"));+ }+ ui.end_row();+ }+ });+ });+ if let Some(e) = &st.error {+ ui.add_space(4.0);+ ui.colored_label(t.warn, e);+ }+ ui.add_space(8.0);+ ui.horizontal(|ui| {+ let dirty = !changed(&st.rows).is_empty();+ if ui.button(crate::tl!("OK")).clicked() {+ choice = Some("ok");+ }+ if ui.button(crate::tl!("Cancel")).clicked() {+ choice = Some("cancel");+ }+ if ui.add_enabled(dirty, egui::Button::new(crate::tl!("Apply"))).clicked() {+ choice = Some("apply");+ }+ });+ });+ match choice {+ Some("ok") => {+ if apply(app) {+ *open = false;+ }+ }+ Some("apply") => {+ apply(app);+ }+ Some(_) => *open = false,+ None => {}+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ fn row(tag: &str, value: &str, typed: &str, editable: bool) -> Row {+ Row { tag: tag.into(), prompt: String::new(), value: value.into(), editable, invisible: false, multiline: false, typed: typed.into() }+ }++ #[test]+ fn only_changed_editable_values_are_sent() {+ let rows = [row("A", "1", "1", true), row("B", "2", "3", true), row("C", "x", "y", false)];+ let v = changed(&rows);+ assert_eq!(v.len(), 1);+ assert_eq!(v.get("B"), Some(&json!("3")));+ }+}@@ -137,7 +137,7 @@ fn object_commands(app: &CadApp) -> Vec<(&'static str, Option<&'static str>)> { EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => vec![("textedit", None)], EntityKind::Insert(_) => { let mut v = vec![("bedit", None), ("refedit", None)];- if ents.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if !i.attribs.is_empty())) {+ if ents.iter().any(|e| matches!(&e.kind, EntityKind::Insert(i) if i.attribs.iter().any(|a| !a.constant))) { v.push(("attedit", Some("Edit Attributes..."))); } v@@ -27,6 +27,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), "blocks" => blocks(app, ctx, &mut open),+ "attedit" => crate::attedit::dialog(app, ctx, &mut open), "bedit" => crate::bedit::dialog(app, ctx, &mut open), "authoring" => crate::bedit::authoring_dialog(app, ctx, &mut open), "stylesmanager" => crate::plotstyles::dialog(app, ctx, &mut open),@@ -19,6 +19,8 @@ pub enum Panel { Properties, #[serde(rename = "externalReferences")] ExternalRefs,+ /// The selected block reference's attributes, opened when one is selected.+ Attributes, } impl Panel {@@ -31,6 +33,7 @@ impl Panel { Self::Layers => "layers", Self::Properties => "properties", Self::ExternalRefs => "externalReferences",+ Self::Attributes => "attributes", } } fn label(self) -> &'static str {@@ -41,10 +44,12 @@ impl Panel { Self::Layers => "Layers", Self::Properties => "Properties", Self::ExternalRefs => "External References",+ Self::Attributes => "Attributes", } }- /// Palettes outside the default arrangement, opened on request (QUICKCALC).- pub const ON_DEMAND: &[Self] = &[Self::QuickCalc];+ /// Palettes outside the default arrangement, opened on request (QUICKCALC) or by the selection+ /// (the Attributes panel, when a block reference with attributes is selected).+ pub const ON_DEMAND: &[Self] = &[Self::QuickCalc, Self::Attributes]; } /// The drawing view never moves, floats, closes or takes tabs; palettes dock beside it.@@ -213,6 +218,26 @@ pub fn command(app: &mut crate::CadApp, reset: bool, params: &Value) -> Result<V Ok(json!({"docking": app.ui.docking})) } +/// Opens the Attributes panel when a block reference with attributes becomes the selection and+/// closes it when the selection stops being one. Closing it by hand sticks until the selection+/// changes, so it never fights the user. The target is the drawing and the handle, so a block with+/// the same handle in another drawing counts as a new selection. A panel restored open with no+/// target to show is closed.+fn follow_selection(workspace: &mut Workspace, app: &crate::CadApp, ui: &egui::Ui) {+ let id = egui::Id::unique("attributes-panel-target");+ let target = if app.ui.show_palettes { crate::palettes::attributes_key(app) } else { None };+ let last = ui.data(|d| d.get_temp::<Option<(u64, Vec<cadcraft_engine::doc::Handle>)>>(id)).flatten();+ if target == last {+ if target.is_none() && workspace.layout.contains(&Panel::Attributes) {+ let _ = set_visible(workspace, Panel::Attributes, false);+ }+ return;+ }+ let shown = target.is_some();+ ui.data_mut(|d| d.insert_temp(id, target));+ let _ = set_visible(workspace, Panel::Attributes, shown);+}+ /// Validate and repair the stored workspace, sync it with the app, and render the dock panels; a failed restore falls back to the default layout with a status message. pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get();@@ -222,6 +247,7 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { workspace = default_workspace(); let _ = sync(&mut workspace, app); }+ follow_selection(&mut workspace, app, ui); // Bodies can dispatch commands (including the drawing's typed command line). // Keep the canonical workspace accessible while rendering an immutable snapshot. app.ui.docking = workspace.clone();@@ -263,6 +289,12 @@ pub fn show(app: &mut crate::CadApp, ui: &mut egui::Ui) { crate::shortcut_menus::palette(app, ui, crate::palettes::properties_section, crate::shortcut_menus::properties_entries) }); }+ Panel::Attributes => {+ egui::ScrollArea::vertical()+ .id_salt("dock-attributes")+ .auto_shrink([false, false])+ .show(ui, |ui| crate::palettes::attributes_section(app, ui));+ } Panel::ExternalRefs => { egui::ScrollArea::both().id_salt("dock-xrefs").auto_shrink([false, false]).show(ui, |ui| crate::xrefs::section(app, ui)); }@@ -295,7 +327,75 @@ fn panel_limits(panel: &Panel) -> PanelLimits { Panel::Canvas => (egui::vec2(300.0, 180.0), 1_000_000.0), Panel::QuickCalc => (egui::vec2(240.0, 160.0), 600.0), Panel::ToolSets => (egui::vec2(220.0, 150.0), 480.0),- Panel::Layers | Panel::Properties | Panel::ExternalRefs => (egui::vec2(220.0, 100.0), 720.0),+ Panel::Layers | Panel::Properties | Panel::ExternalRefs | Panel::Attributes => (egui::vec2(220.0, 100.0), 720.0), }; PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } }++#[cfg(test)]+mod tests {+ use super::*;+ use crate::{CadApp, Services};+ use cadcraft_engine::Session;++ /// One frame of the selection tracking, as `show` runs it.+ fn frame(app: &mut CadApp, ctx: &egui::Context, workspace: &mut Workspace) {+ let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() };+ let mut output = ctx.run_ui(input, |ui| follow_selection(workspace, app, ui));+ output.textures_delta.clear();+ }++ /// A drawing with one block reference that has an attribute; returns its handle.+ fn app_with_block_reference() -> (CadApp, String) {+ let mut app = CadApp::new(Session::default(), Services::default());+ app.run("attdef", json!({ "tag": "REV", "default": "A", "at": [0, 1] })).unwrap();+ let line = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();+ let mut handles: Vec<String> = app.session.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();+ handles.push(line["handles"][0].as_str().unwrap_or_default().to_string());+ handles.sort();+ handles.dedup();+ app.run("block", json!({ "name": "B", "base": [0, 0], "handles": handles, "keep": "delete" })).unwrap();+ let ins = app.run("insert", json!({ "name": "B", "at": [5, 0] })).unwrap();+ let handle = ins["handle"].as_str().unwrap().to_string();+ (app, handle)+ }++ /// The panel opens with the selection, stays closed once closed by hand, and closes with it.+ #[test]+ fn attributes_panel_follows_the_selection_and_respects_a_manual_close() {+ let (mut app, handle) = app_with_block_reference();+ let (ctx, mut workspace) = (egui::Context::default(), default_workspace());++ app.run("select", json!({ "handles": [handle.clone()] })).unwrap();+ frame(&mut app, &ctx, &mut workspace);+ assert!(workspace.layout.contains(&Panel::Attributes), "selecting the block opens the panel");++ // Closed by hand: it stays closed while the same block stays selected.+ set_visible(&mut workspace, Panel::Attributes, false).unwrap();+ frame(&mut app, &ctx, &mut workspace);+ assert!(!workspace.layout.contains(&Panel::Attributes));++ // Another selection and back: it opens again.+ app.run("select", json!({ "handles": [] })).unwrap();+ frame(&mut app, &ctx, &mut workspace);+ app.run("select", json!({ "handles": [handle] })).unwrap();+ frame(&mut app, &ctx, &mut workspace);+ assert!(workspace.layout.contains(&Panel::Attributes));++ // Nothing selected: it closes.+ app.run("select", json!({ "handles": [] })).unwrap();+ frame(&mut app, &ctx, &mut workspace);+ assert!(!workspace.layout.contains(&Panel::Attributes));+ }++ /// A panel restored open with nothing to show is closed.+ #[test]+ fn a_restored_attributes_panel_with_nothing_to_show_is_closed() {+ let (mut app, _) = app_with_block_reference();+ let (ctx, mut workspace) = (egui::Context::default(), default_workspace());+ reveal_panel(&mut workspace, Panel::Attributes).unwrap();+ assert!(workspace.layout.contains(&Panel::Attributes));+ frame(&mut app, &ctx, &mut workspace);+ assert!(!workspace.layout.contains(&Panel::Attributes));+ }+}@@ -81,6 +81,9 @@ menu Modify Edita Array Serie Attach Associa Attribute Attributo+ Attributes Attributi+ Select a block reference that has attributes. Seleziona un riferimento di blocco con attributi.+ Tag used twice in this block Tag usato due volte in questo blocco Attribute Definition Definizione di attributo Attribute Display Visualizzazione attributi Audit Controlla@@ -1371,3 +1371,5 @@ Check for Updates… Перевірити оновлення… CADCraft is up to date. Встановлено найновішу версію CADCraft. Could not check for updates. Не вдалося перевірити оновлення.+ Constant Константа+ Multiline: edit it with Edit Attributes Багаторядковий: змінюйте в «Редагувати атрибути»@@ -10,6 +10,7 @@ pub mod aliasdlg; pub mod animation;+pub mod attedit; pub mod autosave; pub mod bedit; pub mod canvas;@@ -274,6 +275,8 @@ pub struct CadApp { pub alias_dlg: Option<aliasdlg::AliasDlgState>, /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). pub hatch_dlg: Option<hatchdlg::HatchDlgState>,+ /// The Edit Attributes dialog ([`attedit`]).+ pub attedit: attedit::State, /// The Edit Block Definition dialog ([`bedit`]). pub bedit: bedit::State, /// The startup update check and its prompt (desktop; [`updates_ui`]).@@ -323,6 +326,7 @@ impl CadApp { gpu_notice: None, alias_dlg: None, hatch_dlg: None,+ attedit: attedit::State::default(), bedit: bedit::State::default(), #[cfg(not(target_arch = "wasm32"))] updates_rt: updates_ui::Runtime::default(),@@ -92,6 +92,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re if let Some(r) = crate::xrefs::route(app, id, params) { return Some(r); }+ if let Some(r) = crate::attedit::route(app, id, params) {+ return Some(r);+ } if let Some(r) = crate::bedit::route(app, id, params) { return Some(r); }@@ -518,9 +518,14 @@ pub(crate) fn layers_section(app: &mut CadApp, ui: &mut egui::Ui) { /// Properties row: a right-aligned translated label in a 106-point layout box that exposes a Label node to accessibility and names the control added by `add`. fn prop_row(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) {+ prop_row_literal(ui, crate::i18n::t(label), add);+}++/// A property row whose label comes from the drawing (an attribute tag): shown as it is.+fn prop_row_literal(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui)) { let t = Tokens::get(); ui.horizontal(|ui| {- let mut job = egui::text::LayoutJob::simple(crate::i18n::t(label).to_string(), crate::theme::body(), t.text_dim, 106.0);+ let mut job = egui::text::LayoutJob::simple(label.to_string(), crate::theme::body(), t.text_dim, 106.0); job.halign = egui::Align::RIGHT; let galley = ui.painter().layout_job(job); let (lr, label_response) = ui.allocate_exact_size(vec2(118.0, galley.size().y.max(22.0)), Sense::hover());@@ -627,7 +632,12 @@ fn custom_properties(ui: &egui::Ui, app: &CadApp, h: cadcraft_engine::doc::Handl /// A greyed value computed from the geometry. fn read_only(ui: &mut egui::Ui, text: &str) {- value_box(ui, crate::i18n::t(text), false, false).on_hover_text(crate::tl!("Read-only"));+ read_only_literal(ui, crate::i18n::t(text));+}++/// A greyed value that comes from the drawing (a block name, an attribute value): shown as it is.+fn read_only_literal(ui: &mut egui::Ui, text: &str) {+ value_box(ui, text, false, false).on_hover_text(crate::tl!("Read-only")); } /// The Properties header: All/My, the selection-type filter and the PICKADD / Select Objects /@@ -883,6 +893,7 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { let ids: Vec<String> = sel.iter().map(|h| h.hex()).collect(); let mut set: Option<Value> = None; let mut custom_set: Option<Value> = None;+ let mut att_set: Option<Value> = None; let group = |ui: &mut egui::Ui, title: &str| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover()); ui.painter().rect_filled(r.shrink2(vec2(6.0, 1.0)), 2.0, t.chrome_dark);@@ -1140,6 +1151,26 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { }); } }+ let atts = cadcraft_engine::attributes(d, e.handle);+ if !atts.is_empty() {+ group(ui, "Attributes");+ for a in &atts {+ prop_row(ui, &a.tag, |ui| {+ // A value with line breaks is edited in the Edit Attributes dialog;+ // a single-line field would join its lines.+ if !a.editable || a.value.contains('\n') {+ let r = value_box(ui, &a.value.replace('\n', " "), false, false);+ r.on_hover_text(if a.editable {+ crate::tl!("Multiline: edit it with Edit Attributes")+ } else {+ crate::tl!("Constant")+ });+ } else if let Some(v) = edit_field(ui, ui.scope_id().with(("attval", e.handle, &a.tag)), a.value.clone()) {+ att_set = Some(json!({ "handle": e.handle.hex(), "values": { a.tag.clone(): v } }));+ }+ });+ }+ } } _ => {} }@@ -1158,6 +1189,165 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) { if let Some(p) = custom_set { let _ = app.run("blocks.reference.set", p); }+ if let Some(p) = att_set {+ let _ = app.run("attedit", p);+ }+}++/// What the Attributes panel shows for attributes that differ between the selected block+/// references, as the Properties palette does for any property.+const VARIES: &str = "*VARIES*";++/// The block references the Attributes panel edits: the selected objects, when every one of them+/// is a block reference that has attributes.+pub(crate) fn attributes_targets(app: &CadApp) -> Vec<cadcraft_engine::doc::Handle> {+ let sel = app.session.selection();+ let Ok(d) = app.session.doc() else { return Vec::new() };+ if sel.iter().all(|h| !cadcraft_engine::attributes(d, *h).is_empty()) { sel } else { Vec::new() }+}++/// The Attributes panel's targets with the drawing they belong to (its uid), so the same handles in+/// another open drawing are told apart.+pub(crate) fn attributes_key(app: &CadApp) -> Option<(u64, Vec<cadcraft_engine::doc::Handle>)> {+ let hs = attributes_targets(app);+ if hs.is_empty() {+ return None;+ }+ Some((app.session.state().ok()?.uid, hs))+}++/// Set attribute `tag` of the block references `hs` to `value` through ATTEDIT, which changes only+/// the attributes of those references, in one undo step like any other edit.+fn set_attribute(app: &mut CadApp, hs: &[cadcraft_engine::doc::Handle], tag: &str, value: String) {+ let handles: Vec<String> = hs.iter().map(|h| h.hex()).collect();+ let _ = app.run("attedit", json!({ "handles": handles, "values": { tag: value } }));+}++/// Why the Attributes panel can't edit a row.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+enum RowLock {+ /// Constant: ATTEDIT leaves it alone.+ Constant,+ /// A value with line breaks: a single-line field would join its lines (the Properties palette+ /// sends it to the Edit Attributes dialog).+ Multiline,+ /// The tag is used twice in one block, and ATTEDIT addresses an attribute by its tag.+ Repeated,+}++/// One row of the Attributes panel: the tag, the value shared by the selected block references+/// (`varies` when they differ, then `value` is empty) and why it can't be edited, if it can't.+struct AttributeRow {+ tag: String,+ value: String,+ varies: bool,+ lock: Option<RowLock>,+}++/// The panel's rows for block references `hs`, from [`cadcraft_engine::attributes`]. One reference:+/// its attributes in order, a tag used twice shown twice. Several: the tags of all of them in order+/// of first appearance, locked when any reference has the tag locked.+fn attribute_rows(d: &cadcraft_engine::doc::Drawing, hs: &[cadcraft_engine::doc::Handle]) -> Vec<AttributeRow> {+ let per_reference: Vec<Vec<cadcraft_engine::AttributeRow>> = hs.iter().map(|h| cadcraft_engine::attributes(d, *h)).collect();+ let lock_of = |a: &cadcraft_engine::AttributeRow, repeated: bool| {+ if !a.editable {+ Some(RowLock::Constant)+ } else if a.value.contains('\n') {+ Some(RowLock::Multiline)+ } else if repeated {+ Some(RowLock::Repeated)+ } else {+ None+ }+ };+ let repeated = |rows: &[cadcraft_engine::AttributeRow], tag: &str| rows.iter().filter(|r| r.tag.eq_ignore_ascii_case(tag)).count() > 1;+ if let [rows] = per_reference.as_slice() {+ return rows+ .iter()+ .map(|a| AttributeRow { tag: a.tag.clone(), value: a.value.clone(), varies: false, lock: lock_of(a, repeated(rows, &a.tag)) })+ .collect();+ }+ let mut out: Vec<AttributeRow> = Vec::new();+ for rows in &per_reference {+ for a in rows {+ if !out.iter().any(|r| r.tag.eq_ignore_ascii_case(&a.tag)) {+ out.push(AttributeRow { tag: a.tag.clone(), value: a.value.clone(), varies: false, lock: None });+ }+ }+ }+ for row in &mut out {+ let mut first: Option<&str> = None;+ for rows in &per_reference {+ for a in rows.iter().filter(|a| a.tag.eq_ignore_ascii_case(&row.tag)) {+ row.lock = row.lock.or(lock_of(a, repeated(rows, &a.tag)));+ match first {+ None => first = Some(&a.value),+ Some(v) if v != a.value => row.varies = true,+ Some(_) => {}+ }+ }+ }+ row.value = if row.varies { String::new() } else { first.unwrap_or_default().to_string() };+ }+ out+}++/// The Attributes panel: the selected block reference's attributes as tag / value rows, edited in+/// place through ATTEDIT. Constant attributes can't be edited. A tag used twice in one block+/// stays read-only too, because ATTEDIT addresses an attribute by its tag. Block names, tags and+/// values come from the drawing, so they are shown as they are, never translated.+pub(crate) fn attributes_section(app: &mut CadApp, ui: &mut egui::Ui) {+ let Some((uid, hs)) = attributes_key(app) else {+ ui.add_space(8.0);+ ui.horizontal_wrapped(|ui| ui.colored_label(Tokens::get().text_dim, crate::tl!("Select a block reference that has attributes.")));+ return;+ };+ // A dynamic block reference shows its dynamic block's name, as Properties does.+ let dynamic_name = match hs.as_slice() {+ [h] => custom_properties(ui, app, *h).map(|(name, _)| name),+ _ => None,+ };+ let Ok(d) = app.session.doc() else { return };+ let names: Vec<&str> = hs+ .iter()+ .filter_map(|h| match d.entity(*h).map(|e| &e.kind) {+ Some(EntityKind::Insert(i)) => Some(i.block.as_str()),+ _ => None,+ })+ .collect();+ let mut block = match names.first() {+ Some(first) if names.iter().all(|n| n.eq_ignore_ascii_case(first)) => dynamic_name.unwrap_or_else(|| first.to_string()),+ _ => crate::i18n::t(VARIES).to_string(),+ };+ if hs.len() > 1 {+ block = format!("{block} (\u{d7}{})", hs.len());+ }+ let rows = attribute_rows(d, &hs);+ ui.add_space(4.0);+ prop_row(ui, "Block", |ui| read_only_literal(ui, &block));+ let mut set: Option<(String, String)> = None;+ let varies = crate::i18n::t(VARIES);+ for row in &rows {+ prop_row_literal(ui, &row.tag, |ui| match row.lock {+ None => {+ let shown = if row.varies { varies.to_string() } else { row.value.clone() };+ if let Some(v) = edit_field(ui, ui.scope_id().with(("attrib", uid, &hs, &row.tag)), shown) {+ set = Some((row.tag.clone(), v));+ }+ }+ Some(lock) => {+ let shown = if row.varies { varies.to_string() } else { row.value.replace('\n', " ") };+ value_box(ui, &shown, false, false).on_hover_text(match lock {+ RowLock::Constant => crate::tl!("Constant"),+ RowLock::Multiline => crate::tl!("Multiline: edit it with Edit Attributes"),+ RowLock::Repeated => crate::tl!("Tag used twice in this block"),+ });+ }+ });+ }+ if let Some((tag, value)) = set {+ set_attribute(app, &hs, &tag, value);+ } } #[cfg(test)]@@ -1193,4 +1383,179 @@ mod tests { } assert!(painted_blue, "a ByLayer entity on a blue layer needs a blue Properties swatch"); }++ /// The texts of a painted shape, one string per text.+ fn collect_text(shape: &Shape, out: &mut Vec<String>) {+ match shape {+ Shape::Text(t) => out.push(t.galley.text().to_string()),+ Shape::Vec(shapes) => shapes.iter().for_each(|s| collect_text(s, out)),+ _ => {}+ }+ }++ /// Everything the Properties palette paints for the current selection, one string per text.+ fn painted_texts(app: &mut CadApp, section: fn(&mut CadApp, &mut egui::Ui)) -> Vec<String> {+ let ctx = egui::Context::default();+ let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(400.0, 1200.0))), ..Default::default() };+ let mut texts = Vec::new();+ for _ in 0..2 {+ app.logic(&ctx);+ let mut output = ctx.run_ui(input.clone(), |ui| section(app, ui));+ texts.clear();+ output.shapes.iter().for_each(|s| collect_text(&s.shape, &mut texts));+ output.textures_delta.clear();+ }+ texts+ }++ /// Block `B`: a line with the attributes `(tag, default)`.+ fn app_with_block(attribs: &[(&str, &str)]) -> CadApp {+ app_with_named_block("B", attribs)+ }++ /// Block `name`: a line with the attributes `(tag, default)`.+ fn app_with_named_block(name: &str, attribs: &[(&str, &str)]) -> CadApp {+ let mut app = CadApp::new(Session::default(), Services::default());+ for (n, (tag, default)) in attribs.iter().enumerate() {+ app.run("attdef", json!({ "tag": tag, "default": default, "at": [0, n + 1] })).unwrap();+ }+ let line = app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap();+ let mut handles: Vec<String> = app.session.doc().unwrap().model.iter().map(|e| e.handle.hex()).collect();+ handles.push(line["handles"][0].as_str().unwrap_or_default().to_string());+ handles.sort();+ handles.dedup();+ app.run("block", json!({ "name": name, "base": [0, 0], "handles": handles, "keep": "delete" })).unwrap();+ app+ }++ /// Insert block `B` at `x` and return the reference's handle.+ fn insert_block(app: &mut CadApp, x: i32) -> cadcraft_engine::doc::Handle {+ insert_named_block(app, "B", x)+ }++ /// Insert block `name` at `x` and return the reference's handle.+ fn insert_named_block(app: &mut CadApp, name: &str, x: i32) -> cadcraft_engine::doc::Handle {+ let r = app.run("insert", json!({ "name": name, "at": [x, 0] })).unwrap();+ cadcraft_engine::doc::Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap()+ }++ /// The value of attribute `tag` on block reference `h`.+ fn attribute_value(app: &CadApp, h: cadcraft_engine::doc::Handle, tag: &str) -> String {+ let EntityKind::Insert(i) = &app.session.doc().unwrap().entity(h).unwrap().kind else { panic!("not a block reference") };+ i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone()+ }++ const CODICE_REV: [(&str, &str); 2] = [("CODICE", "8231"), ("REV", "A")];++ /// Editing an attribute changes only the selected block reference.+ #[test]+ fn editing_an_attribute_changes_only_the_selected_block_reference() {+ let mut app = app_with_block(&CODICE_REV);+ let (first, second) = (insert_block(&mut app, 5), insert_block(&mut app, 50));+ set_attribute(&mut app, &[first], "REV", "B".into());+ assert_eq!(attribute_value(&app, first, "REV"), "B");+ assert_eq!(attribute_value(&app, first, "CODICE"), "8231");+ assert_eq!(attribute_value(&app, second, "REV"), "A");+ assert_eq!(attribute_value(&app, second, "CODICE"), "8231");+ }++ /// Block names from the drawing are shown as they are, not translated.+ #[test]+ fn block_names_from_the_drawing_are_shown_untranslated() {+ // A block named like an interface word ("Color" is "Colore" in Italian) must not be taken for one.+ let mut app = app_with_named_block("Color", &CODICE_REV);+ let h = insert_named_block(&mut app, "Color", 5);+ app.run("select", json!({ "handles": [h.hex()] })).unwrap();+ let texts = crate::i18n::with_language("it", || painted_texts(&mut app, attributes_section));+ assert!(texts.iter().any(|t| t == "Blocco"), "the fixed label is translated: {texts:?}");+ assert!(texts.iter().any(|t| t == "Color"), "the block name stays as the drawing has it: {texts:?}");+ assert!(!texts.iter().any(|t| t == "Colore"), "the block name was translated: {texts:?}");+ }++ /// The panel lists the selected block reference's attributes.+ #[test]+ fn attributes_panel_lists_the_selected_block_reference_attributes() {+ let mut app = app_with_block(&CODICE_REV);+ let ins = insert_block(&mut app, 5);+ app.run("select", json!({ "handles": [ins.hex()] })).unwrap();++ assert_eq!(attributes_targets(&app), vec![ins]);+ assert_eq!(attributes_key(&app).map(|(_, h)| h), Some(vec![ins]));+ let texts = painted_texts(&mut app, attributes_section);+ for want in ["CODICE", "REV"] {+ assert!(texts.iter().any(|t| t == want), "the panel shows `{want}`; painted: {texts:?}");+ }++ // Anything else selected, or nothing, leaves the panel with just its hint.+ app.run("select", json!({ "handles": [] })).unwrap();+ assert!(attributes_targets(&app).is_empty());+ assert!(!painted_texts(&mut app, attributes_section).iter().any(|t| t == "CODICE"));+ }++ /// Two block references with the same attributes: equal values are shown, different ones as+ /// `*VARIES*`; a change goes to both in one undo step.+ #[test]+ fn several_block_references_show_equal_values_and_varies_for_different_ones() {+ let mut app = app_with_block(&CODICE_REV);+ let (first, second) = (insert_block(&mut app, 5), insert_block(&mut app, 50));+ set_attribute(&mut app, &[second], "REV", "B".into());+ app.run("select", json!({ "handles": [first.hex(), second.hex()] })).unwrap();++ assert_eq!(attributes_targets(&app), vec![first, second]);+ let d = app.session.doc().unwrap();+ let rows: Vec<(String, String, bool)> =+ attribute_rows(d, &attributes_targets(&app)).into_iter().map(|r| (r.tag, r.value, r.varies)).collect();+ assert_eq!(rows, vec![("CODICE".to_string(), "8231".to_string(), false), ("REV".to_string(), String::new(), true)]);+ let texts = painted_texts(&mut app, attributes_section);+ assert!(texts.iter().any(|t| t == "8231") && texts.iter().any(|t| t == VARIES), "painted: {texts:?}");++ let undo_depth = app.session.state().unwrap().undo.len();+ set_attribute(&mut app, &[first, second], "REV", "C".into());+ assert_eq!((attribute_value(&app, first, "REV"), attribute_value(&app, second, "REV")), ("C".into(), "C".into()));+ assert_eq!(app.session.state().unwrap().undo.len(), undo_depth + 1, "one undo step for both");+ let d = app.session.doc().unwrap();+ assert!(attribute_rows(d, &[first, second]).iter().all(|r| !r.varies));+ }++ /// Anything that is not a block reference with attributes in the selection leaves the panel+ /// empty, rather than editing some of the selection.+ #[test]+ fn a_mixed_selection_has_no_attributes_panel() {+ let mut app = app_with_block(&CODICE_REV);+ let ins = insert_block(&mut app, 5);+ let line = app.run("line", json!({ "points": [[0, 20], [10, 20]] })).unwrap();+ app.run("select", json!({ "handles": [ins.hex(), line["handles"][0].clone()] })).unwrap();+ assert!(attributes_targets(&app).is_empty());+ assert_eq!(attributes_key(&app), None);+ }++ /// Rows follow the Properties palette's rules: a constant attribute and a value with line+ /// breaks are read-only, and with several references one such value locks the whole row.+ #[test]+ fn attribute_rows_lock_constant_and_multiline_values() {+ let mut app = app_with_block(&[("T", "one"), ("U", "u"), ("M", "m")]);+ let (first, second) = (insert_block(&mut app, 5), insert_block(&mut app, 50));+ let doc = app.session.doc_mut().unwrap();+ doc.modify_entity(second, |e| {+ if let EntityKind::Insert(i) = &mut e.kind {+ i.attribs[1].constant = true;+ }+ })+ .unwrap();+ doc.modify_entity(first, |e| {+ if let EntityKind::Insert(i) = &mut e.kind {+ i.attribs[2].text.value = "a\nb".into();+ }+ })+ .unwrap();+ let d = app.session.doc().unwrap();+ let locks = |hs: &[cadcraft_engine::doc::Handle]| attribute_rows(d, hs).into_iter().map(|r| (r.tag, r.lock)).collect::<Vec<_>>();+ assert_eq!(locks(&[first]), [("T".into(), None), ("U".into(), None), ("M".into(), Some(RowLock::Multiline))]);+ assert_eq!(locks(&[second]), [("T".into(), None), ("U".into(), Some(RowLock::Constant)), ("M".into(), None)]);+ assert_eq!(+ locks(&[first, second]),+ [("T".into(), None), ("U".into(), Some(RowLock::Constant)), ("M".into(), Some(RowLock::Multiline))],+ "one locked reference locks the row for all"+ );+ } }new file mode 100644@@ -0,0 +1,179 @@+//! Block attributes from the UI: Edit Attributes on a selected block reference opens the Enhanced+//! Attribute Editor, and the Properties palette lists the attributes in their own group.++use cadcraft_engine::Session;+use cadcraft_engine::doc::{EntityKind, Handle};+use cadcraft_ui_egui::{CadApp, Services, attedit, context_menu, menus};+use serde_json::json;++/// A block "Tag" with attributes NO (prompt "Number") and NOTE, and one reference to it.+fn app_with_reference() -> (CadApp, Handle) {+ let mut app = CadApp::new(Session::new(), Services::default());+ let h = add_reference(&mut app);+ (app, h)+}++/// Block "Tag" and its reference, as [`app_with_reference`], in the active drawing (selected).+fn add_reference(app: &mut CadApp) -> Handle {+ let mut hs = vec![app.run("line", json!({ "points": [[0, 0], [10, 0]] })).unwrap()["handles"][0].clone()];+ hs.push(app.run("attdef", json!({ "tag": "NO", "prompt": "Number", "default": "1", "at": [0, 1] })).unwrap()["handle"].clone());+ hs.push(app.run("attdef", json!({ "tag": "NOTE", "default": "n", "at": [0, 2] })).unwrap()["handle"].clone());+ app.run("block", json!({ "name": "Tag", "base": [0, 0], "handles": hs, "keep": "delete" })).unwrap();+ let r = app.run("insert", json!({ "name": "Tag", "at": [100, 0], "attribs": { "NO": "7" } })).unwrap();+ let h = Handle::parse_hex(r["handle"].as_str().unwrap()).unwrap();+ app.session.set_selection(vec![h]);+ h+}++fn value(app: &CadApp, h: Handle, tag: &str) -> String {+ match app.session.doc().unwrap().entity(h).map(|e| &e.kind) {+ Some(EntityKind::Insert(i)) => i.attribs.iter().find(|a| a.tag == tag).unwrap().text.value.clone(),+ other => panic!("{other:?}"),+ }+}++/// The text painted in one frame.+fn painted(app: &mut CadApp, ctx: &egui::Context) -> String {+ fn collect(shape: &egui::Shape, text: &mut String) {+ match shape {+ egui::Shape::Text(s) => {+ text.push_str(s.galley.text());+ text.push('\n');+ }+ egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, text)),+ _ => {}+ }+ }+ let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), ..Default::default() };+ let mut output = ctx.run_ui(input, |ui| app.ui(ui));+ output.textures_delta.clear();+ let mut text = String::new();+ for s in &output.shapes {+ collect(&s.shape, &mut text);+ }+ text+}++#[test]+fn edit_attributes_on_a_selected_reference_opens_the_dialog() {+ let (mut app, h) = app_with_reference();+ let labels: Vec<_> = context_menu::object_entries(&app)+ .into_iter()+ .filter_map(|e| match e {+ menus::Entry::Item { label, .. } => Some(label),+ menus::Entry::Sub { .. } => None,+ })+ .collect();+ assert!(labels.iter().any(|l| l == "Edit Attributes..."), "{labels:?}");++ menus::activate(&mut app, "attedit");+ assert_eq!(app.ui.dialog.as_deref(), Some("attedit"));+ assert_eq!(app.attedit.block, "Tag");+ let rows: Vec<_> = app.attedit.rows.iter().map(|r| (r.tag.as_str(), r.prompt.as_str(), r.value.as_str())).collect();+ assert_eq!(rows, [("NO", "Number", "7"), ("NOTE", "NOTE", "n")]);+ assert!(app.session.running.is_none(), "the dialog, not the command-line prompts");++ let ctx = egui::Context::default();+ app.logic(&ctx);+ painted(&mut app, &ctx);+ let text = painted(&mut app, &ctx);+ for s in ["Enhanced Attribute Editor", "Number", "NOTE", "OK", "Apply"] {+ assert!(text.contains(s), "{s} missing from:\n{text}");+ }++ // Apply changes only what was typed, in one undo step, and keeps the dialog open.+ app.attedit.rows[1].typed = "a note".into();+ assert!(attedit::apply(&mut app));+ assert_eq!((value(&app, h, "NO"), value(&app, h, "NOTE")), ("7".into(), "a note".into()));+ assert_eq!(app.attedit.rows[1].value, "a note", "rows reload after Apply");+ app.run("undo", json!({})).unwrap();+ assert_eq!(value(&app, h, "NOTE"), "n");+}++/// `attedit::open` (a double-click on an attribute) opens the dialog on a reference with a+/// chosen attribute current; its value box takes the focus in the first frame.+#[test]+fn open_makes_the_chosen_attribute_current() {+ let (mut app, h) = app_with_reference();+ app.session.set_selection(Vec::new());+ assert!(attedit::open(&mut app, h, Some(1)));+ assert_eq!(app.ui.dialog.as_deref(), Some("attedit"));+ assert_eq!((app.attedit.current, app.attedit.focus), (Some(1), true));+ let ctx = egui::Context::default();+ app.logic(&ctx);+ painted(&mut app, &ctx);+ assert!(!app.attedit.focus, "the focus was given in the first frame");+ assert!(ctx.memory(|m| m.focused().is_some()), "a value box has the focus");+ assert_eq!(app.attedit.current, Some(1));++ // Apply keeps the current attribute.+ app.attedit.rows[1].typed = "x".into();+ assert!(attedit::apply(&mut app));+ assert_eq!(app.attedit.current, Some(1));++ // No attribute, or one out of range: the first that can be changed.+ assert!(attedit::open(&mut app, h, None));+ assert_eq!(app.attedit.current, Some(0));+ assert!(attedit::open(&mut app, h, Some(99)));+ assert_eq!(app.attedit.current, Some(0));++ // Not a block reference with attributes: nothing opens.+ app.ui.dialog = None;+ let line = app.run("line", json!({ "points": [[0, 0], [1, 1]] })).unwrap()["handles"][0].as_str().map(str::to_string).unwrap();+ assert!(!attedit::open(&mut app, Handle::parse_hex(&line).unwrap(), Some(0)));+ assert!(!attedit::open(&mut app, Handle(0xFFFF_FFFF), None));+ assert!(app.ui.dialog.is_none());+}++#[test]+fn without_a_selected_reference_attedit_asks_at_the_command_line() {+ let (mut app, _) = app_with_reference();+ app.session.set_selection(Vec::new());+ app.start("eattedit");+ assert!(app.ui.dialog.is_none());+ assert!(app.session.prompt_text().contains("Select block reference"), "{}", app.session.prompt_text());+ app.session.cancel();++ // JSON calls never open the dialog.+ let (mut app, h) = app_with_reference();+ app.run("attedit", json!({ "handle": h.hex(), "values": { "NO": "9" } })).unwrap();+ assert!(app.ui.dialog.is_none());+ assert_eq!(value(&app, h, "NO"), "9");+}++#[test]+fn the_properties_palette_lists_the_attributes() {+ let (mut app, _) = app_with_reference();+ let ctx = egui::Context::default();+ app.logic(&ctx);+ painted(&mut app, &ctx);+ let text = painted(&mut app, &ctx);+ // (Text fields' contents are not painted shapes here, so only the labels are checked.)+ for s in ["Attributes", "NO", "NOTE"] {+ assert!(text.lines().any(|l| l == s), "{s} missing from:\n{text}");+ }+}++/// The dialog belongs to the drawing it was opened for: with another drawing's tab chosen (where+/// the same handle is a reference with the same tags), Apply changes nothing and the dialog+/// closes.+#[test]+fn the_dialog_closes_in_another_drawing() {+ let (mut app, h) = app_with_reference();+ menus::activate(&mut app, "attedit");+ assert_eq!(app.ui.dialog.as_deref(), Some("attedit"));+ app.attedit.rows[1].typed = "a note".into();++ app.session.new_drawing(false);+ assert_eq!(add_reference(&mut app), h, "the same handle in the other drawing");+ assert!(!attedit::apply(&mut app));+ assert_eq!(value(&app, h, "NOTE"), "n", "the other drawing's reference is untouched");+ let ctx = egui::Context::default();+ app.logic(&ctx);+ painted(&mut app, &ctx);+ assert!(app.ui.dialog.is_none(), "closed");++ // Back in the first drawing, its reference is as it was too.+ app.session.active = 0;+ assert_eq!(value(&app, h, "NOTE"), "n");+}@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Enhanced Attribute Editor and the Properties palette's Attributes group: F17 narrowed; an Attributes panel for selected block references, also for several at once) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -39,7 +39,7 @@ do them first. | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties (HPDLGMODE 0 keeps HATCH on the command line); no MPolygon; HATCHEDIT and -HATCHEDIT add boundaries from picked points or closed objects and remove chosen loops, but a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 6–10 | | F15 | Viewports: MVIEW Restore prints "not available yet" (named viewport configurations into a layout, see U11); a clipped viewport and its clip boundary move and scale separately. Polygonal, object and clipped viewports, VPCLIP, MVIEW ON/OFF/Lock/2/3/4 and the status-bar scale list landed (#453) | `cmd/layout/viewports.rs` | Low | 2–4 | | F16 | Groups: no group bounding box or single grip when a group is selected (GROUPDISPLAYMODE), copying a whole group does not make a new group, no Object Grouping dialog (GROUP, GROUPEDIT, UNGROUP, -GROUP, PICKSTYLE group selection and the DXF/DWG round trip landed, #417); PICKSTYLE's associative-hatch bit is stored but unused | `cmd/groups.rs` | Low | 2–4 |-| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433): no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 |+| F17 | Attributes (ATTSYNC, global -ATTEDIT, BATTMAN editing and DATAEXTRACTION/EATTEXT landed, #433; the Enhanced Attribute Editor lists and edits a selected reference's values, and the Properties palette has an Attributes group): the editor has no Text Options or Properties tabs; no verify, preset or lock-position modes, and multiline attributes (read, drawn and saved since 2026-10-12) can't be created or switched in BATTMAN; data extraction covers block references and attribute values (CSV or a table) only — no wizard, nested blocks, other objects' properties, `.dxe` templates or spreadsheet output | `cmd/blocks/battman.rs`, `cmd/blocks/extract.rs` | Low | 4–6 | | F19 | MATCHPROP Settings is a keyword prompt (no dialog) and has no material, shadow or centre-object settings; TEXTTOFRONT and HATCHTOBACK act on the current space only; OVERKILL doesn't compare polyline segments with other objects (picker and Settings, DRAWORDER Above/Under, TEXTTOFRONT, HATCHTOBACK, OVERKILL tolerance/ignore/overlap/end-to-end options landed, #439) | `props/matchprop.rs`, `draworder.rs`, `overkill.rs` | Low | 2–4 | | F20 | OPTIONS dialog done (#23): Display, Open and Save, Drafting, Selection, User Preferences and Profiles tabs, and the user profile (object snap modes, drafting toggles, Dynamic Input, PICKBOX, APERTURE, GRIPSIZE, CURSORSIZE, PICKFIRST, PICKADD, QPMODE, model and paper background, snap marker size, system cursor) survives restarts. Left: right-click customization, the autosave interval and default save version on Open and Save (the features themselves landed, #406, #421), AutoSnap magnet and tooltip toggles, colours of the other drawing-area elements, named profiles with import and export | `ui-egui/src/options/` | Low | 2–4 | | F21 | MLINE, REGION and HELIX are objects since #510, with gaps: MLEDIT tees trim square; UNION/SUBTRACT/INTERSECT only combine regions whose boundaries don't meet; regions are saved as closed polylines (no ACIS data); PURGE skips multiline styles | `draw2/mline.rs`, `inquiry/regions.rs`, `dxf_region.rs` | Low | 6–10 |@@ -153,6 +153,8 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |+| 2026-10-25 | minor | The Attributes panel also edits several selected block references: attributes with the same value in all of them show it, different ones show `*VARIES*`, and a change is written to every selected reference (ATTEDIT `handles`, one undo step, built on the Enhanced Attribute Editor's `attributes()` and rules: constant attributes are left alone and named in `constantTags`, a non-block or a reference on a locked layer refuses the whole request before anything changes); constant and multiline-valued rows are read-only as in Properties; a selection with anything other than block references that have attributes shows no panel |+| 2026-10-24 | minor | An Attributes panel opens beside Layers when a single block reference with attributes is selected and closes when the selection changes; each attribute is a tag / value row edited in place through ATTEDIT (constant attributes and tags used twice in one block stay read-only) | | 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed |@@ -167,6 +169,7 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-12 | minor | Object transparency (entity, ByLayer, ByBlock) is drawn: on screen (TRANSPARENCYDISPLAY, on by default), in PNG/SVG exports, and in plots when the page setup's "Plot transparency" is on (PDF alpha, PNG blending, SVG opacity; PLOTTRANSPARENCYOVERRIDE): F14 narrowed | | 2026-10-12 | minor | Hatch and Gradient dialog (HATCH, GRADIENT; HPDLGMODE) and Hatch Edit dialog (HATCHEDIT, right-click, double-click a hatch): F13 narrowed to Preview, Inherit Properties and boundary add/remove in edit mode | | 2026-10-12 | minor | A bare system variable name typed at the command line (ORTHOMODE, GRIDMODE…) runs SETVAR on that variable, as in AutoCAD; U10 narrowed |+| 2026-10-13 | minor | F17: Edit Attributes (ATTEDIT/EATTEDIT with a selected block reference) opens the Enhanced Attribute Editor; the Properties palette lists and edits attribute values; REFEDIT on a dynamic block reference says to use BEDIT or Edit Attributes | | 2026-10-13 | minor | STRETCH takes crossing and window polygons (CP, WP), BOX and a fence (F), typed and as JSON (`polygon`, `fence`): F1 narrowed | | 2026-10-12 | minor | Property-based and hostile-input tests for geometry numerics landed (deterministic xorshift generator, no new dependency; known answers at 1e6–1e7 coordinates): S6 removed | | 2026-10-12 | trivial | F13: HATCHEDIT refuses a bad scale or angle before editing (#724) |@@ -159,7 +159,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Menu bar | 11 menus, 491 leaf items | same 11 menus; 245/491 items covered (233 by `cargo xtask parity`, which counts engine commands only, plus 12 UI-only items: see [parity-checklist.md](parity-checklist.md)); once a menu is open, pointing at another title opens that menu (#1567) | | Tool Bar, File tabs, Start tab, layout tabs | yes | yes | | Tool Sets palette (Drafting, Modeling, … with flyouts) | yes | yes for 2D groups |-| Properties Inspector, Quick Properties | yes | yes (every control acts or is visibly unavailable, #182); block references show read-only Position X/Y/Z from their stored insertion point; coordinate editing is still missing |+| Properties Inspector, Quick Properties | yes | yes (every control acts or is visibly unavailable, #182); block references show read-only Position X/Y/Z from their stored insertion point and an Attributes group whose values can be edited; coordinate editing is still missing | | Layer Properties Manager (filters, states, VP overrides) | yes | yes | | Blocks palette, Content | yes | Blocks window (click to insert, #28) | | Status bar toggles | ~25 | most toggles; Annotation Visibility and Workspace do nothing |@@ -214,6 +214,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | 2026-10-12 | minor | Selected and hovered objects redrawn in the selection colour (text and arrowheads included); Options ▸ Selection laid out as AutoCAD's: Object grouping, Show grips, grip object limit, selection preview, Visual Effect Settings (selection area, preview filter) | | 2026-10-12 | minor | Animation effects (dialog and menu fades, smoothed wheel scrolling and ⌘/Ctrl+wheel zoom) follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects chooses System, On or Off | | 2026-10-12 | minor | Hatch and Gradient / Hatch Edit dialogs (HATCH, GRADIENT, HATCHEDIT, double-click a hatch; HPDLGMODE) |+| 2026-10-13 | minor | Block references list their attributes in the Properties Inspector (editable values); Edit Attributes opens the Enhanced Attribute Editor | | 2026-10-12 | minor | Block references now show their stored insertion X/Y/Z in the Properties Inspector, including nonzero Z; fields are read-only | | 2026-10-11 | minor | Isometric drafting (#556): isometric snap and grid, ISODRAFT, ISOPLANE (F5), ortho along isoplane axes, ELLIPSE Isocircle | | 2026-10-11 | minor | Object snap tracking, Extension, Parallel, Apparent Intersection, additional polar angles (#379): tracking 30% → 70%, object snaps 40% → 45%; total 43% → 48% |