From 7003c5b9309f5e92c2f5b5fcb2e8b34be2840e22 Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Tue, 13 Oct 2026 10:50:32 -0600 Subject: [PATCH 1/5] TEXTEDIT on an MTEXT opens it in the in-place editor (part of #842) Double-clicking an MTEXT (#868) starts TEXTEDIT, which only asked for the new text on the command line, with its formatting codes. Now TEXTEDIT on an MTEXT reports it as the text being edited, so the in-place editor (#792) opens on it with its text and formats; OK writes the edited contents back, and TEXTEDIT goes on asking for objects as before. An editor emptied while editing keeps the text, as an empty answer does. The editor also takes keyboard focus when it opens on existing text, with the caret at the end: it only did so for an empty box, so typing into an existing text went to the command line. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/modify2.rs | 15 ++++++- crates/ui-egui/src/mtext_editor.rs | 63 ++++++++++++++++++++++++++---- 2 files changed, 69 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index 5562f969c..e94d7f124 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2203,12 +2203,18 @@ struct TextEditM { single: bool, /// Asking for the edit mode. mode: bool, + /// The MTEXT being edited, for the in-place editor (#842). + mtext: Option, } impl Interactive for TextEditM { fn name(&self) -> &'static str { "TEXTEDIT" } + /// An MTEXT asking for its new text opens in the in-place editor with its contents. + fn editing_mtext(&self) -> Option { + self.h.as_ref().and(self.mtext.clone()) + } fn prompt(&self, _s: &Session) -> Prompt { if self.mode { let current = if self.single { "Single" } else { "Multiple" }; @@ -2233,7 +2239,12 @@ 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 kind = curves::entity(s, h)?.kind; + let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?; + self.mtext = match kind { + EntityKind::MText(m) => Some(m), + _ => None, + }; self.h = Some((h, old)); Ok(Step::Continue) } @@ -2243,10 +2254,12 @@ impl Interactive for TextEditM { set_text(s, h, &t)?; self.edits.push((h, before)); self.h = None; + self.mtext = None; Ok(if self.single { Step::Done } else { Step::Continue }) } (Some(_), Input::Enter) => { self.h = None; + self.mtext = None; Ok(if self.single { Step::Done } else { Step::Continue }) } (None, Input::Enter) => Ok(Step::Done), diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 9e7db5803..b46b5f04d 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -2,7 +2,8 @@ //! the text's location and width with a formatting toolbar (font, height, bold, italic, //! underline, overline, colour, alignment, lists, tabs) and a ruler for the paragraph's indents //! and tab stops. The text and its formats live in the engine's -//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. +//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. TEXTEDIT +//! (and so a double-click) on an existing MTEXT opens it here with its contents (#842). use std::sync::Arc; @@ -36,6 +37,9 @@ struct State { sel: (usize, usize), /// A caret position to set on the text box in the next frame (after a toolbar insertion). caret: Option, + /// The text box has had keyboard focus: it takes it once when the editor opens, so typing + /// goes into the text, not to the command line. + focused: bool, } fn state_id() -> egui::Id { @@ -55,7 +59,8 @@ fn color32(c: MTextColor) -> Color32 { Color32::from_rgb(rgb.0, rgb.1, rgb.2) } -/// Show the editor while MTEXT collects its text; clears its state when it isn't running. +/// Show the editor while MTEXT, or TEXTEDIT on an MTEXT, collects its text; clears its state +/// when neither is running. pub fn show(app: &mut CadApp, ctx: &egui::Context) { let mtext = app.session.editing_mtext(); let asking = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point); @@ -66,14 +71,17 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let t = Tokens::get(); let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6))); let mut st = ctx.data_mut(|d| d.get_temp::(state_id())).unwrap_or_else(|| { - let mut fmt = Formatted::default(); + // An existing MTEXT (TEXTEDIT) starts with its contents and their formats. + let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents(&mt.contents) }; if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) { // A family name, not a font file (`txt.shx`, `arial.ttf`). if !f.is_empty() && !f.contains('.') { fmt.base_font = f; } } - State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None } + // An existing text opens with the caret at its end. + let caret = (!fmt.is_empty()).then(|| fmt.len()); + State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret, focused: false } }); let base_h = if mt.height > 0.0 { mt.height } else { 0.2 }; let px = ((base_h * scale) as f32).clamp(MIN_PX, MAX_PX); @@ -122,8 +130,12 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { Done::Ok => { ctx.data_mut(|d| d.remove::(state_id())); // The editor holds the whole contents: one input, then Enter ends MTEXT's text entry. + // Emptied while editing an existing MTEXT: it keeps its text (Enter), as TEXTEDIT + // keeps it for an empty answer. if !st.fmt.is_empty() { let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())); + } else if app.session.running.as_ref().is_some_and(|r| r.id == "textedit") { + let _ = app.session.input(cadcraft_engine::Input::Enter); } if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") { let _ = app.session.input(cadcraft_engine::Input::Enter); @@ -376,9 +388,8 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) { let id = edit_id(); - if let Some(at) = st.caret.take() - && let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), id) - { + if let Some(at) = st.caret.take() { + let mut s = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at)))); s.store(ui.ctx(), id); } @@ -436,9 +447,10 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, .char_limit(cadcraft_engine::mtext_edit::MAX_CHARS) .layouter(&mut layouter) .show(ui); - if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() { + if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty())) { out.response.request_focus(); } + st.focused = true; if out.response.changed() { let typing = st.typing.clone(); st.fmt.set_text(&buf, &typing); @@ -495,4 +507,39 @@ mod tests { let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents)); assert!(found, "{contents}"); } + + /// #842: TEXTEDIT on an existing MTEXT (what a double-click starts) opens it in the editor + /// with its text and formats; finishing writes the edited contents back to that MTEXT. + #[test] + fn textedit_opens_an_existing_mtext_with_its_contents() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + let frame = |app: &mut CadApp| { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }; + for _ in 0..2 { + let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + } + }; + app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "{\\fArial|b1;first}\\Psecond", "height": 2.5, "width": 20 })).unwrap(); + let h = app.session.doc().unwrap().model.last().unwrap().handle; + app.start("textedit"); + let at = cadcraft_geom::Vec2::new(1.0, 19.0); + app.session.input(cadcraft_engine::Input::Point(at)).unwrap(); + let m = app.session.editing_mtext().expect("an MTEXT to edit"); + assert!(m.contents.contains("second"), "{}", m.contents); + frame(&mut app); + let st = ctx.data_mut(|d| d.get_temp::(state_id())).expect("editor open"); + assert_eq!(st.fmt.text(), "first\nsecond"); + assert!(st.fmt.fmt_at(0).bold && !st.fmt.fmt_at(7).bold, "formats kept"); + let mut st = st; + st.fmt.set_text("first\nsecond\nthird", &CharFmt::default()); + app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())).unwrap(); + frame(&mut app); + assert!(ctx.data_mut(|d| d.get_temp::(state_id())).is_none(), "editor closed"); + match &app.session.doc().unwrap().entity(h).unwrap().kind { + cadcraft_engine::doc::EntityKind::MText(m) => assert!(m.contents.contains("third") && m.contents.contains("b1"), "{}", m.contents), + _ => panic!(), + } + } } From cab962a7cbd870f6ab35e1e54da12c953668b9ef Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Tue, 13 Oct 2026 20:21:59 -0600 Subject: [PATCH 2/5] Let multileader text be edited (double-click, TEXTEDIT, Properties) Double-clicking a multileader's text now opens the in-place editor, the same as MTEXT. TEXTEDIT accepts a multileader and edits its MText contents (newlines round-trip as \P). The Properties palette shows a Contents field and a Text height field for a multileader with text, and properties.set writes them back. Fixes #1081. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/modify2.rs | 13 ++++++- crates/engine/src/cmd/props.rs | 11 ++++++ crates/engine/tests/mleader_text_edit.rs | 45 +++++++++++++++++++++++ crates/ui-egui/src/canvas.rs | 10 +++-- crates/ui-egui/src/palettes.rs | 11 ++++++ crates/ui-egui/tests/text_double_click.rs | 24 ++++++++++++ 6 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 crates/engine/tests/mleader_text_edit.rs diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index e94d7f124..7e85cfb08 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2161,6 +2161,8 @@ fn text_of(k: &EntityKind) -> Option { EntityKind::Text(t) => Some(t.value.clone()), EntityKind::AttDef(a) => Some(a.text.value.clone()), EntityKind::MText(t) => Some(t.contents.replace("\\P", "\n")), + // A multileader's text (one with a block instead has none, #1081). + 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, } @@ -2175,11 +2177,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) if m.text.is_some() => { + if let Some(t) = &mut m.text { + t.contents = text.replace('\n', "\\P"); + } + } 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, multileader, attribute definition or dimension.")), } set_kind(s, h, k) } @@ -2240,9 +2247,11 @@ impl Interactive for TextEditM { (None, Input::Point(p)) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; let kind = curves::entity(s, h)?.kind; - let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?; + let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, multileader, attribute definition or dimension."))?; self.mtext = match kind { EntityKind::MText(m) => Some(m), + // A multileader's text opens in the same editor (#1081). + EntityKind::MLeader(m) => m.text, _ => None, }; self.h = Some((h, old)); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 6e68a9d8d..12b998202 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -319,6 +319,17 @@ fn run_set(s: &mut Session, p: &Value) -> Result { t.rotation = x.to_radians(); } } + // A multileader's text: its contents (#1081) and height. + 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/engine/tests/mleader_text_edit.rs b/crates/engine/tests/mleader_text_edit.rs new file mode 100644 index 000000000..ba2887419 --- /dev/null +++ b/crates/engine/tests/mleader_text_edit.rs @@ -0,0 +1,45 @@ +//! #1081: a multileader's text can be edited: TEXTEDIT (what a double-click starts) takes it and +//! hands it to the in-place editor, and the Properties palette's Contents field changes it. + +use cadcraft_engine::doc::EntityKind; +use cadcraft_engine::geom::Vec2; +use cadcraft_engine::{Input, Session}; +use serde_json::json; + +fn text_of(s: &Session) -> String { + s.doc() + .unwrap() + .model + .iter() + .find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.contents.clone()) } else { None }) + .unwrap() +} + +#[test] +fn textedit_and_properties_change_a_multileaders_text() { + let mut s = Session::new(); + s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap(); + let (h, at) = s + .doc() + .unwrap() + .model + .iter() + .find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None }) + .unwrap(); + + // TEXTEDIT on the text: the current contents are the default, and the in-place editor gets + // the multileader's MTEXT. + s.cmdline("TEXTEDIT").unwrap(); + s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap(); + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Enter new text"); + assert_eq!(p.default.as_deref(), Some("example text")); + assert_eq!(s.editing_mtext().map(|m| m.contents), Some("example text".to_string())); + s.input(Input::Text("edited\nsecond line".into())).unwrap(); + assert_eq!(text_of(&s), "edited\\Psecond line"); + s.cancel(); + + // The Properties palette's Contents field. + s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap(); + assert_eq!(text_of(&s), "from properties"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index e5725e353..ffe153bcf 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1862,12 +1862,16 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { } /// The model space tiles on screen (none without tiling): each tile's rectangle in `full`. -/// Start TEXTEDIT on the text, mtext or attribute definition at `p` (the objects its shortcut menu -/// offers Edit Text for); false when there is none. +/// Start TEXTEDIT on the text, mtext, attribute definition or multileader text at `p` (the objects +/// its shortcut menu offers Edit Text for, and multileaders, #1081); false when there is none. 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, + 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 index c5283e084..99287fac5 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -1018,6 +1018,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); } + // A multileader's text (#1081): it had no field, so it couldn't be changed. + EntityKind::MLeader(ml) => { + if let Some(tx) = &ml.text { + prop_row(ui, "Contents", |ui| { + if let Some(s) = edit_field(ui, ui.id().with(("mltxt", &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/text_double_click.rs b/crates/ui-egui/tests/text_double_click.rs index 782234d10..1b0918f01 100644 --- a/crates/ui-egui/tests/text_double_click.rs +++ b/crates/ui-egui/tests/text_double_click.rs @@ -76,3 +76,27 @@ fn double_click_elsewhere_starts_no_command() { assert_eq!(h.state().session.running.as_ref().map(|r| r.id.as_str()), None, "{at:?}"); } } + +/// #1081: double-clicking a multileader's text edits it, as for TEXT and MTEXT. +#[test] +fn double_click_on_a_multileaders_text_starts_textedit() { + let mut h = harness(); + h.state_mut().run("mleader", json!({ "points": [[16, -10], [20, -5]], "text": "example text" })).unwrap(); + h.run_steps(3); + let at = h + .state() + .session + .doc() + .unwrap() + .model + .iter() + .find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| t.insert.xy()) } else { None }) + .unwrap(); + double_click(&mut h, at + Vec2::new(1.0, -0.05)); + let s = &h.state().session; + assert_eq!(s.running.as_ref().map(|r| r.id.as_str()), Some("textedit")); + let p = s.current_prompt().unwrap(); + assert_eq!(p.message, "Enter new text"); + assert_eq!(p.default.as_deref(), Some("example text")); + assert!(s.editing_mtext().is_some(), "the in-place editor opens on it"); +} From 2830c7c17621d6ea9e22468f6126aae6f1404f00 Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Fri, 23 Oct 2026 11:41:26 -0600 Subject: [PATCH 3/5] Merge upstream/main into feat/mleader-text The multileader text test moves into tests/it (one test binary per crate, #1989). Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/it/main.rs | 1 + crates/engine/tests/{ => it}/mleader_text_edit.rs | 0 2 files changed, 1 insertion(+) rename crates/engine/tests/{ => it}/mleader_text_edit.rs (100%) diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index b6e088a13..fb51cf645 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -67,6 +67,7 @@ mod measure_distance_snapping; mod metric_linetypes; mod mleader_content; mod mleader_remember; +mod mleader_text_edit; mod mline; mod mocoro; mod modify_curves; diff --git a/crates/engine/tests/mleader_text_edit.rs b/crates/engine/tests/it/mleader_text_edit.rs similarity index 100% rename from crates/engine/tests/mleader_text_edit.rs rename to crates/engine/tests/it/mleader_text_edit.rs From d0ab457f317b5671b1bc8398b00f5a65b1af840e Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Wed, 28 Oct 2026 09:24:29 -0600 Subject: [PATCH 4/5] Relative height codes kept when a text opens in the editor \H2x; with no inline height before it scaled nothing and was dropped, so saving a multileader's (or an MTEXT's) text from the editor lost it. The editor now parses the contents against the text's own height. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/mtext_edit.rs | 8 ++++++- crates/ui-egui/src/mtext_editor.rs | 37 +++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs index 30da0b897..faa802ff3 100644 --- a/crates/engine/src/mtext_edit.rs +++ b/crates/engine/src/mtext_edit.rs @@ -99,6 +99,12 @@ fn arg_end(chars: &[char], from: usize) -> usize { impl Formatted { /// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped. pub fn from_contents(contents: &str) -> Formatted { + Self::from_contents_with_height(contents, None) + } + + /// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no + /// inline height before it scales that one instead of being dropped. + pub fn from_contents_with_height(contents: &str, height: Option) -> Formatted { let mut f = Formatted::default(); let chars: Vec = contents.chars().collect(); let mut stack: Vec = Vec::new(); @@ -153,7 +159,7 @@ impl Formatted { i = end + 1; match k { 'f' | 'F' => apply_font(&mut cur, &a), - 'H' => cur.height = parse_height(&a, cur.height), + 'H' => cur.height = parse_height(&a, cur.height.or(height)), 'C' => { cur.color = a.trim().parse::().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v))); } diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 686acbf19..96640edae 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -108,7 +108,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6))); let mut st = ctx.data_mut(|d| d.get_temp::(state_id())).unwrap_or_else(|| { // An existing MTEXT (TEXTEDIT) starts with its contents and their formats. - let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents(&mt.contents) }; + let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents_with_height(&mt.contents, Some(mt.height)) }; if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) { // A family name, not a font file (`txt.shx`, `arial.ttf`). if !f.is_empty() && !f.contains('.') { @@ -761,4 +761,39 @@ mod tests { _ => panic!(), } } + + /// A multileader's text with a relative height code (`\H2x;`) opens in the editor at twice + /// its text height, so saving it back keeps the bigger letters. + #[test] + fn a_relative_height_in_a_multileaders_text_is_kept() { + let ctx = egui::Context::default(); + let mut app = CadApp::new(Session::new(), Services::default()); + let frame = |app: &mut CadApp| { + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }; + for _ in 0..2 { + let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + } + }; + app.run("mleader", serde_json::json!({ "points": [[16, -10], [20, -5]], "text": "{\\H2x;big} small" })).unwrap(); + let (at, height) = app + .session + .doc() + .unwrap() + .model + .iter() + .find_map(|e| { + if let cadcraft_engine::doc::EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (t.insert.xy(), t.height)) } else { None } + }) + .unwrap(); + app.start("textedit"); + app.session.input(cadcraft_engine::Input::Point(at + cadcraft_geom::Vec2::new(0.5, -0.05))).unwrap(); + assert!(app.session.editing_mtext().is_some(), "the multileader's text is being edited"); + frame(&mut app); + let st = ctx.data_mut(|d| d.get_temp::(state_id())).expect("editor open"); + assert_eq!(st.fmt.text(), "big small"); + let big = st.fmt.fmt_at(0).height.expect("the relative height is kept"); + assert!((big - 2.0 * height).abs() < 1e-9, "{big} for a {height} text"); + assert_eq!(st.fmt.fmt_at(5).height, None, "the rest keeps the text's height"); + } } From 1e02e6f2630e8af40c002dc66f8f1b8941065810 Mon Sep 17 00:00:00 2001 From: Aldo Zarate Date: Wed, 28 Oct 2026 10:10:04 -0600 Subject: [PATCH 5/5] Text editor: relative heights stay relative, long texts are refused, focus stays put A \H2x; height is written back as \H2x; (relative over the text's own height), so the part keeps scaling when the text's height changes; an absolute height stays absolute. TEXTEDIT refuses a text longer than the editor holds instead of opening it cut short, which OK would have saved. With the text empty the editor takes the focus back only when nothing else has it, so a click on the height field keeps it there. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/modify2/textedit.rs | 5 ++ crates/engine/src/mtext_edit.rs | 70 ++++++++++++++++++--- crates/engine/tests/it/mleader_text_edit.rs | 22 +++++++ crates/ui-egui/src/mtext_editor.rs | 31 ++++++++- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/modify2/textedit.rs b/crates/engine/src/cmd/modify2/textedit.rs index 3a07369b5..a0daa7d40 100644 --- a/crates/engine/src/cmd/modify2/textedit.rs +++ b/crates/engine/src/cmd/modify2/textedit.rs @@ -104,6 +104,11 @@ impl Interactive for TextEditM { EntityKind::MLeader(m) => m.text, _ => None, }; + // The editor would cut a text this long short, and saving would shorten it. + if self.mtext.as_ref().is_some_and(|m| !crate::mtext_edit::Formatted::fits(&m.contents)) { + self.mtext = None; + return Err(other("This text is too long to edit; it is left as it is.")); + } self.h = Some((h, old)); Ok(Step::Continue) } diff --git a/crates/engine/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs index faa802ff3..6fc4a46fd 100644 --- a/crates/engine/src/mtext_edit.rs +++ b/crates/engine/src/mtext_edit.rs @@ -33,6 +33,9 @@ pub struct CharFmt { pub font: Option, /// Absolute character height set inline (`\H`); `None`: the text's. pub height: Option, + /// The inline height was set relative to the text's own (`\H2x;`): this multiplier, written + /// back as it was so the part keeps scaling with the text's height. + pub height_rel: Option, /// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock). pub color: Option, } @@ -105,6 +108,17 @@ impl Formatted { /// [`Self::from_contents`] for a text `height` high: a relative height code (`\H2x;`) with no /// inline height before it scales that one instead of being dropped. pub fn from_contents_with_height(contents: &str, height: Option) -> Formatted { + Self::parse(contents, height).0 + } + + /// Whether the editor holds all of `contents`: past [`MAX_CHARS`] characters the rest would + /// be cut off, and saving would shorten the text. + pub fn fits(contents: &str) -> bool { + Self::parse(contents, None).1 + } + + /// The parse, and whether it took in all of `contents`. + fn parse(contents: &str, height: Option) -> (Formatted, bool) { let mut f = Formatted::default(); let chars: Vec = contents.chars().collect(); let mut stack: Vec = Vec::new(); @@ -114,7 +128,7 @@ impl Formatted { let mut i = 0; while let Some(&c) = chars.get(i) { if f.fmt.len() >= MAX_CHARS { - break; + return (f, false); } match c { '{' => { @@ -159,7 +173,7 @@ impl Formatted { i = end + 1; match k { 'f' | 'F' => apply_font(&mut cur, &a), - 'H' => cur.height = parse_height(&a, cur.height.or(height)), + 'H' => apply_height(&mut cur, &a, height), 'C' => { cur.color = a.trim().parse::().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v))); } @@ -195,7 +209,7 @@ impl Formatted { } } } - f + (f, true) } fn push_char(&mut self, c: char, fmt: &CharFmt) { @@ -453,7 +467,9 @@ impl Formatted { let fam: String = fam.chars().filter(|c| !matches!(c, ';' | '|' | '{' | '}' | '\\')).collect(); s.push_str(&format!("\\f{fam}|b{}|i{};", u8::from(f.bold), u8::from(f.italic))); } - if let Some(h) = f.height { + if let Some(m) = f.height_rel { + s.push_str(&format!("\\H{}x;", trim(m))); + } else if let Some(h) = f.height { s.push_str(&format!("\\H{};", trim(h))); } match f.color { @@ -470,12 +486,32 @@ fn trim(v: f64) -> String { s.trim_end_matches('0').trim_end_matches('.').to_string() } -fn parse_height(a: &str, cur: Option) -> Option { +/// A `\H` code on `cur`, for a text `base` high. A relative height over the text's own (no +/// inline height before it, or a relative one) stays relative; over an inline absolute height it +/// becomes absolute. +fn apply_height(cur: &mut CharFmt, a: &str, base: Option) { + let valid = |h: f64| (h.is_finite() && h > 0.0 && h < 1e6).then_some(h); let a = a.trim(); - match a.strip_suffix(['x', 'X']) { - // A relative height needs the current one; without it the code is dropped. - Some(r) => r.trim().parse::().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6), - None => a.parse::().ok().filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6).or(cur), + let Some(r) = a.strip_suffix(['x', 'X']) else { + if let Some(h) = a.parse::().ok().and_then(valid) { + cur.height = Some(h); + cur.height_rel = None; + } + return; + }; + let Some(m) = r.trim().parse::().ok().and_then(valid) else { return }; + match (cur.height, cur.height_rel) { + (_, Some(r0)) => { + if let Some(m) = valid(r0 * m) { + cur.height_rel = Some(m); + cur.height = base.and_then(|b| valid(b * m)); + } + } + (None, None) => { + cur.height_rel = Some(m); + cur.height = base.and_then(|b| valid(b * m)); + } + (Some(h), None) => cur.height = valid(h * m).or(cur.height), } } @@ -501,6 +537,22 @@ fn apply_font(cur: &mut CharFmt, a: &str) { mod tests { use super::*; + #[test] + fn a_relative_height_is_written_back_relative() { + let f = Formatted::from_contents_with_height("{\\H2x;big} small {\\H3;abs}", Some(5.0)); + assert_eq!(f.fmt_at(0).height, Some(10.0), "shown at twice the text's height"); + let out = f.to_contents(); + assert!(out.contains("\\H2x;big"), "{out}"); + assert!(out.contains("\\H3;abs"), "an absolute height stays absolute: {out}"); + // Opened again for a taller text, the part scales with it. + assert_eq!(Formatted::from_contents_with_height(&out, Some(8.0)).fmt_at(0).height, Some(16.0)); + // Relative over relative multiplies; over an absolute one it is absolute. + let n = Formatted::from_contents_with_height("{\\H2x;a{\\H1.5x;b}}{\\H4;c{\\H2x;d}}", Some(1.0)).to_contents(); + assert!(n.contains("\\H3x;b") && n.contains("\\H8;d"), "{n}"); + // With no height known it is still kept. + assert!(Formatted::from_contents("{\\H2x;x}").to_contents().contains("\\H2x;x")); + } + #[test] fn round_trips_formats_and_atoms() { for src in [ diff --git a/crates/engine/tests/it/mleader_text_edit.rs b/crates/engine/tests/it/mleader_text_edit.rs index ba2887419..bff071cf1 100644 --- a/crates/engine/tests/it/mleader_text_edit.rs +++ b/crates/engine/tests/it/mleader_text_edit.rs @@ -43,3 +43,25 @@ fn textedit_and_properties_change_a_multileaders_text() { s.execute("properties.set", &json!({"handles": [h.hex()], "text": "from properties"})).unwrap(); assert_eq!(text_of(&s), "from properties"); } + +#[test] +fn a_text_too_long_for_the_editor_is_left_as_it_is() { + let mut s = Session::new(); + s.execute("mleader", &json!({"points": [[0, 0], [20, 20]], "text": "example text"})).unwrap(); + let (h, at) = s + .doc() + .unwrap() + .model + .iter() + .find_map(|e| if let EntityKind::MLeader(m) = &e.kind { m.text.as_ref().map(|t| (e.handle, t.insert.xy())) } else { None }) + .unwrap(); + let long = format!("example text {}", "x".repeat(cadcraft_engine::mtext_edit::MAX_CHARS)); + s.execute("properties.set", &json!({"handles": [h.hex()], "text": long})).unwrap(); + assert_eq!(text_of(&s), long); + s.cmdline("TEXTEDIT").unwrap(); + s.input(Input::Point(at + Vec2::new(1.0, -0.05))).unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("This text is too long to edit; it is left as it is.")); + assert!(s.editing_mtext().is_none(), "the editor doesn't open"); + s.cancel(); + assert_eq!(text_of(&s), long, "the text is not shortened"); +} diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 96640edae..1b020d705 100644 --- a/crates/ui-egui/src/mtext_editor.rs +++ b/crates/ui-egui/src/mtext_editor.rs @@ -260,7 +260,10 @@ fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { let mut h = cur.height.unwrap_or(base_h); if height_field(ui, &mut h, base_h) { let none = (h - base_h).abs() < 1e-9; - change(st, |c| c.height = (!none).then_some(h)); + change(st, |c| { + c.height = (!none).then_some(h); + c.height_rel = None; + }); } ui.separator(); for (flag, label, tip) in [ @@ -579,7 +582,10 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, .layouter(&mut layouter) .show(ui); out.response.response.clone().accessible_name(crate::tl!("Text")); - if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty())) { + // On opening, and while the text is empty, the editor takes the focus; a field of the toolbar + // the user clicked keeps it. + let free = ui.memory(|m| m.focused().is_none()); + if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty() && free)) { out.response.request_focus(); } st.focused = true; @@ -633,6 +639,27 @@ mod tests { assert!(harness.unnamed_widgets().is_empty()); } + /// With the text empty, a click on the height field keeps the focus there; the editor doesn't + /// take it back. + #[test] + fn an_empty_editor_leaves_the_focus_on_the_height_field() { + let state = + State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None, focused: false }; + let mut harness = Harness::builder().with_size(vec2(1100.0, 300.0)).build_ui_state( + |ui, state| { + toolbar(ui, state, 2.5); + text_box(ui, state, 2.5, 20.0, 1000.0, &Tokens::get()); + }, + state, + ); + harness.run(); + assert!(harness.ctx.memory(|m| m.has_focus(edit_id())), "the editor opens with the focus"); + harness.get_by_role_and_label(Role::SpinButton, "Text height").click(); + harness.run(); + harness.run(); + assert!(!harness.ctx.memory(|m| m.has_focus(edit_id())), "the height field keeps the focus"); + } + /// The ten toolbar controls listed in the test are found by semantic name under the harness's default bundled fonts, and no toolbar widget is unnamed. #[test] fn the_whole_toolbar_uses_bundled_glyphs_and_names_its_controls() {