feat(mtext): edit text directly in the preview with a caret

The rendered MText preview is now an editable surface: it shows a text caret,
click to position it between glyphs, and type/Backspace/Delete/Enter to edit.
Left/Right arrows move the caret; drag-selection + toolbar formatting still
work and typing replaces a selection.

Keystrokes are routed by mapping visible-character offsets to byte spans in
the value (raw newlines count as paragraph breaks, matching the layout). While
the preview is open the command line releases its on_input so typed glyphs
reach the preview instead, and the caret starts at the end of the text so
typing works without a click first.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-05-31 10:16:30 +03:00
1 parent 50115c55d8
commit 7190df8274
5 files changed
+280 -5

No files matched your search

+8
View File
@@ -744,6 +744,14 @@ pub enum Message {
MTextSelStart(usize),
/// Extend the preview selection to the given visible-character offset.
MTextSelTo(usize),
/// Type text at the preview caret (replacing any selection).
MTextType(String),
/// Backspace at the preview caret.
MTextBackspace,
/// Delete-forward at the preview caret.
MTextDelete,
/// Move the preview caret by N visible characters.
MTextCaretMove(i32),
/// Commit the editor: create or update the MText entity.
MTextOk,
/// Discard the editor without creating / changing the entity.
+121
View File
@@ -121,6 +121,9 @@ pub struct MTextEditorState {
pub sel: Option<(usize, usize)>,
/// Anchor offset for an in-progress drag selection.
pub sel_anchor: usize,
/// Text caret as a visible-character offset (0..=count). Used for typing
/// directly into the preview.
pub caret: usize,
/// Canvas-space anchor where the toolbar + text area are drawn (the
/// insertion-point click position).
pub screen_anchor: iced::Point,
@@ -146,6 +149,7 @@ impl MTextEditorState {
glyph_boxes: Vec::new(),
sel: None,
sel_anchor: 0,
caret: 0,
attachment: AttachmentPoint::TopLeft,
line_spacing: 1.0,
// Default box ~20 characters wide; overwritten with the entity's
@@ -263,6 +267,7 @@ 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
'%' if it.peek().map(|&(_, c)| c) == Some('%') => {
it.next(); // second '%'
match it.peek().copied() {
@@ -340,6 +345,12 @@ impl super::OpenCADStudio {
}
self.mtext_editor = Some(state);
self.rebuild_mtext_preview();
// Place the caret at the end so typing works without a click first.
let end = self.mtext_vis_count();
if let Some(ed) = self.mtext_editor.as_mut() {
ed.caret = end;
ed.sel = Some((end, end));
}
}
/// Re-tessellate the current editor text into the editor's OWN preview
@@ -468,6 +479,116 @@ impl super::OpenCADStudio {
self.rebuild_mtext_preview();
}
// ── Caret editing directly on the preview ───────────────────────────────
/// Insert text (or replace the current selection) at the preview caret.
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 spans = visible_spans(&raw);
let added = visible_spans(s).len();
let (new_text, new_caret) = match ed.sel {
Some((a, b)) if a < b && a < spans.len() && b <= spans.len() => {
let (start, end) = (spans[a].0, spans[b - 1].1);
let mut t = raw.clone();
t.replace_range(start..end, s);
(t, a + added)
}
_ => {
let byte = spans.get(ed.caret).map(|s| s.0).unwrap_or(raw.len());
let mut t = raw.clone();
t.insert_str(byte, s);
(t, ed.caret + added)
}
};
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
}
self.rebuild_mtext_preview();
}
/// Delete the selection, or the visible character before the caret.
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 spans = visible_spans(&raw);
let (new_text, new_caret) = match ed.sel {
Some((a, b)) if a < b && a < spans.len() && b <= spans.len() => {
let (start, end) = (spans[a].0, spans[b - 1].1);
let mut t = raw.clone();
t.replace_range(start..end, "");
(t, a)
}
_ if ed.caret > 0 && ed.caret <= spans.len() => {
let (start, end) = (spans[ed.caret - 1].0, spans[ed.caret - 1].1);
let mut t = raw.clone();
t.replace_range(start..end, "");
(t, ed.caret - 1)
}
_ => (raw, ed.caret),
};
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
}
self.rebuild_mtext_preview();
}
/// Delete the selection, or the visible character at the caret.
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 spans = visible_spans(&raw);
let (new_text, new_caret) = match ed.sel {
Some((a, b)) if a < b && a < spans.len() && b <= spans.len() => {
let (start, end) = (spans[a].0, spans[b - 1].1);
let mut t = raw.clone();
t.replace_range(start..end, "");
(t, a)
}
_ if ed.caret < spans.len() => {
let (start, end) = (spans[ed.caret].0, spans[ed.caret].1);
let mut t = raw.clone();
t.replace_range(start..end, "");
(t, ed.caret)
}
_ => (raw, ed.caret),
};
ed.content = iced::widget::text_editor::Content::with_text(&new_text);
ed.caret = new_caret;
ed.sel = Some((new_caret, new_caret));
}
self.rebuild_mtext_preview();
}
/// Move the caret by `delta` visible characters (clears the selection).
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 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));
}
}
/// Visible-character count of the current text.
pub(super) fn mtext_vis_count(&self) -> usize {
self.mtext_editor
.as_ref()
.map(|ed| {
let raw0 = ed.content.text();
let raw = raw0.strip_suffix('\n').unwrap_or(&raw0);
visible_spans(raw).len()
})
.unwrap_or(0)
}
/// Commit the editor — create a new MText or update the edited one.
pub(super) fn mtext_commit(&mut self) {
let i = self.active_tab;
+48 -1
View File
@@ -879,6 +879,13 @@ impl OpenCADStudio {
}
Message::CommandAppendChar(s) => {
// While the MText preview is up, typed glyphs edit it directly.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
if s.chars().all(|c| !c.is_control()) {
self.mtext_type(&s);
}
return Task::none();
}
// Filter out control characters — only push the typed
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
if s.chars().all(|c| !c.is_control()) {
@@ -919,6 +926,10 @@ impl OpenCADStudio {
}
Message::CommandBackspace => {
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_backspace();
return Task::none();
}
let i = self.active_tab;
// Backspace edits the focused dynamic-input field first;
// emptying it unlocks the field (back to cursor tracking).
@@ -1201,6 +1212,11 @@ impl OpenCADStudio {
}
Message::CommandFinalize => {
// In the MText preview, Enter inserts a line break.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_type("\n");
return Task::none();
}
// Grip popup open → Enter commits the highlighted item.
if self.grip_popup.is_some() {
let idx = self
@@ -3443,6 +3459,11 @@ impl OpenCADStudio {
}
Message::DeleteSelected => {
// In the MText preview, Delete removes text at the caret.
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {
self.mtext_delete();
return Task::none();
}
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().context_menu = None;
let handles: Vec<_> = self.tabs[i].scene.selected.iter().cloned().collect();
@@ -3544,12 +3565,21 @@ impl OpenCADStudio {
ed.show_preview = on;
}
self.rebuild_mtext_preview();
Task::none()
// Focus the text area when switching to Edit so the caret
// shows and typing/clicking edits immediately.
if on {
Task::none()
} else {
iced::widget::operation::focus(iced::widget::Id::new(
super::view::MTEXT_TEXT_ID,
))
}
}
Message::MTextSelStart(off) => {
if let Some(ed) = self.mtext_editor.as_mut() {
ed.sel_anchor = off;
ed.sel = Some((off, off));
ed.caret = off;
}
Task::none()
}
@@ -3557,9 +3587,26 @@ impl OpenCADStudio {
if let Some(ed) = self.mtext_editor.as_mut() {
let a = ed.sel_anchor;
ed.sel = Some((a.min(off), a.max(off)));
ed.caret = off;
}
Task::none()
}
Message::MTextType(s) => {
self.mtext_type(&s);
Task::none()
}
Message::MTextBackspace => {
self.mtext_backspace();
Task::none()
}
Message::MTextDelete => {
self.mtext_delete();
Task::none()
}
Message::MTextCaretMove(d) => {
self.mtext_caret_move(d);
Task::none()
}
Message::MTextOk => {
self.mtext_commit();
Task::none()
+75 -4
View File
@@ -828,8 +828,12 @@ impl OpenCADStudio {
let allow_autocomplete = tab.active_cmd.is_none();
// Dynamic input captures keystrokes when its fields are showing,
// so the command-line field must release focus / its on_input.
let dyn_capturing =
self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty();
// The MText preview also captures keystrokes (typing edits it), so the
// command line must likewise release its on_input there.
let dyn_capturing = (self.dyn_input
&& tab.active_cmd.is_some()
&& !tab.dyn_fields.is_empty())
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview);
let command_line_overlay =
iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing))
.width(Fill)
@@ -1060,12 +1064,28 @@ impl OpenCADStudio {
{
Some(Message::DynTabNext)
}
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
keyboard::Key::Named(keyboard::key::Named::ArrowUp)
if status == Status::Ignored =>
{
Some(Message::CommandHistoryPrev)
}
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => {
keyboard::Key::Named(keyboard::key::Named::ArrowDown)
if status == Status::Ignored =>
{
Some(Message::CommandHistoryNext)
}
// Caret movement in the MText preview (no-op
// otherwise; these arrows are unused elsewhere).
keyboard::Key::Named(keyboard::key::Named::ArrowLeft)
if status == Status::Ignored =>
{
Some(Message::MTextCaretMove(-1))
}
keyboard::Key::Named(keyboard::key::Named::ArrowRight)
if status == Status::Ignored =>
{
Some(Message::MTextCaretMove(1))
}
keyboard::Key::Named(keyboard::key::Named::F3) => {
Some(Message::ToggleSnapEnabled)
}
@@ -1323,6 +1343,9 @@ fn position_canvas_overlay<'a>(
// ── In-place MText editor overlay ───────────────────────────────────────────
/// Widget id for the MText editor's text area (focused when Edit mode opens).
pub(super) const MTEXT_TEXT_ID: &str = "mtext_editor_text";
// Stroke-font families the renderer ships (LibreCAD LFF; see scene/lff.rs).
const MTEXT_FONTS: [&str; 10] = [
"[Style default]",
@@ -1360,6 +1383,8 @@ struct MTextPreview {
boxes: Vec<crate::entities::text_support::GlyphBox>,
/// Current selection as a visible-char range.
sel: Option<(usize, usize)>,
/// Caret position as a visible-char offset.
caret: usize,
/// World-space min corner (bbox) and pixels-per-world-unit scale.
minx: f32,
miny: f32,
@@ -1495,6 +1520,50 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
});
frame.stroke(&path, Stroke::default().with_color(*col).with_width(1.4));
}
// 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);
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| {
p.move_to(iced::Point::new(MTEXT_PREVIEW_PAD, MTEXT_PREVIEW_PAD));
p.line_to(iced::Point::new(
MTEXT_PREVIEW_PAD,
(MTEXT_PREVIEW_PAD + 22.0).min(self.content_h),
));
});
frame.stroke(
&path,
Stroke::default()
.with_color(Color { r: 0.95, g: 0.95, b: 0.55, a: 1.0 })
.with_width(1.5),
);
} else if collapsed {
let bar = if let Some(b) = self.boxes.iter().find(|b| b.vis == self.caret) {
Some((b.xmin, b.ymin, b.ymax)) // left edge of the caret's glyph
} else if self.caret > 0 {
self.boxes
.iter()
.find(|b| b.vis == self.caret - 1)
.map(|b| (b.xmax, b.ymin, b.ymax)) // after the last glyph
} else {
self.boxes.first().map(|b| (b.xmin, b.ymin, b.ymax))
};
if let Some((cx, y0, y1)) = bar {
let p0 = map(cx, y0);
let p1 = map(cx, y1);
let path = Path::new(|p| {
p.move_to(p0);
p.line_to(p1);
});
frame.stroke(
&path,
Stroke::default()
.with_color(Color { r: 0.95, g: 0.95, b: 0.55, a: 1.0 })
.with_width(1.5),
);
}
}
vec![frame.into_geometry()]
}
}
@@ -1729,6 +1798,7 @@ fn mtext_editor_overlay<'a>(
segments,
boxes: ed.glyph_boxes.clone(),
sel: ed.sel,
caret: ed.caret,
minx,
miny,
scale,
@@ -1746,6 +1816,7 @@ fn mtext_editor_overlay<'a>(
.into()
} else {
text_editor(&ed.content)
.id(iced::widget::Id::new(MTEXT_TEXT_ID))
.on_action(Message::MTextEdit)
.height(iced::Length::Fixed(VIEW_H))
.padding(6)
+28
View File
@@ -189,10 +189,12 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve
}
let mut dists = vec![0.0_f32; n];
let mut has_break = false;
for i in 1..n {
let p = wire.points[i - 1];
let q = wire.points[i];
if !p[0].is_finite() || !q[0].is_finite() {
has_break = true;
// plinegen=false: reset to 0 at the first real point after a NaN separator.
dists[i] = if !wire.plinegen && !p[0].is_finite() && q[0].is_finite() {
0.0
@@ -207,6 +209,32 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve
}
}
// Center the dash pattern on the wire instead of starting it at the first
// vertex. The shader reads the pattern phase as `dist % pattern_length`, so
// adding a constant offset to every arc-length shifts that phase. We place
// the wire midpoint at the center of the first dash element, which makes the
// line begin and end with matching partial dashes. Skipped for wires with
// NaN breaks (per-segment dash restarts), where a single offset can't center
// every segment.
let pat_len = wire.pattern_length;
if pat_len > 1e-6 && !has_break && n >= 2 {
let total = dists[n - 1];
if total > 1e-6 {
// First dash element (positive), else fall back to the first element.
let first_dash = wire
.pattern
.iter()
.copied()
.find(|&v| v > 0.0)
.unwrap_or_else(|| wire.pattern[0].abs());
// Phase that puts the wire midpoint at the dash center.
let offset = first_dash * 0.5 - total * 0.5;
for d in dists.iter_mut() {
*d += offset;
}
}
}
let mut instances: Vec<WireInstance> = Vec::with_capacity(seg_count);
for i in 0..seg_count {
let a = wire.points[i];