diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index b5efa62..a9530bd 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -1602,6 +1602,10 @@ impl Interactive for MTextM { } Ok(Step::Continue) } + fn editing_mtext(&self) -> Option { + let a = self.first?; + (self.boxed() && self.ask.is_none()).then(|| self.opts.mtext(a, self.second, String::new())) + } fn preview(&self, _s: &Session, c: Vec2) -> Vec { match (self.first, self.boxed(), self.ask) { (Some(a), false, None) => { diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 1a06a7c..34401e3 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -84,6 +84,15 @@ impl Interactive for Guarded { None }) } + fn editing_mtext(&self) -> Option { + if self.broken.get().is_some() { + return None; + } + catch_unwind(AssertUnwindSafe(|| self.inner.editing_mtext())).unwrap_or_else(|_| { + self.broken.set(Some("panic in prompt")); + None + }) + } } #[cfg(test)] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 001e8fb..3142741 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod grip_dims; pub mod grips; mod guard; pub mod lisp; +pub mod mtext_edit; pub mod pointmod; pub mod prompt; mod recent; @@ -1297,6 +1298,12 @@ impl Session { } } + /// The multiline text the running command is collecting, for the in-place editor + /// ([`Interactive::editing_mtext`]). + pub fn editing_mtext(&self) -> Option { + self.running.as_ref().and_then(|r| r.machine.editing_mtext()) + } + pub fn current_prompt(&self) -> Option { if let Some(p) = self.lisp_prompt() { return Some(p); diff --git a/crates/engine/src/mtext_edit.rs b/crates/engine/src/mtext_edit.rs new file mode 100644 index 0000000..30da0b8 --- /dev/null +++ b/crates/engine/src/mtext_edit.rs @@ -0,0 +1,568 @@ +//! The model behind the in-place MTEXT editor: MTEXT contents as plain text with a format per +//! character and the properties of each paragraph, so a text box can show and change bold, +//! italic, underline, font, height, colour, indents and tabs and hand back MTEXT contents. +//! +//! Codes the editor has no control for (stacked fractions `\S`, `\W`, `\T`, `\Q`, `\A`, column +//! breaks `\N`, non-breaking spaces …) are kept as opaque *atoms*: one placeholder character in +//! the text ([`ATOM_BASE`] + n) that [`Formatted::to_contents`] writes back as the original +//! code, so opening and closing the editor never loses them. + +use cadcraft_fonts::ParaProps; +pub use cadcraft_fonts::{ListStyle, MTextColor, ParaAlign, TabKind}; + +/// Largest paragraph indent or tab stop, in drawing units. +const MAX_INDENT: f64 = 1e6; + +/// First placeholder character of the opaque codes (Unicode private use area). +pub const ATOM_BASE: u32 = 0xE000; +/// Most atoms one text keeps; further codes are dropped. +const MAX_ATOMS: usize = 1000; +/// Longest text the editor takes (characters). +pub const MAX_CHARS: usize = 100_000; +/// Most paragraphs. +const MAX_PARAS: usize = 20_000; + +/// Formatting of one character. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CharFmt { + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub overline: bool, + /// Font family set inline (`\f`); `None`: the text style's. + pub font: Option, + /// Absolute character height set inline (`\H`); `None`: the text's. + pub height: Option, + /// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock). + pub color: Option, +} + +/// A character format switch of the toolbar. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Flag { + Bold, + Italic, + Underline, + Overline, +} + +impl CharFmt { + fn flag(&self, f: Flag) -> bool { + match f { + Flag::Bold => self.bold, + Flag::Italic => self.italic, + Flag::Underline => self.underline, + Flag::Overline => self.overline, + } + } + + fn set_flag(&mut self, f: Flag, on: bool) { + match f { + Flag::Bold => self.bold = on, + Flag::Italic => self.italic = on, + Flag::Underline => self.underline = on, + Flag::Overline => self.overline = on, + } + } +} + +/// MTEXT contents as editable text. +#[derive(Clone, Debug, PartialEq)] +pub struct Formatted { + /// The text, paragraphs separated by `\n`; atoms are placeholders. + text: String, + /// One format per character of `text`. + fmt: Vec, + /// One per paragraph (`\n` count + 1). + paras: Vec, + /// The original code of each atom. + atoms: Vec, + /// The font family bold and italic runs are written with when no font was set inline + /// (`\fFamily|b1;`): the text style's. + pub base_font: String, +} + +impl Default for Formatted { + fn default() -> Self { + Formatted { text: String::new(), fmt: Vec::new(), paras: vec![ParaProps::default()], atoms: Vec::new(), base_font: "Arial".into() } + } +} + +fn arg_end(chars: &[char], from: usize) -> usize { + let mut j = from; + while chars.get(j).is_some_and(|c| *c != ';') { + j += 1; + } + j +} + +impl Formatted { + /// Parse MTEXT contents. Never fails: what isn't understood is kept as atoms or dropped. + pub fn from_contents(contents: &str) -> Formatted { + let mut f = Formatted::default(); + let chars: Vec = contents.chars().collect(); + let mut stack: Vec = Vec::new(); + let mut cur = CharFmt::default(); + let mut props = ParaProps::default(); + f.paras = vec![props.clone()]; + let mut i = 0; + while let Some(&c) = chars.get(i) { + if f.fmt.len() >= MAX_CHARS { + break; + } + match c { + '{' => { + if stack.len() < 64 { + stack.push(cur.clone()); + } + i += 1; + } + '}' => { + if let Some(p) = stack.pop() { + cur = p; + } + i += 1; + } + '\r' => i += 1, + '\n' => { + if f.paras.len() < MAX_PARAS { + f.push_char('\n', &cur); + f.paras.push(props.clone()); + } + i += 1; + } + '\\' => { + let code = chars.get(i + 1).copied(); + i += 2; + match code { + None => {} + Some('P') => { + if f.paras.len() < MAX_PARAS { + f.push_char('\n', &cur); + f.paras.push(props.clone()); + } + } + Some(c @ ('\\' | '{' | '}')) => f.push_char(c, &cur), + Some('L') => cur.underline = true, + Some('l') => cur.underline = false, + Some('O') => cur.overline = true, + Some('o') => cur.overline = false, + Some(k @ ('f' | 'F' | 'H' | 'C' | 'c' | 'p')) => { + let end = arg_end(&chars, i); + let a: String = chars.get(i..end).unwrap_or_default().iter().collect(); + i = end + 1; + match k { + 'f' | 'F' => apply_font(&mut cur, &a), + 'H' => cur.height = parse_height(&a, cur.height), + 'C' => { + cur.color = a.trim().parse::().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v))); + } + 'c' => { + if let Ok(v) = a.trim().parse::() { + cur.color = Some(MTextColor::Rgb(v & 0xFF_FFFF)); + } + } + _ => { + props.apply(&a); + if let Some(p) = f.paras.last_mut() { + *p = props.clone(); + } + } + } + } + Some(k) => { + // Any other code is kept verbatim: the ones with an argument up to + // their `;`, the others alone. + let mut code = format!("\\{k}"); + if !matches!(k, '~' | 'K' | 'k' | 'N') { + let end = arg_end(&chars, i); + code.extend(chars.get(i..=end.min(chars.len().saturating_sub(1))).unwrap_or_default().iter()); + i = end + 1; + } + f.push_atom(code, &cur); + } + } + } + _ => { + f.push_char(c, &cur); + i += 1; + } + } + } + f + } + + fn push_char(&mut self, c: char, fmt: &CharFmt) { + self.text.push(c); + self.fmt.push(fmt.clone()); + } + + fn push_atom(&mut self, code: String, fmt: &CharFmt) { + if self.atoms.len() >= MAX_ATOMS { + return; + } + let ch = char::from_u32(ATOM_BASE + self.atoms.len() as u32).unwrap_or('\u{FFFD}'); + self.atoms.push(code); + self.push_char(ch, fmt); + } + + /// The text as the editor shows it. + pub fn text(&self) -> &str { + &self.text + } + + /// Number of characters. + pub fn len(&self) -> usize { + self.fmt.len() + } + + pub fn is_empty(&self) -> bool { + self.fmt.is_empty() + } + + /// Number of paragraphs. + pub fn paragraph_count(&self) -> usize { + self.paras.len() + } + + /// Format of character `i` (the last one past the end, the default for no text). + pub fn fmt_at(&self, i: usize) -> CharFmt { + self.fmt.get(i).or(self.fmt.last()).cloned().unwrap_or_default() + } + + /// The paragraph character `i` is in. + pub fn paragraph_of(&self, i: usize) -> usize { + self.text.chars().take(i).filter(|c| *c == '\n').count().min(self.paras.len().saturating_sub(1)) + } + + /// Properties of paragraph `p`. + pub fn para(&self, p: usize) -> ParaProps { + self.paras.get(p).or(self.paras.last()).cloned().unwrap_or_default() + } + + /// The edited text replaced by `new` (what a text box hands back after a keystroke): the + /// characters that stay keep their format, new ones take `typing`, and paragraphs follow + /// the line breaks added and removed. Text beyond [`MAX_CHARS`] is refused (`false`). + pub fn set_text(&mut self, new: &str, typing: &CharFmt) -> bool { + if new == self.text { + return true; + } + let old: Vec = self.text.chars().collect(); + let new_c: Vec = new.chars().collect(); + if new_c.len() > MAX_CHARS || new_c.iter().filter(|c| **c == '\n').count() >= MAX_PARAS { + return false; + } + let pre = old.iter().zip(&new_c).take_while(|(a, b)| a == b).count(); + let suf = old.iter().rev().zip(new_c.iter().rev()).take(old.len().min(new_c.len()) - pre).take_while(|(a, b)| a == b).count(); + let removed = old.get(pre..old.len() - suf).unwrap_or_default(); + let inserted = new_c.get(pre..new_c.len() - suf).unwrap_or_default(); + // Typing after a character takes its format unless the toolbar changed it. + let first_para = old.iter().take(pre).filter(|c| **c == '\n').count(); + let gone = removed.iter().filter(|c| **c == '\n').count(); + let added = inserted.iter().filter(|c| **c == '\n').count(); + let end = (first_para + 1 + gone).min(self.paras.len()); + self.paras.drain((first_para + 1).min(end)..end); + let template = self.para(first_para); + for _ in 0..added { + self.paras.insert((first_para + 1).min(self.paras.len()), template.clone()); + } + let ins: Vec = inserted.iter().map(|_| typing.clone()).collect(); + self.fmt.splice(pre..old.len() - suf, ins); + self.text = new.to_string(); + if self.paras.is_empty() { + self.paras.push(ParaProps::default()); + } + true + } + + fn clamp(&self, r: std::ops::Range) -> std::ops::Range { + r.start.min(self.fmt.len())..r.end.min(self.fmt.len()) + } + + /// Apply `f` to the format of the characters in `range`. + pub fn edit(&mut self, range: std::ops::Range, f: impl Fn(&mut CharFmt)) { + let r = self.clamp(range); + if let Some(s) = self.fmt.get_mut(r) { + s.iter_mut().for_each(f); + } + } + + /// Whether every character of `range` has `flag`. + pub fn all_have(&self, range: std::ops::Range, flag: Flag) -> bool { + let r = self.clamp(range); + !r.is_empty() && self.fmt.get(r).is_some_and(|s| s.iter().all(|c| c.flag(flag))) + } + + /// Toggle `flag` on `range`: set unless every character has it already. + pub fn toggle(&mut self, range: std::ops::Range, flag: Flag) { + let on = !self.all_have(range.clone(), flag); + self.edit(range, |c| c.set_flag(flag, on)); + } + + /// The paragraphs `range` touches (a caret touches the one it is in). + fn paras_of(&self, range: std::ops::Range) -> std::ops::Range { + let a = self.paragraph_of(range.start); + let b = self.paragraph_of(range.end.max(range.start)); + a..b + 1 + } + + /// Change the properties of the paragraphs `range` touches. + pub fn edit_paras(&mut self, range: std::ops::Range, f: impl Fn(&mut ParaProps)) { + let ps = self.paras_of(range); + if let Some(s) = self.paras.get_mut(ps) { + s.iter_mut().for_each(f); + } + } + + /// Set the indents (in drawing units, as `\p` codes have them) of the paragraphs `range` touches: `first` is relative + /// to `left`. + pub fn set_indents(&mut self, range: std::ops::Range, first: f64, left: f64) { + if first.is_finite() && left.is_finite() { + let left = left.clamp(0.0, MAX_INDENT); + self.edit_paras(range, |p| { + p.first = first.clamp(-left, MAX_INDENT); + p.left = left; + }); + } + } + + /// Add a tab stop at `pos` drawing units to the paragraphs `range` touches. + pub fn add_tab(&mut self, range: std::ops::Range, pos: f64, kind: TabKind) { + if !pos.is_finite() || !(0.0..=MAX_INDENT).contains(&pos) { + return; + } + self.edit_paras(range, |p| { + if p.tabs.len() < 64 && !p.tabs.iter().any(|(t, _)| (t - pos).abs() < 1e-6) { + let at = p.tabs.partition_point(|(t, _)| *t < pos); + p.tabs.insert(at, (pos, kind)); + } + }); + } + + /// Remove the tab stop nearest `pos` (within `tolerance`) from the paragraphs `range` touches. + pub fn remove_tab(&mut self, range: std::ops::Range, pos: f64, tolerance: f64) { + self.edit_paras(range, |p| { + if let Some(i) = p.tabs.iter().position(|(t, _)| (t - pos).abs() <= tolerance) { + p.tabs.remove(i); + } + }); + } + + /// Make the paragraphs `range` touches a list: a hanging indent of `indent` drawing units + /// with a tab stop there, and each paragraph a label and a tab. + pub fn make_list(&mut self, range: std::ops::Range, style: ListStyle, indent: f64) { + let indent = if indent.is_finite() && indent > 0.0 { indent.min(MAX_INDENT) } else { 3.0 }; + let ps = self.paras_of(range); + let lines: Vec<&str> = self.text.split('\n').collect(); + let mut out = String::new(); + let mut fmts: Vec = Vec::new(); + let mut at = 0usize; + for (k, line) in lines.iter().enumerate() { + let n = line.chars().count(); + if k > 0 { + out.push('\n'); + fmts.push(self.fmt.get(at).cloned().unwrap_or_default()); + at += 1; + } + if ps.contains(&k) { + let label = format!("{}\t", style.label(k - ps.start)); + let f = self.fmt.get(at).cloned().unwrap_or_else(|| self.fmt_at(at)); + label.chars().for_each(|_| fmts.push(f.clone())); + out.push_str(&label); + } + out.push_str(line); + fmts.extend(self.fmt.get(at..at + n).unwrap_or_default().iter().cloned()); + at += n; + } + if fmts.len() > MAX_CHARS { + return; + } + self.text = out; + self.fmt = fmts; + let props = ParaProps { first: -indent, left: indent, tabs: vec![(indent, TabKind::Left)], ..ParaProps::default() }; + for p in ps { + if let Some(q) = self.paras.get_mut(p) { + *q = props.clone(); + } + } + } + + /// The MTEXT contents. + pub fn to_contents(&self) -> String { + let mut out = String::new(); + let mut emitted = ParaProps::default(); + let chars: Vec = self.text.chars().collect(); + let mut i = 0; + for (pi, props) in self.paras.iter().enumerate() { + if pi > 0 { + out.push_str("\\P"); + } + if *props != emitted { + out.push_str(&props.code()); + emitted = props.clone(); + } + let end = chars.iter().skip(i).position(|c| *c == '\n').map_or(chars.len(), |n| i + n); + while i < end { + let f = self.fmt.get(i).cloned().unwrap_or_default(); + let mut j = i + 1; + while j < end && self.fmt.get(j) == Some(&f) { + j += 1; + } + let run: String = chars.get(i..j).unwrap_or_default().iter().map(|c| self.escape(*c)).collect(); + if f == CharFmt::default() { + out.push_str(&run); + } else { + out.push('{'); + out.push_str(&self.codes(&f)); + out.push_str(&run); + out.push('}'); + } + i = j; + } + i = end + 1; + } + out + } + + fn escape(&self, c: char) -> String { + match c { + '\\' | '{' | '}' => format!("\\{c}"), + c if (c as u32) >= ATOM_BASE && (c as u32) < ATOM_BASE + MAX_ATOMS as u32 => { + self.atoms.get((c as u32 - ATOM_BASE) as usize).cloned().unwrap_or_default() + } + c => c.to_string(), + } + } + + fn codes(&self, f: &CharFmt) -> String { + let mut s = String::new(); + if f.underline { + s.push_str("\\L"); + } + if f.overline { + s.push_str("\\O"); + } + if f.bold || f.italic || f.font.is_some() { + let fam = f.font.clone().unwrap_or_else(|| self.base_font.clone()); + 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 { + s.push_str(&format!("\\H{};", trim(h))); + } + match f.color { + Some(MTextColor::Aci(v)) => s.push_str(&format!("\\C{v};")), + Some(MTextColor::Rgb(v)) => s.push_str(&format!("\\c{v};")), + None => {} + } + s + } +} + +fn trim(v: f64) -> String { + let s = format!("{v:.4}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn parse_height(a: &str, cur: Option) -> Option { + 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), + } +} + +fn apply_font(cur: &mut CharFmt, a: &str) { + let mut parts = a.split('|'); + let name = parts.next().unwrap_or("").trim(); + if name.is_empty() { + return; + } + cur.font = Some(name.to_string()); + cur.bold = false; + cur.italic = false; + for p in parts { + match p.as_bytes() { + [b'b', v] => cur.bold = *v == b'1', + [b'i', v] => cur.italic = *v == b'1', + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_formats_and_atoms() { + for src in [ + "plain", + "a\\Pb", + "{\\fArial|b1|i0;bold} and {\\Lunder}", + "x \\S1/2; y", + "{\\H2;big}{\\C1;red}{\\c16711680;rgb}", + "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo", + "esc \\{ \\} \\\\", + ] { + let f = Formatted::from_contents(src); + let again = Formatted::from_contents(&f.to_contents()); + assert_eq!(f, again, "{src}"); + } + assert_eq!(Formatted::from_contents("plain").to_contents(), "plain"); + assert!(Formatted::from_contents("x \\S1/2; y").to_contents().contains("\\S1/2;")); + } + + #[test] + fn toggles_and_typing_keep_formats() { + let mut f = Formatted::from_contents("hello world"); + f.toggle(0..5, Flag::Bold); + assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} world"); + assert!(f.all_have(0..5, Flag::Bold)); + // Typing inside the bold run takes the typing format; paragraphs follow line breaks. + let typing = f.fmt_at(2); + assert!(f.set_text("hello big world", &CharFmt::default())); + assert!(f.fmt_at(6) == CharFmt::default() && typing.bold); + assert!(f.set_text("hello big\nworld", &CharFmt::default())); + assert_eq!(f.paragraph_count(), 2); + assert_eq!(f.to_contents(), "{\\fArial|b1|i0;hello} big\\Pworld"); + f.toggle(0..5, Flag::Bold); + assert_eq!(f.to_contents(), "hello big\\Pworld"); + } + + #[test] + fn ruler_and_lists() { + let mut f = Formatted::from_contents("one\ntwo"); + f.set_indents(0..0, -2.0, 4.0); + f.add_tab(0..3, 4.0, TabKind::Left); + let c = f.to_contents(); + assert!(c.starts_with("\\pxi-2,l4,t4;one"), "{c}"); + f.make_list(0..f.len(), ListStyle::Number, 3.0); + assert_eq!(f.text(), "1.\tone\n2.\ttwo"); + assert_eq!(f.to_contents(), "\\pxi-3,l3,t3;1.\tone\\P2.\ttwo"); + f.remove_tab(0..f.len(), 3.0, 0.1); + assert!(!f.to_contents().contains("t3")); + } + + #[test] + fn hostile_input_never_panics() { + for src in + ["\\", "{{{{", "}}}}", "\\f", "\\fArial|b", "\\H;\\H2x;\\Hnan;", "\\S", "\\p;\\pq;\\pt,,,;", "\\C999999999999;", "\u{E000}\u{E001}"] + { + let f = Formatted::from_contents(src); + let _ = f.to_contents(); + } + let mut f = Formatted::from_contents("ab"); + let (a, b) = (5, 1); + f.edit(a..b, |c| c.bold = true); + f.toggle(9..9, Flag::Italic); + f.set_indents(7..9, f64::NAN, 1.0); + f.add_tab(0..99, f64::INFINITY, TabKind::Left); + f.make_list(50..60, ListStyle::Bullet, f64::NAN); + assert!(!f.set_text(&"x".repeat(MAX_CHARS + 1), &CharFmt::default())); + let _ = f.paragraph_of(usize::MAX); + let _ = f.fmt_at(usize::MAX); + let _ = Formatted::from_contents(&"{".repeat(100_000)); + } +} diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index 20388ad..05eb773 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -215,6 +215,11 @@ pub trait Interactive: Send { fn drag(&self, _s: &Session, _cursor: Vec2) -> Option { None } + /// The multiline text being typed (MTEXT, once its box is known) with empty contents: where + /// the UI places its in-place editor and with which height, width and justification. + fn editing_mtext(&self) -> Option { + None + } } /// See [`Interactive::drag`]. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d8ea880..914de61 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -9,7 +9,7 @@ use crate::CadApp; use crate::theme::Tokens; pub fn show(app: &mut CadApp, ctx: &egui::Context) { - mtext_editor(app, ctx); + crate::mtext_editor::show(app, ctx); crate::quick::quick_properties(app, ctx); let Some(d) = app.ui.dialog.clone() else { return }; let mut open = true; @@ -405,103 +405,6 @@ fn insert_block(app: &mut CadApp, name: &str) { } } -/// The editor's lines as a list: a hanging indent of three text heights (`height` is one, in -/// drawing units, as `\p` indents are) with a tab stop there, and each line a bullet or its -/// number and a tab. -fn as_list(buf: &str, numbered: bool, height: f64) -> String { - let lines: Vec = - buf.split('\n').enumerate().map(|(i, l)| if numbered { format!("{}.\t{l}", i + 1) } else { format!("•\t{l}") }).collect(); - let indent = format!("{:.6}", 3.0 * height); - let indent = indent.trim_end_matches('0').trim_end_matches('.'); - format!("\\pxi-{indent},l{indent},t{indent};{}", lines.join("\n")) -} - -/// The multiline text editor shown while MTEXT asks for its contents. -fn mtext_editor(app: &mut CadApp, ctx: &egui::Context) { - let active = app.session.running.as_ref().is_some_and(|r| r.id == "mtext") - && app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point); - let id = egui::Id::new("mtext_editor_buffer"); - if !active { - ctx.data_mut(|d| d.remove::(id)); - return; - } - let mut buf = ctx.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); - let mut submit = None; - let mut cancel = false; - egui::Window::new(crate::tl!("Text Editor")) - .id(egui::Id::new("Text Editor")) - .collapsible(false) - .resizable(true) - .default_size(vec2(460.0, 220.0)) - .show(ctx, |ui| { - ui.horizontal(|ui| { - ui.label(crate::tl!("Style: Standard")); - ui.separator(); - let h = app.session.doc().map(|d| d.header.f64("TEXTSIZE", 0.2)).unwrap_or(0.2); - ui.label(crate::tf!("Height: {height}", height = format!("{h:.4}"))); - ui.separator(); - if ui.button(crate::tl!("B")).on_hover_text(crate::tl!("Bold")).clicked() { - buf.push_str("{\\fArial|b1;}"); - } - if ui.button("⅟").on_hover_text(crate::tl!("Stack (type 1/2 then select)")).clicked() { - buf.push_str("\\S1/2;"); - } - if ui.button("°").on_hover_text(crate::tl!("Degree")).clicked() { - buf.push_str("%%d"); - } - if ui.button("±").on_hover_text(crate::tl!("Plus/minus")).clicked() { - buf.push_str("%%p"); - } - if ui.button("⌀").on_hover_text(crate::tl!("Diameter")).clicked() { - buf.push_str("%%c"); - } - ui.separator(); - if ui.button("⇥").on_hover_text(crate::tl!("Insert tab")).clicked() { - buf.push('\t'); - } - if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { - buf = as_list(&buf, false, h); - } - if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { - buf = as_list(&buf, true, h); - } - }); - let r = ui.add( - egui::TextEdit::multiline(&mut buf) - .desired_rows(6) - .desired_width(f32::INFINITY) - .hint_text(crate::tl!("Type text; Enter starts a new paragraph")), - ); - if !r.has_focus() && buf.is_empty() { - r.request_focus(); - } - ui.horizontal(|ui| { - if ui.button(crate::tl!("OK")).clicked() || (r.has_focus() && ui.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter))) { - submit = Some(buf.replace('\n', "\\P")); - } - if ui.button(crate::tl!("Cancel")).clicked() { - cancel = true; - } - let finish = if ui.ctx().os().is_mac() { crate::tl!("⌘↩ to finish") } else { crate::tl!("Ctrl+Enter to finish") }; - ui.label(RichText::new(finish).small()); - }); - }); - ctx.data_mut(|d| d.insert_temp(id, buf)); - if let Some(t) = submit { - ctx.data_mut(|d| d.remove::(id)); - // 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(); - } -} - fn language(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { egui::Window::new(crate::tl!("Interface Language")).id(egui::Id::new("interface_language")).open(open).resizable(false).show(ctx, |ui| { egui::ComboBox::from_id_salt("interface_language_choice").selected_text(app.ui.interface_language.name()).show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 905eacc..bd1f77d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1028,3 +1028,9 @@ Search for pattern Muster suchen Not a number or distance: {text} Keine Zahl und keine Entfernung: {text} Query Texts Texte abfragen + Italic Kursiv + Underline Unterstrichen + Overline Überstrichen + Justify Blocksatz + Text color Textfarbe + Click the ruler to add a tab stop, right-click to remove one Klick auf das Lineal setzt einen Tabstopp, Rechtsklick entfernt ihn diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 8daf20d..6c46147 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -97,6 +97,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { include_str!("../palettes/patterns.rs"), include_str!("../dialogs.rs"), include_str!("../hatchdlg.rs"), + include_str!("../mtext_editor.rs"), include_str!("../layers.rs"), include_str!("../canvas/compare.rs"), include_str!("../canvas/blockedit.rs"), diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1452707..b702893 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1300,3 +1300,9 @@ contains authoring elements. Open in Block Editor? містить елементи розробки. Відкрити в редакторі блоків? Preview Попередній перегляд New block Новий блок + Italic Курсив + Underline Підкреслений + Overline Надкреслений + Justify За шириною + Text color Колір тексту + Click the ruler to add a tab stop, right-click to remove one Натисніть на лінійку, щоб додати табуляцію; права кнопка видаляє її diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4817db..7e52f53 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod credits; pub mod dialogs; pub mod docking; pub mod hatchdlg; +mod mtext_editor; pub mod spacemouse; // The External References palette. pub mod xrefs; diff --git a/crates/ui-egui/src/mtext_editor.rs b/crates/ui-egui/src/mtext_editor.rs new file mode 100644 index 0000000..9e7db58 --- /dev/null +++ b/crates/ui-egui/src/mtext_editor.rs @@ -0,0 +1,498 @@ +//! The in-place MTEXT editor: while MTEXT asks for its text, a text box opens on the canvas at +//! 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. + +use std::sync::Arc; + +use cadcraft_engine::mtext_edit::{CharFmt, Flag, Formatted, ListStyle, MTextColor, ParaAlign, TabKind}; +use egui::text::{CCursor, CCursorRange, LayoutJob}; +use egui::{Align2, Color32, FontId, Rect, RichText, Sense, Shape, Stroke, TextFormat, pos2, vec2}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// Smallest and largest size of the text in the box, in points: the box stays readable at any zoom. +const MIN_PX: f32 = 13.0; +const MAX_PX: f32 = 36.0; +/// Families the font list offers besides the one set in the text. +const FAMILIES: [&str; 6] = ["Arial", "Times New Roman", "Courier New", "Verdana", "Georgia", "Calibri"]; +/// The standard colours (ACI 1..=7) of the colour menu. +const SWATCHES: [u16; 7] = [1, 2, 3, 4, 5, 6, 7]; +/// Ruler snap, in text heights. +const SNAP: f64 = 0.25; + +#[derive(Clone)] +struct State { + fmt: Formatted, + /// Format of the next typed character when nothing is selected. + typing: CharFmt, + /// Caret the `typing` format was set for. + typing_at: usize, + /// Height of the toolbar and ruler above the text box (last frame). + top: f32, + /// Selection (characters) of the last frame, for toolbar actions of this one. + sel: (usize, usize), + /// A caret position to set on the text box in the next frame (after a toolbar insertion). + caret: Option, +} + +fn state_id() -> egui::Id { + egui::Id::new("mtext_editor_state") +} + +fn edit_id() -> egui::Id { + egui::Id::new("mtext_editor_text") +} + +/// The ACI index or true colour as a screen colour. +fn color32(c: MTextColor) -> Color32 { + let rgb = match c { + MTextColor::Aci(v) => cadcraft_color::aci_rgb(u8::try_from(v).unwrap_or(7)), + MTextColor::Rgb(v) => cadcraft_color::Rgb((v >> 16) as u8, (v >> 8) as u8, v as u8), + }; + 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. +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); + let (Some(mt), true) = (mtext, asking) else { + ctx.data_mut(|d| d.remove::(state_id())); + return; + }; + 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(); + 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 } + }); + 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); + let width = if mt.width > 0.0 { ((mt.width * scale) as f32).clamp(280.0, rect.width().max(280.0) * 0.9) } else { 380.0 }; + // The box's top-left corner on screen (rotation is ignored while editing). + let code = usize::from(mt.attach.clamp(1, 9) - 1); + let (col, row) = ((code % 3) as f64, (code / 3) as f64); + let ww = f64::from(width) / scale; + let corner = cadcraft_geom::Vec2::new(mt.insert.x - col * ww / 2.0, mt.insert.y + row * base_h * 2.0); + let at = app.canvas.xf.map_or(rect.center(), |x| x.to_screen(corner)); + let max_x = (rect.right() - width - 8.0).max(rect.left()); + let pos = pos2(at.x.clamp(rect.left(), max_x), at.y.clamp(rect.top() + st.top, (rect.bottom() - 120.0).max(rect.top() + st.top))); + + let mut done = Done::No; + let top_start = pos.y - st.top; + egui::Area::new(egui::Id::new("mtext_editor_area")).order(egui::Order::Foreground).fixed_pos(pos2(pos.x, top_start)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).inner_margin(egui::Margin::same(0)).show(ui, |ui| { + ui.set_width(width); + let y0 = ui.cursor().top(); + toolbar(ui, &mut st, base_h); + ruler(ui, &mut st, px, width, base_h); + st.top = ui.cursor().top() - y0; + text_box(ui, &mut st, base_h, px, width, &t); + ui.horizontal(|ui| { + ui.add_space(4.0); + if ui.button(crate::tl!("OK")).clicked() { + done = Done::Ok; + } + if ui.button(crate::tl!("Cancel")).clicked() { + done = Done::Cancel; + } + let finish = if ui.ctx().os().is_mac() { crate::tl!("⌘↩ to finish") } else { crate::tl!("Ctrl+Enter to finish") }; + ui.label(RichText::new(finish).small().color(t.text_dim)); + }); + }); + }); + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + done = Done::Cancel; + } else if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Enter)) { + done = Done::Ok; + } + match done { + Done::No => { + ctx.data_mut(|d| d.insert_temp(state_id(), st)); + } + 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. + if !st.fmt.is_empty() { + let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())); + } + if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") { + let _ = app.session.input(cadcraft_engine::Input::Enter); + } + } + Done::Cancel => { + ctx.data_mut(|d| d.remove::(state_id())); + app.session.cancel(); + } + } +} + +#[derive(PartialEq)] +enum Done { + No, + Ok, + Cancel, +} + +/// Change the selected characters' format, or the next typed ones' when nothing is selected. +fn change(st: &mut State, f: impl Fn(&mut CharFmt)) { + let (a, b) = st.sel; + if a == b { + f(&mut st.typing); + } else { + st.fmt.edit(a.min(b)..a.max(b), f); + } +} + +fn current(st: &State) -> CharFmt { + let (a, b) = st.sel; + if a == b { st.typing.clone() } else { st.fmt.fmt_at(a.min(b)) } +} + +fn toggle(st: &mut State, flag: Flag) { + let (a, b) = st.sel; + if a == b { + let on = !flag_of(&st.typing, flag); + change(st, |c| set_flag(c, flag, on)); + } else { + st.fmt.toggle(a.min(b)..a.max(b), flag); + } +} + +fn flag_of(c: &CharFmt, f: Flag) -> bool { + match f { + Flag::Bold => c.bold, + Flag::Italic => c.italic, + Flag::Underline => c.underline, + Flag::Overline => c.overline, + } +} + +fn set_flag(c: &mut CharFmt, f: Flag, on: bool) { + match f { + Flag::Bold => c.bold = on, + Flag::Italic => c.italic = on, + Flag::Underline => c.underline = on, + Flag::Overline => c.overline = on, + } +} + +fn toolbar(ui: &mut egui::Ui, st: &mut State, base_h: f64) { + let cur = current(st); + let range = st.sel.0.min(st.sel.1)..st.sel.0.max(st.sel.1); + let mut refocus = false; + ui.horizontal_wrapped(|ui| { + ui.add_space(4.0); + // Font + let shown = cur.font.clone().unwrap_or_else(|| st.fmt.base_font.clone()); + egui::ComboBox::from_id_salt("mtext_font").width(120.0).selected_text(shown.clone()).show_ui(ui, |ui| { + for fam in FAMILIES.iter().map(|s| s.to_string()).chain(cur.font.clone().filter(|f| !FAMILIES.contains(&f.as_str()))) { + if ui.selectable_label(shown == fam, &fam).clicked() { + change(st, |c| c.font = Some(fam.clone())); + refocus = true; + } + } + }); + // Height + let mut h = cur.height.unwrap_or(base_h); + if ui + .add(egui::DragValue::new(&mut h).speed(base_h * 0.02).range(base_h * 0.05..=base_h * 50.0).max_decimals(4)) + .on_hover_text(crate::tl!("Text height")) + .changed() + { + let none = (h - base_h).abs() < 1e-9; + change(st, |c| c.height = (!none).then_some(h)); + } + ui.separator(); + for (flag, label, tip) in [ + (Flag::Bold, RichText::new("B").strong(), crate::tl!("Bold")), + (Flag::Italic, RichText::new("I").italics(), crate::tl!("Italic")), + (Flag::Underline, RichText::new("U").underline(), crate::tl!("Underline")), + (Flag::Overline, RichText::new("O").strikethrough(), crate::tl!("Overline")), + ] { + if ui.selectable_label(flag_of(&cur, flag), label).on_hover_text(tip).clicked() { + toggle(st, flag); + refocus = true; + } + } + // Colour + let swatch = cur.color.map_or(ui.visuals().text_color(), color32); + ui.menu_button(RichText::new("A▾").color(swatch).strong(), |ui| { + if ui.button(crate::tl!("ByLayer")).clicked() { + change(st, |c| c.color = None); + refocus = true; + ui.close(); + } + ui.horizontal(|ui| { + for v in SWATCHES { + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + ui.painter().rect_filled(r, 2.0, color32(MTextColor::Aci(v))); + if resp.clicked() { + change(st, |c| c.color = Some(MTextColor::Aci(v))); + refocus = true; + ui.close(); + } + } + }); + let mut rgb = cur.color.map_or([255, 255, 255], |c| { + let k = color32(c); + [k.r(), k.g(), k.b()] + }); + if ui.color_edit_button_srgb(&mut rgb).changed() { + let v = (u32::from(rgb[0]) << 16) | (u32::from(rgb[1]) << 8) | u32::from(rgb[2]); + change(st, |c| c.color = Some(MTextColor::Rgb(v))); + } + }) + .response + .on_hover_text(crate::tl!("Text color")); + ui.separator(); + // Paragraph alignment + let para = st.fmt.para(st.fmt.paragraph_of(range.start)); + for (al, glyph, tip) in [ + (ParaAlign::Left, "⇤", crate::tl!("Left")), + (ParaAlign::Center, "↔", crate::tl!("Center")), + (ParaAlign::Right, "⇥", crate::tl!("Right")), + (ParaAlign::Justify, "≡", crate::tl!("Justify")), + ] { + let on = para.align == al; + if ui.selectable_label(on, glyph).on_hover_text(tip).clicked() { + st.fmt.edit_paras(range.clone(), |p| p.align = if on { ParaAlign::Default } else { al }); + refocus = true; + } + } + ui.separator(); + // Lists and special characters + if ui.button("•≡").on_hover_text(crate::tl!("Bulleted list")).clicked() { + st.fmt.make_list(range.clone(), ListStyle::Bullet, 3.0 * base_h); + refocus = true; + } + if ui.button("1≡").on_hover_text(crate::tl!("Numbered list")).clicked() { + st.fmt.make_list(range.clone(), ListStyle::Number, 3.0 * base_h); + refocus = true; + } + for (glyph, tip, code) in [ + ("⇥|", crate::tl!("Insert tab"), "\t"), + ("°", crate::tl!("Degree"), "°"), + ("±", crate::tl!("Plus/minus"), "±"), + ("⌀", crate::tl!("Diameter"), "⌀"), + ] { + if ui.button(glyph).on_hover_text(tip).clicked() { + insert(st, code); + refocus = true; + } + } + }); + if refocus { + ui.ctx().memory_mut(|m| m.request_focus(edit_id())); + } +} + +/// Insert `s` at the caret (over the selection). +fn insert(st: &mut State, s: &str) { + let (a, b) = (st.sel.0.min(st.sel.1), st.sel.0.max(st.sel.1)); + let chars: Vec = st.fmt.text().chars().collect(); + let new: String = chars.iter().take(a).chain(s.chars().collect::>().iter()).chain(chars.iter().skip(b)).collect(); + if st.fmt.set_text(&new, &st.typing.clone()) { + let at = a + s.chars().count(); + st.sel = (at, at); + st.typing_at = at; + st.caret = Some(at); + } +} + +/// The ruler: `px` points per text height (`base_h` drawing units); paragraph indents and tab +/// stops are in drawing units, as `\p` codes are. +fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) { + let t = Tokens::get(); + let range = st.sel.0.min(st.sel.1)..st.sel.0.max(st.sel.1); + let p = st.fmt.para(st.fmt.paragraph_of(range.start)); + let (rect, bg) = ui.allocate_exact_size(vec2(width, 20.0), Sense::click()); + let painter = ui.painter_at(rect); + painter.rect_filled(rect, 0.0, t.control); + let tick = |u: f64| rect.left() + (u as f32) * px; + let x_of = |v: f64| tick(v / base_h); + let units = (f64::from(width) / f64::from(px)).floor() as i32; + for u in 0..=units.clamp(0, 400) { + let x = tick(f64::from(u)); + let major = u % 5 == 0; + painter.line_segment([pos2(x, rect.bottom() - if major { 7.0 } else { 3.0 }), pos2(x, rect.bottom())], Stroke::new(1.0, t.text_faint)); + if major && u > 0 { + painter.text(pos2(x + 2.0, rect.top() + 1.0), Align2::LEFT_TOP, u.to_string(), FontId::proportional(8.0), t.text_faint); + } + } + let to_units = |x: f32| ((f64::from(x - rect.left()) / f64::from(px) / SNAP).round() * SNAP).clamp(0.0, 400.0) * base_h; + // Tab stops: an L at the stop; a right-click removes one. + for (pos, _kind) in &p.tabs { + let x = x_of(*pos); + painter.add(Shape::line( + vec![pos2(x, rect.bottom() - 9.0), pos2(x, rect.bottom() - 2.0), pos2(x + 4.0, rect.bottom() - 2.0)], + Stroke::new(1.5, t.accent), + )); + } + let hover = bg.hover_pos(); + // Indent markers: the first line's above, the left indent's below. + let first_abs = p.left + p.first; + let mut drag = None; + for (k, (u, up)) in [(first_abs, false), (p.left, true)].into_iter().enumerate() { + let x = x_of(u); + let y = if up { rect.bottom() - 1.0 } else { rect.top() + 1.0 }; + let dir = if up { -1.0 } else { 1.0 }; + let hit = Rect::from_center_size(pos2(x, y + dir * 4.0), vec2(10.0, 10.0)); + let r = ui.interact(hit, ui.id().with(("mtext_indent", k)), Sense::drag()); + let col = if r.hovered() || r.dragged() { t.accent } else { t.icon }; + painter.add(Shape::convex_polygon(vec![pos2(x - 4.0, y), pos2(x + 4.0, y), pos2(x, y + dir * 8.0)], col, Stroke::NONE)); + if r.dragged() + && let Some(m) = r.interact_pointer_pos() + { + drag = Some((k, to_units(m.x))); + } + } + if let Some((k, u)) = drag { + if k == 0 { + st.fmt.set_indents(range.clone(), u - p.left, p.left); + } else { + st.fmt.set_indents(range.clone(), p.first, u); + } + } else if bg.clicked() + && let Some(m) = hover + { + st.fmt.add_tab(range.clone(), to_units(m.x), TabKind::Left); + } else if bg.secondary_clicked() + && let Some(m) = hover + { + st.fmt.remove_tab(range, to_units(m.x), 0.6 * base_h); + } + bg.on_hover_text(crate::tl!("Click the ruler to add a tab stop, right-click to remove one")); +} + +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) + { + s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at)))); + s.store(ui.ctx(), id); + } + let fmt = st.fmt.clone(); + let default_color = t.text; + let mut layouter = move |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap: f32| -> Arc { + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap; + let s = text.as_str(); + let mut run = String::new(); + let mut cur: Option = None; + let flush = |job: &mut LayoutJob, run: &mut String, f: &CharFmt| { + if run.is_empty() { + return; + } + let size = f.height.map_or(px, |h| (px * (h / base_h) as f32).clamp(6.0, 120.0)); + let color = f.color.map_or(default_color, color32); + job.append( + run, + 0.0, + TextFormat { + font_id: FontId::proportional(size), + color, + italics: f.italic, + underline: if f.underline { Stroke::new(1.0, color) } else { Stroke::NONE }, + // The editor has one weight of each font: bold is shown wider. + extra_letter_spacing: if f.bold { size * 0.06 } else { 0.0 }, + ..TextFormat::default() + }, + ); + run.clear(); + }; + for (i, c) in s.chars().enumerate() { + let f = fmt.fmt_at(i); + if cur.as_ref() != Some(&f) { + if let Some(p) = &cur { + flush(&mut job, &mut run, p); + } + cur = Some(f); + } + run.push(c); + } + if let Some(p) = &cur { + flush(&mut job, &mut run, p); + } + ui.fonts_mut(|f| f.layout_job(job)) + }; + let mut buf = st.fmt.text().to_string(); + let out = egui::TextEdit::multiline(&mut buf) + .id(id) + .desired_width(width) + .desired_rows(5) + .margin(egui::Margin::symmetric(4, 4)) + .lock_focus(true) + .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() { + out.response.request_focus(); + } + if out.response.changed() { + let typing = st.typing.clone(); + st.fmt.set_text(&buf, &typing); + } + if let Some(r) = out.cursor_range { + let (a, b) = (r.primary.index.0, r.secondary.index.0); + st.sel = (a, b); + if a == b { + if out.response.changed() { + st.typing_at = a; + } else if a != st.typing_at { + // The caret moved: typing continues the formatting of the character before it. + st.typing = st.fmt.fmt_at(a.saturating_sub(1)); + st.typing_at = a; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cadcraft_engine::Session; + + use crate::Services; + + #[test] + fn editor_opens_only_for_mtext_text_entry_and_finishes_with_formatted_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(); + } + }; + frame(&mut app); + assert!(ctx.data_mut(|d| d.get_temp::(state_id())).is_none()); + app.start("mtext"); + assert!(app.session.editing_mtext().is_none(), "no box yet"); + app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(1.0, 1.0))).unwrap(); + app.session.input(cadcraft_engine::Input::Point(cadcraft_geom::Vec2::new(6.0, 0.0))).unwrap(); + let m = app.session.editing_mtext().expect("box known"); + assert!((m.width - 5.0).abs() < 1e-9, "{}", m.width); + frame(&mut app); + let mut st = ctx.data_mut(|d| d.get_temp::(state_id())).expect("editor open"); + st.fmt.set_text("hello", &CharFmt::default()); + st.fmt.toggle(0..5, Flag::Bold); + let contents = st.fmt.to_contents(); + app.session.input(cadcraft_engine::Input::Text(contents.clone())).unwrap(); + app.session.input(cadcraft_engine::Input::Enter).unwrap(); + let d = app.session.doc().unwrap(); + let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents)); + assert!(found, "{contents}"); + } +} diff --git a/crates/ui-egui/tests/bedit_dialog.rs b/crates/ui-egui/tests/bedit_dialog.rs index d12f145..85367e4 100644 --- a/crates/ui-egui/tests/bedit_dialog.rs +++ b/crates/ui-egui/tests/bedit_dialog.rs @@ -134,6 +134,13 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { std::fs::write(&symbol, "authoring").unwrap(); let mut app = CadApp::new(Session::new(), Services::default()); app.open_path(&symbol.to_string_lossy()); + // The file is read in the background (#797): wait for it to be added. + let t = std::time::Instant::now(); + while app.opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { + app.finish_opening(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.finish_opening(); assert_eq!(app.ui.dialog.as_deref(), Some("authoring")); let ctx = egui::Context::default(); app.logic(&ctx); @@ -157,6 +164,13 @@ fn opening_a_drawing_with_authoring_elements_offers_the_block_editor() { std::fs::write(&plain, "plain").unwrap(); let mut app = CadApp::new(Session::new(), Services::default()); app.open_path(&plain.to_string_lossy()); + // The file is read in the background (#797): wait for it to be added. + let t = std::time::Instant::now(); + while app.opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { + app.finish_opening(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.finish_opening(); assert!(app.session.state().unwrap().path.is_some(), "opened"); assert!(app.ui.dialog.is_none()); let _ = std::fs::remove_dir_all(&dir); diff --git a/docs/gaps.md b/docs/gaps.md index 30c9bbd..ce6c976 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -31,8 +31,8 @@ do them first. | F5 | Associative arrays (ASsociative, Levels, polar ROWs, ARRAYEDIT, ARRAYCLOSE) landed (#448); left: grip editing of arrays (count/spacing grips), ARRAYPATH is not associative, ARRAYEDIT has no item deletion (CTRL+click) and no Key point base points, levels only shift Z (no 3D view) | `cmd/array.rs`, `cmd/array/edit.rs` | Medium | 4–6 | | F7 | Dimensions: no DIMSTYLE manager dialog; no smart DIM; DIMTFILL/DIMTFILLCLR, DIMALTTD/DIMALTTZ, DIMUPT and DIMTXTDIRECTION not modelled; jog height and break size not style settings; DIMBREAK breaks don't follow moved objects (style variables, DIMJOGGED, DIMJOGLINE, DIMBREAK, DIMEDIT, DIMINSPECT and QDIM modes done, #507) | `doc/src/tables.rs`, `cmd/annotate_dimedit.rs`, `cmd/annotate_qdim.rs` | Medium | 10–15 | | F8 | TOLERANCE leftovers (feature control frames landed, #420: model, our own symbol drawing, DXF, command-line prompts, explode, grips): no Geometric Tolerance dialog, no LEADER/QLEADER tolerance annotation, no in-place editing of a frame's contents | `cmd/tolerance.rs` | Low-medium (mechanical) | 2–4 | -| F9 | MTEXT is edited in a dialog box, not in place (no ruler for indents and tabs, no formatting toolbar on the canvas); no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/dialogs.rs` | Medium | 15–25 | | F10 | MLEADEREDIT (add/remove leader), MLEADERALIGN and MLEADERCOLLECT landed, but a multileader has one landing and one block (a collection keeps the other blocks as block references beside it; MLEADERALIGN has no Options-menu preview); MLEADERSTYLE has 5 fields (block content and spline/no-leader types are per command, not in the style; block content has no attributes) | `annotate.rs`, `cmd/mleader_content.rs`, `tables.rs:417` | Medium | 8–12 | +| F9 | MTEXT is typed in an on-canvas editor with a formatting toolbar (font, height, bold, italic, underline, overline, colour, alignment, lists, tab and special characters) and a ruler for indents and tab stops (#532); left: the box does not lay the text out by the indents, tabs, columns or font weights while typing (bold shows wider, overline is not drawn), no justification (attachment) or columns controls in the editor, existing MTEXT cannot be edited (no DDEDIT/MTEDIT), stacked fractions and other codes show as placeholders; no fields (FIELD, UPDATEFIELD) and no spell check (SPELL): open PR #551. Paragraph indents, tabs, bullets, numbering and columns landed (#547) | `ui-egui/mtext_editor.rs`, `engine/mtext_edit.rs` | Medium | 10–18 | | F11 | Tables (cell styles, formulas, TABLEDIT, TABLEEXPORT, table breaking and CSV data links landed, #534): formulas aren't read from or written as AutoCAD FIELD objects (other programs see values); number formats apply to formula results only; cell text doesn't wrap; custom cell styles, breaks and data links travel as CADCraft xdata (not through DWG); data links read CSV only (no Excel ranges, no write-back, not on the web); no cell-style tab in the Table Style dialog | `cmd/table/*`, `io/dxf_table.rs` | Low-medium | 6–10 | | F12 | Annotative scaling done (#468): scale list (SCALELISTEDIT), CANNOSCALE, OBJECTSCALE, ANNOALLVISIBLE/ANNOAUTOSCALE, viewport scale, DXF, status bar list. Left: per-scale positions (ANNORESET is a no-op), a viewport annotation scale apart from its viewport scale, annotative objects inside blocks and old LEADERs, AutoCAD files' per-object scale contexts (read as not annotative) | `cmd/annoscale.rs`, `doc/src/anno.rs` | Low-medium | 4–8 | | F13 | Hatch: 23 patterns (AutoCAD ~85; ours must stay original); the Hatch and Gradient and Hatch Edit dialogs have no Preview or Inherit Properties and can't add or remove boundaries of an existing hatch (HPDLGMODE 0 keeps HATCH on the command line), no MPolygon; HATCHEDIT can't add or remove boundaries; a hatch copied with its boundary isn't linked to the copy; HPGAPTOL closes gaps between curve ends only | `doc/src/library.rs`, `cmd/hatch.rs`, `cmd/hatch/edit.rs` | Medium | 8–12 | @@ -155,6 +155,7 @@ Detail: [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | F3: opening a dynamic block's own drawing (authoring elements in model space) offers the Block Editor on ``, as AutoCAD does | | 2026-10-12 | minor | Edit Block Definition dialog (block list, preview, description; a new name starts a new block): F3 text updated, hours unchanged | | 2026-10-12 | minor | Block Editor (BEDIT, BSAVE, BSAVEAS, BCLOSE) for static blocks landed: rows 4 (65–100 → 55–90 h) and F3 (50–75 → 40–65 h) narrowed to REFEDIT and dynamic blocks | +| 2026-10-12 | minor | In-place MTEXT editor with formatting toolbar and indent/tab ruler landed (#532): F9 narrowed to the editor leftovers, fields and spell check | | 2026-10-12 | minor | SCRIPT command reading `.scr` files landed (runs a file through the same script engine as CLI/MCP; nested scripts refused): E2 removed | | 2026-10-11 | minor | Refreshed for merged features: code pages (#505), tracking leftovers (#441), dynamic input/grip input (#519, #425) and SPLINEDIT Join/Add/Elevate (#559) closed their top-10 rows (top 10 now has 6 rows until the next review re-ranks); MULTILEADER (#459) and xrefs (#525) narrowed rows 1 and 4; F1 narrowed to the remaining stubs (#558, #559, #547); F5, F7, F9, F12, F14, F15, F17, F20, F22, U5, U7, U10, FF2, FF4, FF10 narrowed; F18, U2, U8, U9 removed (done); U13, S3, S4, P1 updated for merged fixes | | 2026-10-11 | minor | German interface catalog landed: L1 row count 936 → 989, L2 narrowed (de done; 40–60 → 35–55 h) |