TEXTEDIT on an MTEXT opens it in the in-place editor (part of #842)

Double-clicking an MTEXT (#868) starts TEXTEDIT, which only asked for
the new text on the command line, with its formatting codes. Now TEXTEDIT
on an MTEXT reports it as the text being edited, so the in-place editor
(#792) opens on it with its text and formats; OK writes the edited
contents back, and TEXTEDIT goes on asking for objects as before. An
editor emptied while editing keeps the text, as an empty answer does.

The editor also takes keyboard focus when it opens on existing text,
with the caret at the end: it only did so for an empty box, so typing
into an existing text went to the command line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aldo Zarate
2026-10-13 10:50:32 -06:00
co-authored by Claude Opus 5.5
parent e707ab4d7c
commit 7003c5b930
2 changed files with 69 additions and 9 deletions
+14 -1
View File
@@ -2203,12 +2203,18 @@ struct TextEditM {
single: bool,
/// Asking for the edit mode.
mode: bool,
/// The MTEXT being edited, for the in-place editor (#842).
mtext: Option<cadcraft_doc::MText>,
}
impl Interactive for TextEditM {
fn name(&self) -> &'static str {
"TEXTEDIT"
}
/// An MTEXT asking for its new text opens in the in-place editor with its contents.
fn editing_mtext(&self) -> Option<cadcraft_doc::MText> {
self.h.as_ref().and(self.mtext.clone())
}
fn prompt(&self, _s: &Session) -> Prompt {
if self.mode {
let current = if self.single { "Single" } else { "Multiple" };
@@ -2233,7 +2239,12 @@ impl Interactive for TextEditM {
match (&self.h, i) {
(None, Input::Point(p)) => {
let h = curves::pick_at(s, p).ok_or_else(|| other("*Invalid selection*"))?;
let old = text_of(&curves::entity(s, h)?.kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
let kind = curves::entity(s, h)?.kind;
let old = text_of(&kind).ok_or_else(|| other("Select a text, mtext, attribute definition or dimension."))?;
self.mtext = match kind {
EntityKind::MText(m) => Some(m),
_ => None,
};
self.h = Some((h, old));
Ok(Step::Continue)
}
@@ -2243,10 +2254,12 @@ impl Interactive for TextEditM {
set_text(s, h, &t)?;
self.edits.push((h, before));
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(Some(_), Input::Enter) => {
self.h = None;
self.mtext = None;
Ok(if self.single { Step::Done } else { Step::Continue })
}
(None, Input::Enter) => Ok(Step::Done),
+55 -8
View File
@@ -2,7 +2,8 @@
//! the text's location and width with a formatting toolbar (font, height, bold, italic,
//! underline, overline, colour, alignment, lists, tabs) and a ruler for the paragraph's indents
//! and tab stops. The text and its formats live in the engine's
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits.
//! [`cadcraft_engine::mtext_edit::Formatted`]; this file only draws and forwards edits. TEXTEDIT
//! (and so a double-click) on an existing MTEXT opens it here with its contents (#842).
use std::sync::Arc;
@@ -36,6 +37,9 @@ struct State {
sel: (usize, usize),
/// A caret position to set on the text box in the next frame (after a toolbar insertion).
caret: Option<usize>,
/// The text box has had keyboard focus: it takes it once when the editor opens, so typing
/// goes into the text, not to the command line.
focused: bool,
}
fn state_id() -> egui::Id {
@@ -55,7 +59,8 @@ fn color32(c: MTextColor) -> Color32 {
Color32::from_rgb(rgb.0, rgb.1, rgb.2)
}
/// Show the editor while MTEXT collects its text; clears its state when it isn't running.
/// Show the editor while MTEXT, or TEXTEDIT on an MTEXT, collects its text; clears its state
/// when neither is running.
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let mtext = app.session.editing_mtext();
let asking = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point);
@@ -66,14 +71,17 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
let t = Tokens::get();
let (rect, scale) = app.canvas.xf.map_or((ctx.content_rect(), 20.0), |x| (x.rect, x.scale.max(1e-6)));
let mut st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).unwrap_or_else(|| {
let mut fmt = Formatted::default();
// An existing MTEXT (TEXTEDIT) starts with its contents and their formats.
let mut fmt = if mt.contents.is_empty() { Formatted::default() } else { Formatted::from_contents(&mt.contents) };
if let Some(f) = app.session.doc().ok().and_then(|d| d.text_style(&mt.style).map(|s| s.font.clone())) {
// A family name, not a font file (`txt.shx`, `arial.ttf`).
if !f.is_empty() && !f.contains('.') {
fmt.base_font = f;
}
}
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret: None }
// An existing text opens with the caret at its end.
let caret = (!fmt.is_empty()).then(|| fmt.len());
State { fmt, typing: CharFmt::default(), typing_at: 0, top: 70.0, sel: (0, 0), caret, focused: false }
});
let base_h = if mt.height > 0.0 { mt.height } else { 0.2 };
let px = ((base_h * scale) as f32).clamp(MIN_PX, MAX_PX);
@@ -122,8 +130,12 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) {
Done::Ok => {
ctx.data_mut(|d| d.remove::<State>(state_id()));
// The editor holds the whole contents: one input, then Enter ends MTEXT's text entry.
// Emptied while editing an existing MTEXT: it keeps its text (Enter), as TEXTEDIT
// keeps it for an empty answer.
if !st.fmt.is_empty() {
let _ = app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents()));
} else if app.session.running.as_ref().is_some_and(|r| r.id == "textedit") {
let _ = app.session.input(cadcraft_engine::Input::Enter);
}
if app.session.running.as_ref().is_some_and(|r| r.id == "mtext") {
let _ = app.session.input(cadcraft_engine::Input::Enter);
@@ -376,9 +388,8 @@ fn ruler(ui: &mut egui::Ui, st: &mut State, px: f32, width: f32, base_h: f64) {
fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32, t: &Tokens) {
let id = edit_id();
if let Some(at) = st.caret.take()
&& let Some(mut s) = egui::TextEdit::load_state(ui.ctx(), id)
{
if let Some(at) = st.caret.take() {
let mut s = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
s.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(at))));
s.store(ui.ctx(), id);
}
@@ -436,9 +447,10 @@ fn text_box(ui: &mut egui::Ui, st: &mut State, base_h: f64, px: f32, width: f32,
.char_limit(cadcraft_engine::mtext_edit::MAX_CHARS)
.layouter(&mut layouter)
.show(ui);
if !out.response.has_focus() && buf.is_empty() && st.fmt.is_empty() {
if !out.response.has_focus() && (!st.focused || (buf.is_empty() && st.fmt.is_empty())) {
out.response.request_focus();
}
st.focused = true;
if out.response.changed() {
let typing = st.typing.clone();
st.fmt.set_text(&buf, &typing);
@@ -495,4 +507,39 @@ mod tests {
let found = d.model.iter().any(|e| matches!(&e.kind, cadcraft_engine::doc::EntityKind::MText(m) if m.contents == contents));
assert!(found, "{contents}");
}
/// #842: TEXTEDIT on an existing MTEXT (what a double-click starts) opens it in the editor
/// with its text and formats; finishing writes the edited contents back to that MTEXT.
#[test]
fn textedit_opens_an_existing_mtext_with_its_contents() {
let ctx = egui::Context::default();
let mut app = CadApp::new(Session::new(), Services::default());
let frame = |app: &mut CadApp| {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() };
for _ in 0..2 {
let mut out = ctx.run_ui(input.clone(), |ui| show(app, ui.ctx()));
out.textures_delta.clear();
}
};
app.run("mtext", serde_json::json!({ "at": [0, 20], "text": "{\\fArial|b1;first}\\Psecond", "height": 2.5, "width": 20 })).unwrap();
let h = app.session.doc().unwrap().model.last().unwrap().handle;
app.start("textedit");
let at = cadcraft_geom::Vec2::new(1.0, 19.0);
app.session.input(cadcraft_engine::Input::Point(at)).unwrap();
let m = app.session.editing_mtext().expect("an MTEXT to edit");
assert!(m.contents.contains("second"), "{}", m.contents);
frame(&mut app);
let st = ctx.data_mut(|d| d.get_temp::<State>(state_id())).expect("editor open");
assert_eq!(st.fmt.text(), "first\nsecond");
assert!(st.fmt.fmt_at(0).bold && !st.fmt.fmt_at(7).bold, "formats kept");
let mut st = st;
st.fmt.set_text("first\nsecond\nthird", &CharFmt::default());
app.session.input(cadcraft_engine::Input::Text(st.fmt.to_contents())).unwrap();
frame(&mut app);
assert!(ctx.data_mut(|d| d.get_temp::<State>(state_id())).is_none(), "editor closed");
match &app.session.doc().unwrap().entity(h).unwrap().kind {
cadcraft_engine::doc::EntityKind::MText(m) => assert!(m.contents.contains("third") && m.contents.contains("b1"), "{}", m.contents),
_ => panic!(),
}
}
}