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");
+}
