feat(mtext): editable preview with caret, blink, and new-line support

Type directly into the rendered MText preview: click to position the
caret, insert/delete, Enter for a new line, Space for a literal space,
and arrow keys to move. The caret blinks when idle.

Keep the trailing empty paragraph in the editor so the caret appears on a
fresh line immediately after Enter, before any typing. parse_mtext_paragraphs
split into a _ex variant; layout_mtext skips the blank-edge trim when
emitting glyph boxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-31 13:14:35 +03:00
commit ac9022676f
8 changed files with 162 additions and 26 deletions

View file

@ -550,6 +550,9 @@ pub enum Message {
WindowResized(f32, f32),
/// Enter pressed globally — finalises the active command (no text-input involvement).
CommandFinalize,
/// Space pressed globally — a literal space in the MText preview, otherwise
/// finalises like Enter.
CommandSpace,
/// Escape pressed globally — cancels the active command.
CommandEscape,
/// Toggle the global snap on/off (OSNAP button body click).
@ -746,6 +749,8 @@ pub enum Message {
MTextSelTo(usize),
/// Move the preview caret by N visible characters.
MTextCaretMove(i32),
/// Timer tick toggling the preview caret's blink phase.
MTextCaretBlink,
/// Commit the editor: create or update the MText entity.
MTextOk,
/// Discard the editor without creating / changing the entity.

View file

@ -124,6 +124,9 @@ pub struct MTextEditorState {
/// Text caret as a visible-character offset (0..=count). Used for typing
/// directly into the preview.
pub caret: usize,
/// Blink phase — the caret is drawn only when true; reset to true on any
/// edit/caret move so it's solid right after activity.
pub caret_blink_on: bool,
/// Canvas-space anchor where the toolbar + text area are drawn (the
/// insertion-point click position).
pub screen_anchor: iced::Point,
@ -150,6 +153,7 @@ impl MTextEditorState {
sel: None,
sel_anchor: 0,
caret: 0,
caret_blink_on: true,
attachment: AttachmentPoint::TopLeft,
line_spacing: 1.0,
// Default box ~20 characters wide; overwritten with the entity's
@ -169,8 +173,10 @@ impl MTextEditorState {
/// dropdowns and value fields set. Per-selection toggles already live
/// inside the text.
pub fn composed_value(&self) -> String {
// No trailing-newline strip: a trailing line break is a real empty
// line the user typed; keeping it lets the layout emit the caret slot
// so the caret shows on the new line right after Enter.
let body = self.content.text();
let body = body.strip_suffix('\n').unwrap_or(&body);
let mut prefix = String::new();
if !self.font.trim().is_empty() {
prefix.push_str(&format!("\\f{};", self.font.trim()));
@ -224,23 +230,24 @@ fn parse_non_default(s: &str, default: f64) -> Option<f64> {
/// spaces trimmed per paragraph, inline codes skipped). Lets a preview
/// selection (visible-char range) be spliced back into the raw value.
pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
let is_sp = |c: char| c == ' ' || c == '\u{00A0}';
let mut result: Vec<(usize, usize)> = Vec::new();
let mut para: Vec<(usize, usize, char)> = Vec::new();
// No leading/trailing-space trim here: the editor's layout keeps those
// boxes (want_glyph_boxes), so caret offsets must count every space.
let flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| {
let s = para.iter().position(|t| !is_sp(t.2)).unwrap_or(para.len());
let e = para.iter().rposition(|t| !is_sp(t.2)).map(|i| i + 1).unwrap_or(s);
for t in &para[s..e] {
for t in para.drain(..) {
result.push((t.0, t.1));
}
para.clear();
};
let mut it = raw.char_indices().peekable();
while let Some((i, ch)) = it.next() {
match ch {
'\\' => match it.peek().map(|&(_, c)| c) {
Some('P') | Some('n') | Some('N') => {
it.next();
let (j, c) = it.next().unwrap();
// Paragraph break gets a caret slot (matches the layout's
// line-start box), then the paragraph flushes.
para.push((i, j + c.len_utf8(), '\n'));
flush(&mut para, &mut result);
}
Some('~') => {
@ -267,7 +274,11 @@ pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> {
None => {}
},
'{' | '}' => { /* group markers — not visible */ }
'\n' | '\r' => flush(&mut para, &mut result), // raw line break = paragraph
'\n' | '\r' => {
// Raw line break = paragraph break with a caret slot.
para.push((i, i + ch.len_utf8(), '\n'));
flush(&mut para, &mut result);
}
'%' if it.peek().map(|&(_, c)| c) == Some('%') => {
it.next(); // second '%'
match it.peek().copied() {
@ -401,7 +412,6 @@ impl super::OpenCADStudio {
return false;
}
let raw = ed.content.text();
let raw = raw.strip_suffix('\n').unwrap_or(&raw).to_string();
let spans = visible_spans(&raw);
if a >= spans.len() || b > spans.len() {
return false;
@ -485,7 +495,7 @@ impl super::OpenCADStudio {
pub(super) fn mtext_type(&mut self, s: &str) {
if let Some(ed) = self.mtext_editor.as_mut() {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0).to_string();
let raw = raw0.clone();
let spans = visible_spans(&raw);
let added = visible_spans(s).len();
let (new_text, new_caret) = match ed.sel {
@ -505,6 +515,7 @@ impl super::OpenCADStudio {
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
ed.caret_blink_on = true;
}
self.rebuild_mtext_preview();
}
@ -513,7 +524,7 @@ impl super::OpenCADStudio {
pub(super) fn mtext_backspace(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0).to_string();
let raw = raw0.clone();
let spans = visible_spans(&raw);
let (new_text, new_caret) = match ed.sel {
Some((a, b)) if a < b && a < spans.len() && b <= spans.len() => {
@ -533,6 +544,7 @@ impl super::OpenCADStudio {
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
ed.caret_blink_on = true;
}
self.rebuild_mtext_preview();
}
@ -541,7 +553,7 @@ impl super::OpenCADStudio {
pub(super) fn mtext_delete(&mut self) {
if let Some(ed) = self.mtext_editor.as_mut() {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0).to_string();
let raw = raw0.clone();
let spans = visible_spans(&raw);
let (new_text, new_caret) = match ed.sel {
Some((a, b)) if a < b && a < spans.len() && b <= spans.len() => {
@ -561,6 +573,7 @@ impl super::OpenCADStudio {
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
ed.caret_blink_on = true;
}
self.rebuild_mtext_preview();
}
@ -569,11 +582,12 @@ impl super::OpenCADStudio {
pub(super) fn mtext_caret_move(&mut self, delta: i32) {
if let Some(ed) = self.mtext_editor.as_mut() {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0);
let raw = raw0.as_str();
let n = visible_spans(raw).len() as i32;
let c = (ed.caret as i32 + delta).clamp(0, n) as usize;
ed.caret = c;
ed.sel = Some((c, c));
ed.caret_blink_on = true;
}
}
@ -583,7 +597,7 @@ impl super::OpenCADStudio {
.as_ref()
.map(|ed| {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0);
let raw = raw0.as_str();
visible_spans(raw).len()
})
.unwrap_or(0)

View file

@ -1211,6 +1211,15 @@ impl OpenCADStudio {
Task::none()
}
Message::CommandSpace => {
// Space is a literal space inside the MText preview; otherwise
// it finalises the active command like Enter.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_type(" ");
return Task::none();
}
return self.update(Message::CommandFinalize);
}
Message::CommandFinalize => {
// In the MText preview, Enter inserts a line break.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
@ -3580,6 +3589,7 @@ impl OpenCADStudio {
ed.sel_anchor = off;
ed.sel = Some((off, off));
ed.caret = off;
ed.caret_blink_on = true;
}
Task::none()
}
@ -3588,6 +3598,7 @@ impl OpenCADStudio {
let a = ed.sel_anchor;
ed.sel = Some((a.min(off), a.max(off)));
ed.caret = off;
ed.caret_blink_on = true;
}
Task::none()
}
@ -3595,6 +3606,12 @@ impl OpenCADStudio {
self.mtext_caret_move(d);
Task::none()
}
Message::MTextCaretBlink => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.caret_blink_on = !ed.caret_blink_on;
}
Task::none()
}
Message::MTextOk => {
self.mtext_commit();
Task::none()

View file

@ -998,10 +998,18 @@ impl OpenCADStudio {
} else {
Subscription::none()
};
// Blink the MText preview caret while the editor is open.
let caret_blink = if self.mtext_editor.is_some() {
iced::time::every(std::time::Duration::from_millis(530))
.map(|_| Message::MTextCaretBlink)
} else {
Subscription::none()
};
iced::Subscription::batch([
frames,
history_tick,
grip_dwell,
caret_blink,
event::listen_with(|ev, status, win_id| {
use iced::event::Status;
match ev {
@ -1040,8 +1048,15 @@ impl OpenCADStudio {
}
}
match key {
// Space is a literal space inside the MText preview
// but finalises a command otherwise; the handler
// decides based on editor state.
keyboard::Key::Named(keyboard::key::Named::Space)
if status == Status::Ignored =>
{
Some(Message::CommandSpace)
}
keyboard::Key::Named(keyboard::key::Named::Enter)
| keyboard::Key::Named(keyboard::key::Named::Space)
if status == Status::Ignored =>
{
Some(Message::CommandFinalize)
@ -1385,6 +1400,8 @@ struct MTextPreview {
sel: Option<(usize, usize)>,
/// Caret position as a visible-char offset.
caret: usize,
/// Whether the caret is in its visible blink phase.
caret_on: bool,
/// World-space min corner (bbox) and pixels-per-world-unit scale.
minx: f32,
miny: f32,
@ -1522,7 +1539,9 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
}
// Caret — a vertical bar at the caret's glyph boundary, shown when the
// selection is empty (a plain text cursor).
let collapsed = self.sel.map(|(a, b)| a == b).unwrap_or(true);
// Caret is shown only when the selection is empty and the blink is in
// its visible phase.
let collapsed = self.caret_on && self.sel.map(|(a, b)| a == b).unwrap_or(true);
if collapsed && self.boxes.is_empty() {
// Empty text: show a caret at the top-left so the user can type.
let path = Path::new(|p| {
@ -1799,6 +1818,7 @@ fn mtext_editor_overlay<'a>(
boxes: ed.glyph_boxes.clone(),
sel: ed.sel,
caret: ed.caret,
caret_on: ed.caret_blink_on,
minx,
miny,
scale,