diff --git a/Cargo.lock b/Cargo.lock index 8ec69c2ae..46c869500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 66f6f4b6e..7d8eed8d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/mod.rs b/src/app/mod.rs index ce660b5df..9e2399984 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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. diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index a2d562e43..16ce96737 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -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 { /// 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) diff --git a/src/app/update.rs b/src/app/update.rs index a1353b987..e7e94d48e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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() diff --git a/src/app/view.rs b/src/app/view.rs index f0e728e5c..8804776bb 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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 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, diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index f8da50789..4ee68dc93 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -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 { + 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 { let mut lines: Vec = 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 { 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 { diff --git a/src/scene/lff.rs b/src/scene/lff.rs index ea4c34296..a3b44ec6a 100644 --- a/src/scene/lff.rs +++ b/src/scene/lff.rs @@ -723,4 +723,3 @@ mod tests { } } } -