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!(), + } + } }