forked from 1b-agent/opencadstudio-upstream
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:
1 parent
50115c55d8
commit
7190df8274
5 files changed
+280
-5
No files matched your search
@@ -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,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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user