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:
parent
9fa33ec7a0
commit
ac9022676f
8 changed files with 162 additions and 26 deletions
40
Cargo.lock
generated
40
Cargo.lock
generated
|
|
@ -328,6 +328,17 @@ dependencies = [
|
|||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-fs"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
|
||||
dependencies = [
|
||||
"async-lock",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
|
|
@ -357,6 +368,17 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-net"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
|
|
@ -2092,6 +2114,7 @@ dependencies = [
|
|||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"smol",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
]
|
||||
|
|
@ -4873,6 +4896,23 @@ dependencies = [
|
|||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-executor",
|
||||
"async-fs",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-net",
|
||||
"async-process",
|
||||
"blocking",
|
||||
"futures-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ edition = "2021"
|
|||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
iced = { version = "0.14", features = ["debug", "image", "svg", "advanced", "canvas"] }
|
||||
iced = { version = "0.14", features = ["debug", "image", "svg", "advanced", "canvas", "smol"] }
|
||||
bytemuck = { version = "1.25", features = ["derive"] }
|
||||
glam = { version = "0.33", features = ["bytemuck"] }
|
||||
truck-modeling = "0.6"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 ¶[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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -427,6 +427,20 @@ fn flush_glyph_buf(line: &mut MTextLine, buf: &mut String, state: &RunState) {
|
|||
/// the height-multiplier representation carried in [`RunState`]; pass the
|
||||
/// MTEXT entity's `height` field.
|
||||
pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
|
||||
parse_mtext_paragraphs_ex(s, entity_height, true)
|
||||
}
|
||||
|
||||
/// Like [`parse_mtext_paragraphs`] but with control over blank-edge trimming.
|
||||
///
|
||||
/// `trim_blank_edges` drops leading and trailing blank paragraphs (the
|
||||
/// rendering default, so a stray trailing `\P` adds no empty space). The MText
|
||||
/// editor passes `false` so a freshly inserted newline keeps its empty
|
||||
/// paragraph and the caret can sit on the new line.
|
||||
pub fn parse_mtext_paragraphs_ex(
|
||||
s: &str,
|
||||
entity_height: f32,
|
||||
trim_blank_edges: bool,
|
||||
) -> Vec<MTextLine> {
|
||||
let mut lines: Vec<MTextLine> = Vec::new();
|
||||
let mut current = MTextLine::default();
|
||||
let mut buf = String::new();
|
||||
|
|
@ -692,6 +706,9 @@ pub fn parse_mtext_paragraphs(s: &str, entity_height: f32) -> Vec<MTextLine> {
|
|||
current.tab_stops = props.tab_stops.clone();
|
||||
lines.push(current);
|
||||
|
||||
if !trim_blank_edges {
|
||||
return lines;
|
||||
}
|
||||
let start = lines.iter().position(|l| !l.is_blank()).unwrap_or(0);
|
||||
let end = lines
|
||||
.iter()
|
||||
|
|
@ -1123,7 +1140,9 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
let rect_w = opts.rect_w;
|
||||
|
||||
// ── 1. Parse ─────────────────────────────────────────────────────────
|
||||
let paragraphs = parse_mtext_paragraphs(opts.value, entity_h);
|
||||
// The editor (want_glyph_boxes) keeps blank edges so a freshly typed
|
||||
// trailing newline yields an empty paragraph the caret can sit on.
|
||||
let paragraphs = parse_mtext_paragraphs_ex(opts.value, entity_h, !opts.want_glyph_boxes);
|
||||
|
||||
// ── 2. Atomise + wrap each paragraph into sub-lines ──────────────────
|
||||
struct SubLine {
|
||||
|
|
@ -1179,13 +1198,19 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
// on the paragraph's visible content. Without this a stray trailing
|
||||
// space measures wider than it draws and centring / right-alignment
|
||||
// is off by half a space-width.
|
||||
let first_word = atoms
|
||||
.iter()
|
||||
.position(|a| !matches!(a.kind, AtomKind::Space))
|
||||
.unwrap_or(atoms.len());
|
||||
atoms.drain(..first_word);
|
||||
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||||
atoms.pop();
|
||||
//
|
||||
// Skipped when emitting glyph boxes (the MText editor) so a space the
|
||||
// user just typed at the end keeps a selectable box and the caret can
|
||||
// sit after it.
|
||||
if !opts.want_glyph_boxes {
|
||||
let first_word = atoms
|
||||
.iter()
|
||||
.position(|a| !matches!(a.kind, AtomKind::Space))
|
||||
.unwrap_or(atoms.len());
|
||||
atoms.drain(..first_word);
|
||||
while matches!(atoms.last().map(|a| &a.kind), Some(AtomKind::Space)) {
|
||||
atoms.pop();
|
||||
}
|
||||
}
|
||||
|
||||
let wrapped = wrap_paragraph(
|
||||
|
|
@ -1327,6 +1352,22 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
.map(|a| a.state.height_mul * entity_h)
|
||||
.fold(entity_h, f32::max);
|
||||
|
||||
// A paragraph break (explicit `\n` / `\P`) that started this line gets
|
||||
// a zero-width caret slot at the line start, so the MText editor can
|
||||
// place the caret on a fresh/empty line.
|
||||
if opts.want_glyph_boxes && i > 0 && sub.is_first_in_paragraph {
|
||||
let (ax, ay) = to_world(line_base_x, line_base_y, cursor_start, 0.0);
|
||||
let (_, by) = to_world(line_base_x, line_base_y, cursor_start, entity_h);
|
||||
glyph_boxes.push(GlyphBox {
|
||||
vis,
|
||||
xmin: ax,
|
||||
xmax: ax,
|
||||
ymin: ay.min(by),
|
||||
ymax: ay.max(by),
|
||||
});
|
||||
vis += 1;
|
||||
}
|
||||
|
||||
let mut cursor_x = cursor_start;
|
||||
for atom in &sub.atoms {
|
||||
match &atom.kind {
|
||||
|
|
|
|||
|
|
@ -723,4 +723,3 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue