In-place MTEXT editor with formatting toolbar and indent/tab ruler (#532) (#792)

The MTEXT text entry opens a text box on the canvas at the text's location and
width instead of a dialog: font, height, bold, italic, underline, overline,
colour, paragraph alignment, lists, tab and special characters, plus a ruler for
the paragraph's indents and tab stops. Text, per-character formats and paragraph
properties live in engine::mtext_edit (parse and write MTEXT codes, opaque codes
kept verbatim), tested headlessly.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein 2026-10-13 12:07:55 +02:00 committed by GitHub
parent d826450e03
commit e74b276dfd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1122 additions and 99 deletions

View File

@ -1602,6 +1602,10 @@ impl Interactive for MTextM {
}
Ok(Step::Continue)
}
fn editing_mtext(&self) -> Option<MText> {
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<EntityKind> {
match (self.first, self.boxed(), self.ask) {
(Some(a), false, None) => {

View File

@ -84,6 +84,15 @@ impl Interactive for Guarded {
None
})
}
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
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)]

View File

@ -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<cadcraft_doc::MText> {
self.running.as_ref().and_then(|r| r.machine.editing_mtext())
}
pub fn current_prompt(&self) -> Option<Prompt> {
if let Some(p) = self.lisp_prompt() {
return Some(p);

View File

@ -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<String>,
/// Absolute character height set inline (`\H`); `None`: the text's.
pub height: Option<f64>,
/// Colour set inline (`\C`, `\c`); `None`: the entity's (ByLayer/ByBlock).
pub color: Option<MTextColor>,
}
/// 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<CharFmt>,
/// One per paragraph (`\n` count + 1).
paras: Vec<ParaProps>,
/// The original code of each atom.
atoms: Vec<String>,
/// 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<char> = contents.chars().collect();
let mut stack: Vec<CharFmt> = 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::<u16>().ok().and_then(|v| (v > 0 && v < 256).then_some(MTextColor::Aci(v)));
}
'c' => {
if let Ok(v) = a.trim().parse::<u32>() {
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<char> = self.text.chars().collect();
let new_c: Vec<char> = 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<CharFmt> = 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<usize>) -> std::ops::Range<usize> {
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<usize>, 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<usize>, 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<usize>, 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<usize>) -> std::ops::Range<usize> {
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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<CharFmt> = 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<char> = 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<f64>) -> Option<f64> {
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::<f64>().ok().zip(cur).map(|(m, h)| h * m).filter(|h| h.is_finite() && *h > 0.0 && *h < 1e6),
None => a.parse::<f64>().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));
}
}

View File

@ -215,6 +215,11 @@ pub trait Interactive: Send {
fn drag(&self, _s: &Session, _cursor: Vec2) -> Option<Drag> {
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<cadcraft_doc::MText> {
None
}
}
/// See [`Interactive::drag`].

View File

@ -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<String> =
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::<String>(id));
return;
}
let mut buf = ctx.data_mut(|d| d.get_temp::<String>(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::<String>(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::<String>(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| {

View File

@ -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

Can't render this file because it has a wrong number of fields in line 7.

View File

@ -97,6 +97,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
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"),

View File

@ -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 Натисніть на лінійку, щоб додати табуляцію; права кнопка видаляє її

Can't render this file because it has a wrong number of fields in line 8.

View File

@ -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;

View File

@ -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<usize>,
}
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>(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>(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>(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>(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<char> = st.fmt.text().chars().collect();
let new: String = chars.iter().take(a).chain(s.chars().collect::<Vec<_>>().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<egui::Galley> {
let mut job = LayoutJob::default();
job.wrap.max_width = wrap;
let s = text.as_str();
let mut run = String::new();
let mut cur: Option<CharFmt> = 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>(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>(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}");
}
}

View File

@ -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);

View File

@ -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 `<Current Drawing>`, 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) |