collective

Browse / github.com/storytold/cadcraft / patch

Multileaders: make their text editable like other annotations (fixes #1081)

[Bug Report] [CadCraft] Can't edit multileader text ### Description Create Multileader with text "example text" Double clicking the leader text does not enable editing. There is no text field in the Multileader Properties. There doesn't appear to be a way to change the multileader text. ### Ticket details | | | |---|---| | **App** | CadCraft | | **Type** | Bug Report | | **Reported by** | Bob (Discord username: bobbeats) — Discord user ID `983433270479970334` | | **Submitted** | 2026-10-13 23:13:32 UTC | | **Discord server** | ArtCraft (`1359579021108842617`) | | **Submitted from channel** | `1558917176453374102` | | **Submission ID** | `T

Status
open
Score
0.00
Adopters
0 other operators
Author
SusieeTheLinuxUser (imported from pull request #2033, open)
Base
7bf5905ac97316a7896de8cbc12d243f57b09bd8
Size
+97 −3 in 6 files
Review
pending
Shared
2026-10-28
Id
7c06baffdce01cd03e670212

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/modify2.rs crates/engine/src/cmd/modify2_tests.rs crates/engine/src/cmd/props.rs crates/ui-egui/src/canvas.rs crates/ui-egui/src/palettes.rs crates/ui-egui/tests/it/text_double_click.rs

diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs--- a/crates/engine/src/cmd/modify2.rs+++ b/crates/engine/src/cmd/modify2.rs@@ -2173,6 +2173,7 @@ fn text_of(k: &EntityKind) -> Option<String> {         EntityKind::Text(t) => Some(t.value.clone()),         EntityKind::AttDef(a) => Some(a.text.value.clone()),         EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")),+        EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.replace("\\P", "\n")),         EntityKind::Dimension(d) => Some(if d.text.is_empty() { "<>".into() } else { d.text.clone() }),         _ => None,     }@@ -2187,11 +2188,16 @@ pub(crate) fn set_text(s: &mut Session, h: Handle, text: &str) -> Result<()> {         EntityKind::Text(t) => t.value = text.to_string(),         EntityKind::AttDef(a) => a.text.value = text.to_string(),         EntityKind::MText(t) => t.contents = text.replace('\n', "\\P"),+        EntityKind::MLeader(m) => match &mut m.text {+            Some(t) => t.contents = text.replace('\n', "\\P"),+            // A multileader carrying a block has no text to change (MLEADEREDIT moves that).+            None => return Err(other("This multileader carries a block, not text.")),+        },         EntityKind::Dimension(d) => {             d.text = if text == "<>" { String::new() } else { text.to_string() };             d.block = None;         }-        _ => return Err(other("Select a text, mtext, attribute definition or dimension.")),+        _ => return Err(other("Select a text, mtext, attribute definition, dimension or multileader.")),     }     set_kind(s, h, k) }@@ -2245,7 +2251,8 @@ impl Interactive for TextEditM {         match (&self.h, i) {             (None, Input::Point(p)) => {                 let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;-                let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;+                let old = text_of(&curves::entity(s, h)?.kind)+                    .ok_or_else(|| other("Select a text, mtext, attribute definition, dimension or multileader."))?;                 self.h = Some((h, old));                 Ok(Step::Continue)             }diff --git a/crates/engine/src/cmd/modify2_tests.rs b/crates/engine/src/cmd/modify2_tests.rs--- a/crates/engine/src/cmd/modify2_tests.rs+++ b/crates/engine/src/cmd/modify2_tests.rs@@ -659,6 +659,24 @@ fn textedit_json_and_command_line() {     assert!(matches!(kind(&s, &t), EntityKind::Text(x) if x.value == "hello world")); } +#[test]+fn textedit_edits_a_multileader() {+    let mut s = Session::new();+    let m = h(&s.execute("mleader", &json!({"points": [[10, 5], [0, 0]], "text": "example text"})).unwrap());+    s.execute("textedit", &json!({"handle": m, "text": "changed"})).unwrap();+    assert!(matches!(kind(&s, &m), EntityKind::MLeader(x) if x.text.as_ref().unwrap().contents == "changed"));+    // The Properties palette's Contents and Text height go through properties.set.+    s.execute("properties.set", &json!({"handles": [m], "text": "via the palette", "height": 0.5})).unwrap();+    assert!(+        matches!(kind(&s, &m), EntityKind::MLeader(x) if x.text.as_ref().map(|t| (t.contents.as_str(), t.height)) == Some(("via the palette", 0.5)))+    );+    // A block multileader has no text to edit.+    let sq = h(&s.execute("pline", &json!({"vertices": [[-1, -1], [1, -1], [1, 1], [-1, 1]], "closed": true})).unwrap());+    s.execute("block", &json!({"name": "TAG", "base": [-1, -1], "handles": [sq], "keep": "delete"})).unwrap();+    let b = h(&s.execute("mleader", &json!({"points": [[10, 5], [0, 0]], "block": "TAG"})).unwrap());+    assert!(s.execute("textedit", &json!({"handle": b, "text": "x"})).is_err());+}+ #[test] fn explode_polyline3d_keeps_elevations() {     let mut s = Session::new();diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs--- a/crates/engine/src/cmd/props.rs+++ b/crates/engine/src/cmd/props.rs@@ -320,6 +320,17 @@ fn run_set(s: &mut Session, p: &Value) -> Result<Value> {                         t.rotation = x.to_radians();                     }                 }+                // The multileader's own text; a block multileader has none to set.+                EntityKind::MLeader(m) => {+                    if let Some(t) = &mut m.text {+                        if let Some(v) = str_param(p, "text") {+                            t.contents = v.replace('\n', "\\P");+                        }+                        if let Some(x) = num("height").filter(|x| *x > 0.0) {+                            t.height = x;+                        }+                    }+                }                 EntityKind::LwPolyline(pl) => {                     if let Some(x) = num("width").filter(|x| *x >= 0.0) {                         pl.const_width = x;diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs--- a/crates/ui-egui/src/canvas.rs+++ b/crates/ui-egui/src/canvas.rs@@ -2039,7 +2039,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {     use cadcraft_engine::doc::EntityKind;     let is_text = cadcraft_engine::cmd::curves::pick_at(&app.session, p).and_then(|h| {-        app.session.doc().ok()?.entity(h).map(|e| matches!(e.kind, EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_)))+        app.session.doc().ok()?.entity(h).map(|e| match &e.kind {+            EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::AttDef(_) => true,+            // A multileader carrying a block has no text to edit.+            EntityKind::MLeader(m) => m.text.is_some(),+            _ => false,+        })     });     if is_text != Some(true) {         return false;diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs--- a/crates/ui-egui/src/palettes.rs+++ b/crates/ui-egui/src/palettes.rs@@ -1055,6 +1055,17 @@ pub(crate) fn properties_section(app: &mut CadApp, ui: &mut egui::Ui) {                 num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);                 num(ui, "Defined width", "width", len(tx.width), &mut set, &mut bad, &ids);             }+            // The multileader's own text; a block multileader has none, so it shows no fields.+            EntityKind::MLeader(m) => {+                if let Some(tx) = &m.text {+                    prop_row(ui, "Contents", |ui| {+                        if let Some(s) = edit_field(ui, ui.id().with(("mldr", &ids)), tx.contents.clone()) {+                            set = Some(json!({ "handles": ids, "text": s }));+                        }+                    });+                    num(ui, "Text height", "height", len(tx.height), &mut set, &mut bad, &ids);+                }+            }             EntityKind::LwPolyline(pl) => {                 num(ui, "Global width", "width", len(pl.const_width), &mut set, &mut bad, &ids);                 let g = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: pl.closed };diff --git a/crates/ui-egui/tests/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs--- a/crates/ui-egui/tests/it/text_double_click.rs+++ b/crates/ui-egui/tests/it/text_double_click.rs@@ -76,3 +76,45 @@ fn double_click_elsewhere_starts_no_command() {         assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), None, "{at:?}");     } }++#[test]+fn double_click_on_a_multileader_edits_its_text() {+    let mut h = harness();+    h.state_mut().session.execute("mleader", &json!({"points": [[10, 5], [0, 0]], "text": "example text"})).unwrap();+    h.run_steps(2);+    // The multileader's text sits at the end of its landing.+    double_click(&mut h, Vec2::new(-0.45, 0.05));+    let s = &h.state().session;+    assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("textedit"), "the clicked multileader is already picked");+    let p = s.current_prompt().unwrap();+    assert_eq!(p.message, "Enter new text");+    assert_eq!(p.default.as_deref(), Some("example text"));+    h.state_mut().session.input(Input::Text("changed text".into())).unwrap();+    h.run_steps(2);+    let got = h+        .state()+        .session+        .doc()+        .unwrap()+        .model+        .iter()+        .find_map(|e| match &e.kind {+            EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.contents.clone()),+            _ => None,+        })+        .unwrap();+    assert_eq!(got, "changed text");+}++#[test]+fn double_click_on_a_block_multileader_starts_no_text_edit() {+    let mut h = harness();+    let s = &mut h.state_mut().session;+    let sq = s.execute("pline", &json!({"vertices": [[-1, -1], [1, -1], [1, 1], [-1, 1]], "closed": true})).unwrap();+    s.execute("block", &json!({"name": "TAG", "base": [-1, -1], "handles": [sq["handle"].as_str().or_else(|| sq["handles"][0].as_str()).unwrap()], "keep": "delete"})).unwrap();+    s.execute("mleader", &json!({"points": [[10, 5], [0, 0]], "block": "TAG"})).unwrap();+    h.run_steps(2);+    // On the leader line, halfway along.+    double_click(&mut h, Vec2::new(5.0, 2.5));+    assert_ne!(h.state().session.running.as_ref().map(|r| r.id.as_str()), Some("textedit"), "a block multileader has no text");+}