diff --git a/src/app/mod.rs b/src/app/mod.rs index 25d638b28..31af67932 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -740,6 +740,10 @@ pub enum Message { /// Switch the editor body between raw code input (`false`) and the /// rendered preview (`true`). MTextShowPreview(bool), + /// Begin a preview text selection at the given visible-character offset. + MTextSelStart(usize), + /// Extend the preview selection to the given visible-character offset. + MTextSelTo(usize), /// 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 58ae81c41..c6dd1d24c 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -114,6 +114,13 @@ pub struct MTextEditorState { /// When true the panel shows the rendered preview; when false the raw /// code/text input. Toggled so the two never stack. pub show_preview: bool, + /// Per-visible-character boxes (world XY, world_offset already removed) for + /// click-to-select in the preview, and the current selection as a visible- + /// character range `[start, end)`. + pub glyph_boxes: Vec, + pub sel: Option<(usize, usize)>, + /// Anchor offset for an in-progress drag selection. + pub sel_anchor: usize, /// Canvas-space anchor where the toolbar + text area are drawn (the /// insertion-point click position). pub screen_anchor: iced::Point, @@ -136,6 +143,9 @@ impl MTextEditorState { char_space: "0".to_string(), preview_wires: Vec::new(), show_preview: true, + glyph_boxes: Vec::new(), + sel: None, + sel_anchor: 0, attachment: AttachmentPoint::TopLeft, line_spacing: 1.0, // Default box ~20 characters wide; overwritten with the entity's @@ -191,9 +201,6 @@ impl MTextEditorState { } } - pub fn build_entity(&self) -> EntityType { - EntityType::MText(self.build_mtext()) - } } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -207,6 +214,97 @@ fn parse_non_default(s: &str, default: f64) -> Option { } } +/// Map each visible character of a raw MText value to its byte span +/// `(start, end)` in that raw string, in the same reading order the layout +/// counts glyph boxes (paragraphs split on `\P`/`\n`/`\N`, leading/trailing +/// 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(); + let mut 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] { + 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(); + flush(&mut para, &mut result); + } + Some('~') => { + let (j, c) = it.next().unwrap(); + para.push((i, j + c.len_utf8(), '\u{00A0}')); + } + Some('\\') | Some('{') | Some('}') => { + let (j, c) = it.next().unwrap(); + para.push((i, j + c.len_utf8(), c)); + } + Some(c) if "LlOoKk".contains(c) => { + it.next(); // value-less toggle, no visible glyph + } + Some(_) => { + // Value code (\f… \C… \H… \pxq… \U… etc) — skip to ';'. + it.next(); + while let Some(&(_, c)) = it.peek() { + it.next(); + if c == ';' { + break; + } + } + } + None => {} + }, + '{' | '}' => { /* group markers — not visible */ } + '%' if it.peek().map(|&(_, c)| c) == Some('%') => { + it.next(); // second '%' + match it.peek().copied() { + Some((k, '%')) => { + it.next(); + para.push((i, k + 1, '%')); + } + Some((_, d)) if d.is_ascii_digit() => { + let mut last = i; + let mut n = 0; + while n < 3 { + match it.peek().copied() { + Some((m, c)) if c.is_ascii_digit() => { + last = m; + it.next(); + n += 1; + } + _ => break, + } + } + para.push((i, last + 1, '\u{25A1}')); + } + Some((m, c)) => { + it.next(); + let g = match c { + 'd' | 'D' => '°', + 'c' | 'C' => 'Ø', + 'p' | 'P' => '±', + other => other, + }; + para.push((i, m + c.len_utf8(), g)); + } + None => para.push((i, i + 1, '%')), + } + } + _ => para.push((i, i + ch.len_utf8(), ch)), + } + } + flush(&mut para, &mut result); + result +} + /// Prefix/suffix inline codes for a character-level toggle. `Uppercase` / /// `Lowercase` return `None` (they transform the selected text instead). pub fn fmt_wrap(kind: MTextFmt) -> Option<(&'static str, &'static str)> { @@ -266,7 +364,8 @@ impl super::OpenCADStudio { pub(super) fn rebuild_mtext_preview(&mut self) { let i = self.active_tab; let Some(ed) = self.mtext_editor.as_ref() else { return }; - let entity = ed.build_entity(); + let mt = ed.build_mtext(); + let entity = EntityType::MText(mt.clone()); let woff = self.tabs[i].scene.world_offset; let anno = self.tabs[i].scene.annotation_scale; let wires: Vec = tessellate::tessellate( @@ -281,35 +380,94 @@ impl super::OpenCADStudio { woff, anno, ); + // Per-character boxes in the same offset-subtracted frame as the + // preview wires (tessellate removes world_offset; mirror that). + let mut boxes = crate::entities::mtext::glyph_boxes(&mt, &self.tabs[i].scene.document); + let (ox, oy) = (woff[0] as f32, woff[1] as f32); + for b in &mut boxes { + b.xmin -= ox; + b.xmax -= ox; + b.ymin -= oy; + b.ymax -= oy; + } if let Some(ed) = self.mtext_editor.as_mut() { ed.preview_wires = wires; + ed.glyph_boxes = boxes; } } - /// Apply a character-format toggle to the current selection (or insert at - /// the cursor when there is no selection). + /// Splice text around the preview selection (visible-char range) in the + /// raw value. `case` optionally transforms the selected slice. Returns + /// true when a preview selection was present and applied. + fn mtext_splice_sel(&mut self, prefix: &str, suffix: &str, case: Option) -> bool { + let Some(ed) = self.mtext_editor.as_mut() else { return false }; + let Some((a, b)) = ed.sel else { return false }; + if a >= b { + 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; + } + let start = spans[a].0; + let end = spans[b - 1].1; + let mut s = raw; + if let Some(upper) = case { + let slice = &s[start..end]; + let repl = if upper { slice.to_uppercase() } else { slice.to_lowercase() }; + s.replace_range(start..end, &repl); + // Length may change; recompute end for the suffix insert. + let new_end = start + repl.len(); + s.insert_str(new_end, suffix); + s.insert_str(start, prefix); + } else { + s.insert_str(end, suffix); + s.insert_str(start, prefix); + } + ed.content = iced::widget::text_editor::Content::with_text(&s); + ed.sel = None; + true + } + + /// Apply a character-format toggle to the preview selection (preferred) or + /// the Edit-box selection. pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) { - if let Some(ed) = self.mtext_editor.as_mut() { - let sel = ed.content.selection(); - let text = match kind { - MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(), - MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(), - _ => { - let (pre, suf) = fmt_wrap(kind).unwrap(); - format!("{pre}{}{suf}", sel.as_deref().unwrap_or("")) - } - }; - ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + let applied = match kind { + MTextFmt::Uppercase => self.mtext_splice_sel("", "", Some(true)), + MTextFmt::Lowercase => self.mtext_splice_sel("", "", Some(false)), + _ => { + let (pre, suf) = fmt_wrap(kind).unwrap(); + self.mtext_splice_sel(pre, suf, None) + } + }; + if !applied { + if let Some(ed) = self.mtext_editor.as_mut() { + let sel = ed.content.selection(); + let text = match kind { + MTextFmt::Uppercase => sel.as_deref().unwrap_or("").to_uppercase(), + MTextFmt::Lowercase => sel.as_deref().unwrap_or("").to_lowercase(), + _ => { + let (pre, suf) = fmt_wrap(kind).unwrap(); + format!("{pre}{}{suf}", sel.as_deref().unwrap_or("")) + } + }; + ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + } } self.rebuild_mtext_preview(); } - /// Prefix the selection with a paragraph-alignment code. + /// Prefix the selection (or cursor) with a paragraph-alignment code. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { - if let Some(ed) = self.mtext_editor.as_mut() { - let sel = ed.content.selection().unwrap_or_default(); - let text = format!("\\pxq{};{sel}", align.code()); - ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + let code = format!("\\pxq{};", align.code()); + if !self.mtext_splice_sel(&code, "", None) { + if let Some(ed) = self.mtext_editor.as_mut() { + let sel = ed.content.selection().unwrap_or_default(); + let text = format!("{code}{sel}"); + ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + } } self.rebuild_mtext_preview(); } diff --git a/src/app/update.rs b/src/app/update.rs index d9c614055..2800b076a 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3546,6 +3546,20 @@ impl OpenCADStudio { self.rebuild_mtext_preview(); Task::none() } + Message::MTextSelStart(off) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.sel_anchor = off; + ed.sel = Some((off, off)); + } + Task::none() + } + Message::MTextSelTo(off) => { + if let Some(ed) = self.mtext_editor.as_mut() { + let a = ed.sel_anchor; + ed.sel = Some((a.min(off), a.max(off))); + } + Task::none() + } Message::MTextOk => { self.mtext_commit(); Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index ef331ae69..774da4175 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1346,13 +1346,14 @@ const MTEXT_COLORS: [(&str, u16); 8] = [ /// editor's own preview area (never on the drawing). Strokes lie in the /// world XY plane; the program fits + vertically flips them into the box. const MTEXT_PREVIEW_PAD: f32 = 12.0; -/// Screen pixels per world unit of text height — fixes the preview to the -/// real text size (so it scrolls rather than shrinking to fit). -const MTEXT_PREVIEW_PX_PER_UNIT: f32 = 10.0; struct MTextPreview { /// Disconnected polylines as (x, y) world points (NaN-split already done). segments: Vec>, + /// Per-visible-character boxes (world frame) for click-to-select. + boxes: Vec, + /// Current selection as a visible-char range. + sel: Option<(usize, usize)>, /// World-space min corner (bbox) and pixels-per-world-unit scale. minx: f32, miny: f32, @@ -1360,11 +1361,93 @@ struct MTextPreview { content_h: f32, } +impl MTextPreview { + /// Visible-char offset (0..=N) nearest the cursor point (bounds-local px). + fn offset_at(&self, p: iced::Point) -> usize { + if self.boxes.is_empty() { + return 0; + } + let wx = self.minx + (p.x - MTEXT_PREVIEW_PAD) / self.scale; + let wy = self.miny + (self.content_h - p.y - MTEXT_PREVIEW_PAD) / self.scale; + let mut best = 0usize; + let mut best_d = f32::MAX; + for b in &self.boxes { + let dx = if wx < b.xmin { + b.xmin - wx + } else if wx > b.xmax { + wx - b.xmax + } else { + 0.0 + }; + let dy = if wy < b.ymin { + b.ymin - wy + } else if wy > b.ymax { + wy - b.ymax + } else { + 0.0 + }; + let d = dy * 1000.0 + dx; // prefer the correct line first + if d < best_d { + best_d = d; + best = b.vis; + // After the glyph centre → caret sits after this char. + if wx > (b.xmin + b.xmax) * 0.5 { + best = b.vis + 1; + } + } + } + best + } +} + +#[derive(Default)] +struct MTextPreviewState { + dragging: bool, +} + impl iced::widget::canvas::Program for MTextPreview { - type State = (); + type State = MTextPreviewState; + + fn update( + &self, + state: &mut MTextPreviewState, + event: &iced::Event, + bounds: iced::Rectangle, + cursor: iced::mouse::Cursor, + ) -> Option> { + use iced::mouse::{Button, Event as Me}; + use iced::widget::canvas::Action; + use iced::Event; + match event { + Event::Mouse(Me::ButtonPressed(Button::Left)) => { + if let Some(p) = cursor.position_in(bounds) { + state.dragging = true; + let off = self.offset_at(p); + return Some(Action::publish(Message::MTextSelStart(off)).and_capture()); + } + } + Event::Mouse(Me::CursorMoved { .. }) => { + if state.dragging { + if let Some(p) = cursor.position_in(bounds) { + let off = self.offset_at(p); + return Some(Action::publish(Message::MTextSelTo(off))); + } + } + } + Event::Mouse(Me::ButtonReleased(Button::Left)) => { + if state.dragging { + state.dragging = false; + return Some(Action::capture()); + } + } + _ => {} + } + None + } + fn draw( &self, - _state: &(), + _state: &MTextPreviewState, renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, @@ -1380,6 +1463,20 @@ impl iced::widget::canvas::Program for MTextPreview { self.content_h - (pad + (y - self.miny) * self.scale), ) }; + // Selection highlight behind the glyphs. + if let Some((a, b)) = self.sel { + for bx in &self.boxes { + if bx.vis >= a && bx.vis < b { + let p0 = map(bx.xmin, bx.ymax); + let p1 = map(bx.xmax, bx.ymin); + let rect = Path::rectangle( + iced::Point::new(p0.x.min(p1.x), p0.y.min(p1.y)), + iced::Size::new((p1.x - p0.x).abs(), (p1.y - p0.y).abs()), + ); + frame.fill(&rect, Color { r: 0.20, g: 0.42, b: 0.72, a: 0.45 }); + } + } + } for seg in &self.segments { if seg.len() < 2 { continue; @@ -1601,6 +1698,14 @@ fn mtext_editor_overlay<'a>( maxy = maxy.max(y); } } + // Include glyph boxes so all-whitespace / box-only lines still anchor + // the transform (hit-testing relies on minx/miny). + for b in &ed.glyph_boxes { + minx = minx.min(b.xmin); + miny = miny.min(b.ymin); + maxx = maxx.max(b.xmax); + maxy = maxy.max(b.ymax); + } let h_unit = ed.height_value() as f32; // Real text size: fixed pixels per em so more/taller text grows the // canvas (and scrolls) instead of shrinking to fit. @@ -1610,7 +1715,15 @@ fn mtext_editor_overlay<'a>( } else { 40.0 }; - let prog = MTextPreview { segments, minx, miny, scale, content_h }; + let prog = MTextPreview { + segments, + boxes: ed.glyph_boxes.clone(), + sel: ed.sel, + minx, + miny, + scale, + content_h, + }; let cv = canvas(prog).width(Fill).height(iced::Length::Fixed(content_h)); container(iced::widget::scrollable(cv).height(iced::Length::Fixed(VIEW_H))) .style(move |_: &Theme| container::Style { diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 2f5ea84b9..15631bc40 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -208,6 +208,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument) v_anchor, line_spacing_factor: 1.0, vertical_text: false, + want_glyph_boxes: false, }); let _ = input.line_count; let _ = input.is_multiline; diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index fbf92b2c0..0a74e8588 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -4,7 +4,7 @@ use glam::Vec3; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip, triangle_grip}; use crate::entities::text_support::{ - layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, + layout_mtext, resolve_text_style, GlyphBox, MTextRenderOpts, MTextVAnchor, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; @@ -74,6 +74,52 @@ fn drawing_dir_str(d: &DrawingDirection) -> &'static str { } } +/// Per-visible-character world-space boxes for the MText editor's +/// click-to-select preview. Uses the exact same layout opts as `to_truck` +/// so the boxes line up with the rendered glyphs. +pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec { + let resolved_style = resolve_text_style(&t.style, document); + let attach_h_anchor: f32 = match t.attachment_point { + AttachmentPoint::TopCenter + | AttachmentPoint::MiddleCenter + | AttachmentPoint::BottomCenter => 0.5, + AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => { + 1.0 + } + _ => 0.0, + }; + let v_anchor = match t.attachment_point { + AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => { + MTextVAnchor::Top + } + AttachmentPoint::MiddleLeft + | AttachmentPoint::MiddleCenter + | AttachmentPoint::MiddleRight => MTextVAnchor::Middle, + AttachmentPoint::BottomLeft + | AttachmentPoint::BottomCenter + | AttachmentPoint::BottomRight => MTextVAnchor::Bottom, + }; + let rotation = if resolved_style.is_upside_down { + t.rotation as f32 + std::f32::consts::PI + } else { + t.rotation as f32 + }; + let layout = layout_mtext(&MTextRenderOpts { + value: &t.value, + insertion: [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z], + height: t.height as f32, + rect_w: t.rectangle_width as f32, + rotation, + style: &resolved_style, + attach_h_anchor, + v_anchor, + line_spacing_factor: t.line_spacing_factor as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + want_glyph_boxes: true, + }); + layout.glyph_boxes +} + fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { let resolved_style = resolve_text_style(&t.style, document); let attach_h_anchor: f32 = match t.attachment_point { @@ -112,6 +158,7 @@ fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity { v_anchor, line_spacing_factor: t.line_spacing_factor as f32, vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + want_glyph_boxes: false, }); let insertion = Vec3::new( t.insertion_point.x as f32, diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index bf38577c7..7d0711d28 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -257,6 +257,7 @@ fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option { pub line_spacing_factor: f32, /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). pub vertical_text: bool, + /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with + /// one world-space box per visible character (used by the MText editor's + /// click-to-select preview). Off in the hot render path. + pub want_glyph_boxes: bool, +} + +/// One selectable character in the laid-out text: its world-space AABB plus +/// the running index of visible characters (in reading order) so the editor +/// can map a clicked box back to an offset in the value. +#[derive(Clone, Copy, Debug)] +pub struct GlyphBox { + pub vis: usize, + pub xmin: f32, + pub xmax: f32, + pub ymin: f32, + pub ymax: f32, } /// Output of [`layout_mtext`]: stroke groups + the geometry the caller @@ -1088,6 +1104,9 @@ pub struct MTextLayout { /// Y of the first sub-line's baseline relative to the insertion point /// (in the entity-local, pre-rotation frame). pub v_offset: f32, + /// One world-space AABB per visible character — only populated when + /// `MTextRenderOpts::want_glyph_boxes` is set. + pub glyph_boxes: Vec, } /// Lay out and render an MText-formatted value, returning the stroke @@ -1232,6 +1251,18 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { // ── 4. Render each sub-line ────────────────────────────────────────── let mut all_strokes: Vec = Vec::new(); let mut line_widths: Vec = Vec::with_capacity(sub_lines.len()); + let mut glyph_boxes: Vec = Vec::new(); + let mut vis: usize = 0; + // Transform an entity-local point to world space (mirrors the stroke + // origin maths) so glyph boxes line up with the drawn glyphs. + let to_world = |line_base_x: f32, line_base_y: f32, lx: f32, ly: f32| -> (f32, f32) { + let wdx = lx * cos_r - ly * sin_r; + let wdy = lx * sin_r + ly * cos_r; + ( + ins_x as f32 + line_base_x + wdx, + ins_y as f32 + line_base_y + wdy, + ) + }; for (i, sub) in sub_lines.iter().enumerate() { let li = i as f32; let (line_base_x, line_base_y) = if opts.vertical_text { @@ -1337,12 +1368,52 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, }); + if opts.want_glyph_boxes { + // Per-character boxes, advancing exactly as + // `measure_word` does so they track the glyphs. + let scale = run_scale(&atom.state, entity_h, base_wf); + let font = cxf::get_font(font_name); + let mut cx = cursor_x; + for ch in text.chars() { + let adv = match font.glyph(ch) { + Some(g) => { + (g.advance + font.letter_spacing * tracking) * scale + } + None => (6.0 + font.letter_spacing * tracking) * scale, + }; + let (ax, ay) = to_world(line_base_x, line_base_y, cx, ly); + let (bx, by) = to_world(line_base_x, line_base_y, cx + adv, ly + run_h); + glyph_boxes.push(GlyphBox { + vis, + xmin: ax.min(bx), + xmax: ax.max(bx), + ymin: ay.min(by), + ymax: ay.max(by), + }); + vis += 1; + cx += adv; + } + } cursor_x += measure_word(text, &atom.state, entity_h, base_wf, &base_font_name); } AtomKind::Space => { - cursor_x += - measure_space(&atom.state, entity_h, base_wf, &base_font_name); + let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name); + if opts.want_glyph_boxes { + let run_h = atom.state.height_mul * entity_h; + let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0); + let (bx, by) = + to_world(line_base_x, line_base_y, cursor_x + adv, run_h); + glyph_boxes.push(GlyphBox { + vis, + xmin: ax.min(bx), + xmax: ax.max(bx), + ymin: ay.min(by), + ymax: ay.max(by), + }); + vis += 1; + } + cursor_x += adv; } AtomKind::Tab => { cursor_x = next_tab_position( @@ -1362,6 +1433,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_count: sub_lines.len(), line_height: line_h, v_offset, + glyph_boxes, } } pub(crate) fn text_obb_corners_native(