From ca882b38e293f8aac15d9a54c5b7780fb562cece Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 20:37:52 +0200 Subject: [PATCH] TEXTEDIT prompt, Undo and Mode; MTEXT takes lines until an empty one (#234) (#239) TEXTEDIT's prompt no longer reads "or or". Undo reverts the last edit made in this TEXTEDIT, and Mode switches between Multiple (keep selecting) and Single (end after one edit). At the command line and in scripts, MTEXT takes one paragraph per input line until an empty line, instead of ending after the first line and running the next one as a command. The Text Editor window sends its contents and then Enter, so it still ends MTEXT. Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/draw.rs | 14 ++++-- crates/engine/src/cmd/modify2.rs | 43 +++++++++++++++-- crates/engine/tests/text_commands.rs | 69 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 8 +++- crates/ui-egui/src/i18n/uk.tsv | 2 +- 5 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 crates/engine/tests/text_commands.rs diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index 71aa8a970..99a7d2319 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1315,6 +1315,9 @@ impl Interactive for TextM { struct MTextM { first: Option, second: Option, + /// Paragraphs typed so far: one per input line, an empty line ends the text (as on + /// AutoCAD's command line and in scripts). + lines: Vec, } impl Interactive for MTextM { @@ -1327,14 +1330,19 @@ impl Interactive for MTextM { (Some(a), None) => Prompt::new("Specify opposite corner", Accept::POINT) .kw(&["Height", "Justify", "Line spacing", "Rotation", "Style", "Width", "Columns"]) .base(a), - _ => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), + _ if self.lines.is_empty() => Prompt::new("Enter text (use \\P for new paragraphs)", Accept::TEXT), + _ => Prompt::new("Enter next line of text (empty line to finish)", Accept::TEXT), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { match (self.first, self.second, i) { (None, _, Input::Point(p)) => self.first = Some(p), (Some(_), None, Input::Point(p)) => self.second = Some(p), - (Some(a), Some(b), Input::Text(t)) => { + (Some(_), Some(_), Input::Text(t)) if !t.is_empty() => self.lines.push(t), + (Some(a), Some(b), Input::Text(_) | Input::Enter) => { + if self.lines.is_empty() { + return Ok(Step::Done); + } let d = s.doc()?; let h = d.header.f64("TEXTSIZE", 0.2); let style = d.header.str("TEXTSTYLE", "Standard"); @@ -1346,7 +1354,7 @@ impl Interactive for MTextM { attach: 1, rotation: 0.0, style, - contents: t, + contents: self.lines.join("\\P"), line_spacing: 1.0, }))?; return Ok(Step::Done); diff --git a/crates/engine/src/cmd/modify2.rs b/crates/engine/src/cmd/modify2.rs index d1ba72b84..ba21904ca 100644 --- a/crates/engine/src/cmd/modify2.rs +++ b/crates/engine/src/cmd/modify2.rs @@ -2269,6 +2269,12 @@ fn run_textedit(s: &mut Session, p: &Value) -> Result { #[derive(Default)] struct TextEditM { h: Option<(Handle, String)>, + /// Edits made in this run, newest last: each object as it was before (the Undo option). + edits: Vec<(Handle, EntityKind)>, + /// Single mode: end after one edit (the Mode option; Multiple by default). + single: bool, + /// Asking for the edit mode. + mode: bool, } impl Interactive for TextEditM { @@ -2276,12 +2282,26 @@ impl Interactive for TextEditM { "TEXTEDIT" } fn prompt(&self, _s: &Session) -> Prompt { + if self.mode { + let current = if self.single { "Single" } else { "Multiple" }; + return Prompt::new("Enter a text edit mode option", curves::KW).kw(&["Single", "Multiple"]).default(current); + } match &self.h { - None => Prompt::new("Select an annotation object or", Accept::POINT).kw(&["Undo", "Mode"]), + None => Prompt::new("Select an annotation object", Accept::POINT).kw(&["Undo", "Mode"]), Some((_, old)) => Prompt::new("Enter new text", Accept::TEXT).default(old.chars().take(40).collect::()), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { + if self.mode { + match i { + Input::Keyword(k) if k == "Single" => self.single = true, + Input::Keyword(k) if k == "Multiple" => self.single = false, + Input::Enter => {} + _ => return Err(other("Enter Single or Multiple.")), + } + self.mode = false; + return Ok(Step::Continue); + } match (&self.h, i) { (None, Input::Point(p)) => { let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?; @@ -2290,16 +2310,29 @@ impl Interactive for TextEditM { Ok(Step::Continue) } (Some((h, _)), Input::Text(t) | Input::Keyword(t)) => { - set_text(s, *h, &t)?; + let h = *h; + let before = curves::entity(s, h)?.kind; + set_text(s, h, &t)?; + self.edits.push((h, before)); self.h = None; - Ok(Step::Continue) + Ok(if self.single { Step::Done } else { Step::Continue }) } (Some(_), Input::Enter) => { self.h = None; - Ok(Step::Continue) + Ok(if self.single { Step::Done } else { Step::Continue }) } (None, Input::Enter) => Ok(Step::Done), - (None, Input::Keyword(_)) => Ok(Step::Continue), + (None, Input::Keyword(k)) if k == "Undo" => { + match self.edits.pop() { + Some((h, before)) => set_kind(s, h, before)?, + None => s.echo("Everything has been undone"), + } + Ok(Step::Continue) + } + (None, Input::Keyword(k)) if k == "Mode" => { + self.mode = true; + Ok(Step::Continue) + } _ => Ok(Step::Continue), } } diff --git a/crates/engine/tests/text_commands.rs b/crates/engine/tests/text_commands.rs new file mode 100644 index 000000000..e5ecc3a18 --- /dev/null +++ b/crates/engine/tests/text_commands.rs @@ -0,0 +1,69 @@ +//! MTEXT contents from scripts and the command line; TEXTEDIT's prompt and options. + +use cadcraft_engine::Session; +use cadcraft_engine::doc::{EntityKind, Space}; + +fn kinds(s: &Session) -> Vec { + s.doc().ok().and_then(|d| d.space(&Space::Model)).map(|st| st.iter().map(|e| e.kind.clone()).collect()).unwrap_or_default() +} + +fn mtexts(s: &Session) -> Vec { + kinds(s) + .into_iter() + .filter_map(|k| match k { + EntityKind::MText(t) => Some(t.contents), + _ => None, + }) + .collect() +} + +fn texts(s: &Session) -> Vec { + kinds(s) + .into_iter() + .filter_map(|k| match k { + EntityKind::Text(t) => Some(t.value), + _ => None, + }) + .collect() +} + +#[test] +fn script_mtext_takes_lines_until_an_empty_line() { + let mut s = Session::new(); + s.script("MTEXT 0,10 20,0\nFirst line\nSecond line\n\nLINE 0,0 5,5\n\n").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(mtexts(&s), vec!["First line\\PSecond line".to_string()]); + assert_eq!(kinds(&s).len(), 2, "{:?}", kinds(&s)); + + // Typed at the command line: spaces stay in the text, Enter on an empty line ends it. + let mut s = Session::new(); + s.cmdline("MTEXT 0,10 20,0").unwrap(); + s.cmdline("Hello world").unwrap(); + assert!(s.running.is_some()); + s.cmdline("").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(mtexts(&s), vec!["Hello world".to_string()]); +} + +#[test] +fn textedit_prompt_mode_and_undo() { + let mut s = Session::new(); + s.cmdline("TEXT 0,0 1 0 old").unwrap(); + s.cmdline("").unwrap(); + s.cmdline("TEXTEDIT").unwrap(); + let p = s.prompt_text(); + assert!(!p.contains("or or"), "{p}"); + // Edit, then Undo restores the old text. + s.cmdline("0.5,0.5").unwrap(); + s.cmdline("new").unwrap(); + assert_eq!(texts(&s), vec!["new".to_string()]); + s.cmdline("u").unwrap(); + assert_eq!(texts(&s), vec!["old".to_string()]); + // Mode Single: the command ends after one edit. + s.cmdline("m").unwrap(); + s.cmdline("s").unwrap(); + s.cmdline("0.5,0.5").unwrap(); + s.cmdline("once").unwrap(); + assert!(s.running.is_none(), "{}", s.prompt_text()); + assert_eq!(texts(&s), vec!["once".to_string()]); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 060504ac5..cd2627e51 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -388,7 +388,13 @@ fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { ctx.data_mut(|d| d.insert_temp(id, buf)); if let Some(t) = submit { ctx.data_mut(|d| d.remove::(id)); - let _ = app.session.input(cadcraft_engine::Input::Text(t)); + // The editor holds the whole contents: one input, then Enter ends MTEXT's text entry. + if !t.is_empty() { + let _ = app.session.input(cadcraft_engine::Input::Text(t)); + } + if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") { + let _ = app.session.input(cadcraft_engine::Input::Enter); + } } else if cancel { ctx.data_mut(|d| d.remove::(id)); app.session.cancel(); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index c054517fc..d8a3e6131 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -612,7 +612,7 @@ Select a line. Виберіть відрізок. Select a polyline. Виберіть полілінію. Select a text, mtext, attribute definition or dimension. Виберіть текст, багаторядковий текст, визначення атрибута або розмір. - Select an annotation object or Виберіть об'єкт анотації або + Select an annotation object Виберіть об'єкт анотації Select an arc or circle Виберіть дугу або коло Select an arc. Виберіть дугу. Select an object Виберіть об'єкт