From a5ad0666ce1f68ac22ac2edda621f9ac2a21e680 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 31 May 2026 01:51:20 +0300 Subject: [PATCH] feat: Implement in-place MText editor with formatting toolbar and live preview MTEXT (and double-clicking an MText) now opens an in-place editor anchored at the insertion point instead of typing a single line on the command line. The editor has a two-row formatting toolbar: text style / font / height, bold, italic, underline, overline, strike, upper/lowercase and a colour dropdown on top; oblique angle, width factor, character spacing, paragraph alignment, justification, line spacing and OK/Cancel below. Character toggles wrap the selection in DXF inline codes; global fields apply as leading runs or entity properties. Editing is shown in the editor's own area, not on the drawing: a Code/Preview toggle switches between the raw text input and a rendered preview canvas that draws the tessellated glyph strokes at real size, scrolling when the text outgrows the view. The MText box keeps a fixed width so text wraps to the next line instead of stretching into one line. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 2 + src/app/mtext_editor.rs | 4 ++ src/app/update.rs | 7 +++ src/app/view.rs | 105 ++++++++++++++++++++++++---------------- 4 files changed, 75 insertions(+), 43 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 92f0b5e90..37992f2cc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -737,6 +737,8 @@ pub enum Message { MTextAlign(mtext_editor::ParaAlign), /// Toolbar line-spacing change. MTextLineSpacing(f32), + /// Toggle the editor between the raw code input and the rendered preview. + MTextTogglePreview, /// 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 073348df7..f64c78cad 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -111,6 +111,9 @@ pub struct MTextEditorState { pub rect_width: f64, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, + /// 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, /// Canvas-space anchor where the toolbar + text area are drawn (the /// insertion-point click position). pub screen_anchor: iced::Point, @@ -132,6 +135,7 @@ impl MTextEditorState { width: "1".to_string(), char_space: "0".to_string(), preview_wires: Vec::new(), + show_preview: false, attachment: AttachmentPoint::TopLeft, line_spacing: 1.0, // Default box ~20 characters wide; overwritten with the entity's diff --git a/src/app/update.rs b/src/app/update.rs index 3d5aeeed2..ef08cd670 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -3539,6 +3539,13 @@ impl OpenCADStudio { self.rebuild_mtext_preview(); Task::none() } + Message::MTextTogglePreview => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.show_preview = !ed.show_preview; + } + self.rebuild_mtext_preview(); + Task::none() + } Message::MTextOk => { self.mtext_commit(); Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index 2ccf66459..a838f0316 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1345,9 +1345,19 @@ const MTEXT_COLORS: [(&str, u16); 8] = [ /// Canvas program that renders the tessellated MText strokes inside the /// 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>, + /// World-space min corner (bbox) and pixels-per-world-unit scale. + minx: f32, + miny: f32, + scale: f32, + content_h: f32, } impl iced::widget::canvas::Program for MTextPreview { @@ -1362,30 +1372,13 @@ impl iced::widget::canvas::Program for MTextPreview { ) -> Vec { use iced::widget::canvas::{Frame, Path, Stroke}; let mut frame = Frame::new(renderer, bounds.size()); - let (mut minx, mut miny, mut maxx, mut maxy) = - (f32::MAX, f32::MAX, f32::MIN, f32::MIN); - for seg in &self.segments { - for &(x, y) in seg { - minx = minx.min(x); - miny = miny.min(y); - maxx = maxx.max(x); - maxy = maxy.max(y); - } - } - if minx > maxx { - return vec![frame.into_geometry()]; - } - let pad = 10.0_f32; - let w = (maxx - minx).max(1e-4); - let h = (maxy - miny).max(1e-4); - let s = ((bounds.width - 2.0 * pad) / w) - .min((bounds.height - 2.0 * pad) / h) - .max(0.0); - let ox = pad + (bounds.width - 2.0 * pad - w * s) * 0.5; - let oy = pad + (bounds.height - 2.0 * pad - h * s) * 0.5; - // Flip Y: world up → screen down. + let pad = MTEXT_PREVIEW_PAD; + // Draw at the real size; flip Y (world up → screen down). let map = |x: f32, y: f32| { - iced::Point::new(ox + (x - minx) * s, bounds.height - (oy + (y - miny) * s)) + iced::Point::new( + pad + (x - self.minx) * self.scale, + self.content_h - (pad + (y - self.miny) * self.scale), + ) }; for seg in &self.segments { if seg.len() < 2 { @@ -1557,6 +1550,11 @@ fn mtext_editor_overlay<'a>( button(lbl("1")).on_press(Message::MTextLineSpacing(1.0)).padding(3).style(btn_style), button(lbl("1.5")).on_press(Message::MTextLineSpacing(1.5)).padding(3).style(btn_style), button(lbl("2")).on_press(Message::MTextLineSpacing(2.0)).padding(3).style(btn_style), + iced::widget::Space::new().width(6), + button(lbl(if ed.show_preview { "Code" } else { "Preview" })) + .on_press(Message::MTextTogglePreview) + .padding(3) + .style(btn_style), iced::widget::Space::new().width(Fill), icon_btn(include_bytes!("../../assets/icons/mt_ok.svg"), Message::MTextOk), icon_btn(include_bytes!("../../assets/icons/mt_cancel.svg"), Message::MTextCancel), @@ -1564,28 +1562,49 @@ fn mtext_editor_overlay<'a>( .spacing(4) .align_y(iced::Alignment::Center); - // ── Dedicated preview area (rendered MText) ────────────────────────── - let preview = container( - canvas(MTextPreview { segments: mtext_preview_segments(ed) }) + // ── Body: toggles between raw code input and rendered preview ──────── + const VIEW_H: f32 = 150.0; + let body: Element<'a, Message> = if ed.show_preview { + let segments = mtext_preview_segments(ed); + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for seg in &segments { + for &(x, y) in seg { + minx = minx.min(x); + miny = miny.min(y); + maxx = maxx.max(x); + maxy = maxy.max(y); + } + } + 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. + let scale = (22.0 / h_unit.max(1e-3)).clamp(2.0, 600.0); + let content_h = if maxx >= minx { + ((maxy - miny) * scale + 2.0 * MTEXT_PREVIEW_PAD).max(40.0) + } else { + 40.0 + }; + let prog = MTextPreview { segments, 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 { + background: Some(Background::Color(FIELD_BG)), + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + ..Default::default() + }) + .padding(2) .width(Fill) - .height(iced::Length::Fixed(96.0)), - ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(FIELD_BG)), - border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, - ..Default::default() - }) - .padding(2) - .width(Fill); + .into() + } else { + text_editor(&ed.content) + .on_action(Message::MTextEdit) + .height(iced::Length::Fixed(VIEW_H)) + .padding(6) + .size(13) + .into() + }; - // ── Raw text + inline-code input ───────────────────────────────────── - let editor = text_editor(&ed.content) - .on_action(Message::MTextEdit) - .height(iced::Length::Fixed(72.0)) - .padding(6) - .size(13); - - let panel = container(column![row1, row2, preview, editor].spacing(5)) + let panel = container(column![row1, row2, body].spacing(5)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { color: BORDER, width: 1.0, radius: 5.0.into() },