diff --git a/crates/engine/src/cmd/modify2/textedit.rs b/crates/engine/src/cmd/modify2/textedit.rs index 10ca46245..a0daa7d40 100644 --- a/crates/engine/src/cmd/modify2/textedit.rs +++ b/crates/engine/src/cmd/modify2/textedit.rs @@ -11,6 +11,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, } @@ -25,11 +27,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) } @@ -53,12 +60,18 @@ pub(super) 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" }; @@ -83,7 +96,19 @@ 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, 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, + }; + // 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) } @@ -93,10 +118,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/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 34e5541fe..bbe6847d0 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -402,6 +402,17 @@ fn apply_kind_properties( 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(params, "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/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs index 97ecede6f..334ff5ed7 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, } @@ -99,6 +102,23 @@ 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 { + 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(); @@ -108,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 { '{' => { @@ -151,7 +171,7 @@ impl Formatted { i = end + 1; match k { 'f' | 'F' => apply_font(&mut cur, &a), - 'H' => cur.height = parse_height(&a, cur.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))); } @@ -187,7 +207,7 @@ impl Formatted { } } } - f + (f, true) } fn push_char(&mut self, c: char, fmt: &CharFmt) { @@ -445,7 +465,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 { @@ -462,12 +484,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), } } @@ -493,6 +535,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/main.rs b/crates/engine/tests/it/main.rs index 80885ad33..78571b219 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -86,6 +86,7 @@ mod minsert; mod mirror_degenerate_line; mod mleader_content; mod mleader_remember; +mod mleader_text_edit; mod mline; mod mocoro; mod modify_curves; diff --git a/crates/engine/tests/it/mleader_text_edit.rs b/crates/engine/tests/it/mleader_text_edit.rs new file mode 100644 index 000000000..bff071cf1 --- /dev/null +++ b/crates/engine/tests/it/mleader_text_edit.rs @@ -0,0 +1,67 @@ +//! #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"); +} + +#[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/canvas/pick.rs b/crates/ui-egui/src/canvas/pick.rs index 3bd28b017..96e510df9 100644 --- a/crates/ui-egui/src/canvas/pick.rs +++ b/crates/ui-egui/src/canvas/pick.rs @@ -26,12 +26,16 @@ pub(super) fn wheel_zoom_points(events: &[egui::Event], opts: &egui::InputOption } /// 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. pub(super) 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/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs index 5b9813e85..1b020d705 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; @@ -72,6 +73,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 { @@ -91,7 +95,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); @@ -102,14 +107,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_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('.') { 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); @@ -158,8 +166,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); @@ -248,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 [ @@ -507,9 +522,8 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { /// MTEXT editor area with the formatted preview; restores a pending caret position before laying text out with `base_h`, `px`, `width` and the theme tokens. 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); } @@ -568,9 +582,13 @@ 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() && 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; if out.response.changed() { let typing = st.typing.clone(); st.fmt.set_text(&buf, &typing); @@ -603,7 +621,8 @@ mod tests { /// Clicking Diameter inserts U+2300 into both the formatted text and `to_contents()`, selects it, and leaves no unnamed widget. #[test] fn diameter_insertion_keeps_real_text_and_renders_without_optional_fonts() { - let state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + 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)).with_context_setup(install_diameter_glyph).build_ui_state( |ui, state| { toolbar(ui, state, 2.5); @@ -620,10 +639,32 @@ 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() { - let mut state = State { fmt: Formatted::default(), typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }; + let mut 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, 120.0)).build_ui(move |ui| toolbar(ui, &mut state, 2.5)); harness.run(); for name in ["Left", "Center", "Right", "Justify", "Bulleted list", "Numbered list", "Insert tab", "Diameter", "Text color", "Font"] { @@ -712,4 +753,74 @@ 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!(), + } + } + + /// 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"); + } } diff --git a/crates/ui-egui/src/palettes.rs b/crates/ui-egui/src/palettes.rs index ea2deae82..554ea4f9b 100644 --- a/crates/ui-egui/src/palettes.rs +++ b/crates/ui-egui/src/palettes.rs @@ -1162,6 +1162,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.scope_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/it/text_double_click.rs b/crates/ui-egui/tests/it/text_double_click.rs index f585ae46a..faf3da714 100644 --- a/crates/ui-egui/tests/it/text_double_click.rs +++ b/crates/ui-egui/tests/it/text_double_click.rs @@ -105,3 +105,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"); +}