diff --git a/src/app/mod.rs b/src/app/mod.rs index 31af67932..fa1e4fe80 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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. diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index 98f7c0edf..a2d562e43 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -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; diff --git a/src/app/update.rs b/src/app/update.rs index 2800b076a..058600b2e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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() diff --git a/src/app/view.rs b/src/app/view.rs index 28cf98b24..f0e728e5c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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, /// 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 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) diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index bfc4d0fc9..bfdef3830 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -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 = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i];