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 Виберіть об'єкт