From de08bd598c0a076d0918dd56b630ae0d6072ab0e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 30 May 2026 23:24:06 +0300 Subject: [PATCH] feat: Implement in-place MText editor with formatting toolbar and live preview - Added SVG icons for text formatting options (bold, italic, underline, overline, strike, uppercase, lowercase). - Introduced a new `MTextEditorState` struct to manage the state of the MText editor. - Implemented functionality to open the MText editor from the command line and edit existing MText entities. - Added message handling for various MText editor actions (formatting, height, style, font, justification, alignment, line spacing). - Created a dedicated preview area for MText rendering within the editor. - Updated the command structure to support opening the MText editor and handling text input. - Refactored existing MText command logic to integrate with the new editor. --- assets/icons/mt_align_center.svg | 6 + assets/icons/mt_align_justify.svg | 6 + assets/icons/mt_align_left.svg | 6 + assets/icons/mt_align_right.svg | 6 + assets/icons/mt_bold.svg | 3 + assets/icons/mt_cancel.svg | 4 + assets/icons/mt_italic.svg | 5 + assets/icons/mt_lower.svg | 6 + assets/icons/mt_ok.svg | 3 + assets/icons/mt_overline.svg | 4 + assets/icons/mt_strike.svg | 4 + assets/icons/mt_underline.svg | 4 + assets/icons/mt_upper.svg | 4 + src/app/cmd_result.rs | 10 + src/app/mod.rs | 33 +++ src/app/mtext_editor.rs | 349 ++++++++++++++++++++++++++++++ src/app/update.rs | 107 +++++++++ src/app/view.rs | 295 +++++++++++++++++++++++++ src/command/mod.rs | 9 + src/modules/annotate/mtext.rs | 43 +--- 20 files changed, 871 insertions(+), 36 deletions(-) create mode 100644 assets/icons/mt_align_center.svg create mode 100644 assets/icons/mt_align_justify.svg create mode 100644 assets/icons/mt_align_left.svg create mode 100644 assets/icons/mt_align_right.svg create mode 100644 assets/icons/mt_bold.svg create mode 100644 assets/icons/mt_cancel.svg create mode 100644 assets/icons/mt_italic.svg create mode 100644 assets/icons/mt_lower.svg create mode 100644 assets/icons/mt_ok.svg create mode 100644 assets/icons/mt_overline.svg create mode 100644 assets/icons/mt_strike.svg create mode 100644 assets/icons/mt_underline.svg create mode 100644 assets/icons/mt_upper.svg create mode 100644 src/app/mtext_editor.rs diff --git a/assets/icons/mt_align_center.svg b/assets/icons/mt_align_center.svg new file mode 100644 index 000000000..d5cb601af --- /dev/null +++ b/assets/icons/mt_align_center.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/mt_align_justify.svg b/assets/icons/mt_align_justify.svg new file mode 100644 index 000000000..9277dffe6 --- /dev/null +++ b/assets/icons/mt_align_justify.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/mt_align_left.svg b/assets/icons/mt_align_left.svg new file mode 100644 index 000000000..34437ea9a --- /dev/null +++ b/assets/icons/mt_align_left.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/mt_align_right.svg b/assets/icons/mt_align_right.svg new file mode 100644 index 000000000..8ffa3023f --- /dev/null +++ b/assets/icons/mt_align_right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/mt_bold.svg b/assets/icons/mt_bold.svg new file mode 100644 index 000000000..e116853b0 --- /dev/null +++ b/assets/icons/mt_bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/mt_cancel.svg b/assets/icons/mt_cancel.svg new file mode 100644 index 000000000..2121542d1 --- /dev/null +++ b/assets/icons/mt_cancel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/mt_italic.svg b/assets/icons/mt_italic.svg new file mode 100644 index 000000000..747277c7e --- /dev/null +++ b/assets/icons/mt_italic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/mt_lower.svg b/assets/icons/mt_lower.svg new file mode 100644 index 000000000..2487e968a --- /dev/null +++ b/assets/icons/mt_lower.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/mt_ok.svg b/assets/icons/mt_ok.svg new file mode 100644 index 000000000..948c8a040 --- /dev/null +++ b/assets/icons/mt_ok.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/mt_overline.svg b/assets/icons/mt_overline.svg new file mode 100644 index 000000000..7278996d6 --- /dev/null +++ b/assets/icons/mt_overline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/mt_strike.svg b/assets/icons/mt_strike.svg new file mode 100644 index 000000000..15b2fb43e --- /dev/null +++ b/assets/icons/mt_strike.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/mt_underline.svg b/assets/icons/mt_underline.svg new file mode 100644 index 000000000..0f5b3cb62 --- /dev/null +++ b/assets/icons/mt_underline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/mt_upper.svg b/assets/icons/mt_upper.svg new file mode 100644 index 000000000..efc2fdb3a --- /dev/null +++ b/assets/icons/mt_upper.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index fe296a5d2..35539a0d3 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -1496,6 +1496,16 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } + CmdResult::OpenMTextEditor { + pos, + handle, + initial, + height, + } => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.open_mtext_editor(pos, handle, &initial, height); + } CmdResult::DdeditEntity { handle, new_text } => { let mut updated = false; if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { diff --git a/src/app/mod.rs b/src/app/mod.rs index 68a87ae1e..92f0b5e90 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ mod document; mod helpers; mod history; mod layers; +mod mtext_editor; mod properties; mod update; mod view; @@ -250,6 +251,8 @@ pub(super) struct OpenCADStudio { /// removes the picked entity from the selection). Tracked from keyboard /// modifier-change events since mouse click messages carry no modifiers. shift_down: bool, + /// Open in-place MText editor (toolbar + text area + live preview), if any. + mtext_editor: Option, /// Which layout tab has its context menu open (None = closed). layout_context_menu: Option, /// Inline rename state: (original_name, current_edit_value). @@ -709,6 +712,35 @@ pub enum Message { /// Keyboard modifier state changed — tracks whether Shift is held so the /// pick path can do subtractive (Shift+click) selection. SetShiftDown(bool), + // ── In-place MText editor ─────────────────────────────────────────── + /// Text-area edit action from the multi-line editor widget. + MTextEdit(iced::widget::text_editor::Action), + /// Toolbar character-format toggle applied to the selection. + MTextFmt(mtext_editor::MTextFmt), + /// Toolbar height field changed. + MTextHeight(String), + /// Toolbar text-style dropdown changed. + MTextStyle(String), + /// Toolbar font dropdown changed. + MTextFont(String), + /// Toolbar oblique-angle field changed. + MTextOblique(String), + /// Toolbar width-factor field changed. + MTextWidth(String), + /// Toolbar character-spacing field changed. + MTextCharSpace(String), + /// Toolbar colour dropdown — global text colour (ACI index, 256 = ByLayer). + MTextColor(u16), + /// Toolbar justification / attachment-point change. + MTextJustify(acadrust::entities::mtext::AttachmentPoint), + /// Toolbar paragraph-alignment change. + MTextAlign(mtext_editor::ParaAlign), + /// Toolbar line-spacing change. + MTextLineSpacing(f32), + /// Commit the editor: create or update the MText entity. + MTextOk, + /// Discard the editor without creating / changing the entity. + MTextCancel, // ── Draw Order context menu ───────────────────────────────────────── /// Toggle the Draw Order sub-items in the viewport context menu. DrawOrderSubmenuToggle, @@ -949,6 +981,7 @@ impl OpenCADStudio { clipboard: Vec::new(), clipboard_centroid: glam::Vec3::ZERO, shift_down: false, + mtext_editor: None, layout_context_menu: None, layout_rename_state: None, last_vp_click_time: None, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs new file mode 100644 index 000000000..073348df7 --- /dev/null +++ b/src/app/mtext_editor.rs @@ -0,0 +1,349 @@ +// In-place MText editor: formatting toolbar + multi-line text area with a +// live viewport preview. Opened by the MTEXT command (new text) or by +// DDEDIT / double-click on an existing MText. The text area holds the raw +// MText value (plain text plus DXF inline format codes the toolbar inserts); +// the real entity is re-tessellated into the scene's preview wires on every +// change so the user sees the actual drawing result while typing. + +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::{EntityType, Handle, MText}; +use glam::Vec3; +use iced::widget::text_editor; + +/// Character-level format toggles applied to the current selection by the +/// toolbar. Each maps to a DXF MTEXT inline code understood by the renderer +/// in `entities/text_support.rs`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MTextFmt { + Bold, + Italic, + Underline, + Overline, + Strike, + Uppercase, + Lowercase, +} + +/// Paragraph alignment written as `\pxq;`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaAlign { + Left, + Center, + Right, + Justify, +} + +impl ParaAlign { + pub fn code(self) -> &'static str { + match self { + ParaAlign::Left => "l", + ParaAlign::Center => "c", + ParaAlign::Right => "r", + ParaAlign::Justify => "j", + } + } +} + +/// `pick_list`-friendly wrapper for the 9 attachment points. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct JustifyChoice(pub AttachmentPoint); + +impl JustifyChoice { + pub const ALL: [JustifyChoice; 9] = [ + JustifyChoice(AttachmentPoint::TopLeft), + JustifyChoice(AttachmentPoint::TopCenter), + JustifyChoice(AttachmentPoint::TopRight), + JustifyChoice(AttachmentPoint::MiddleLeft), + JustifyChoice(AttachmentPoint::MiddleCenter), + JustifyChoice(AttachmentPoint::MiddleRight), + JustifyChoice(AttachmentPoint::BottomLeft), + JustifyChoice(AttachmentPoint::BottomCenter), + JustifyChoice(AttachmentPoint::BottomRight), + ]; +} + +impl std::fmt::Display for JustifyChoice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self.0 { + AttachmentPoint::TopLeft => "Top Left", + AttachmentPoint::TopCenter => "Top Center", + AttachmentPoint::TopRight => "Top Right", + AttachmentPoint::MiddleLeft => "Middle Left", + AttachmentPoint::MiddleCenter => "Middle Center", + AttachmentPoint::MiddleRight => "Middle Right", + AttachmentPoint::BottomLeft => "Bottom Left", + AttachmentPoint::BottomCenter => "Bottom Center", + AttachmentPoint::BottomRight => "Bottom Right", + }; + f.write_str(s) + } +} + +/// Live state of the open MText editor. Absent (`None`) when no editor is up. +pub struct MTextEditorState { + /// World insertion point (WCS, same convention the committed entity uses). + pub pos: Vec3, + /// The editable text buffer (raw value with inline codes). + pub content: text_editor::Content, + /// Text height, edited as a string so partial input is allowed. + pub height: String, + /// Text style name (entity field). + pub style: String, + /// Global font family applied via a leading `\f;` run ("" = style default). + pub font: String, + /// Global colour ACI (256 = ByLayer) applied via a leading `\C;`. + pub color_aci: u16, + /// Global oblique angle, width factor, char spacing — leading `\Q`/`\W`/`\T`. + pub oblique: String, + pub width: String, + pub char_space: String, + /// Tessellated strokes of the current text, drawn in the editor's own + /// preview area (NOT on the drawing canvas). + pub preview_wires: Vec, + /// Paragraph attachment / justification (entity field). + pub attachment: AttachmentPoint, + /// Line spacing factor (entity field). + pub line_spacing: f32, + /// Fixed MText box width (drawing units). The text wraps within this — + /// it is NOT derived from the typed content, so adding characters wraps + /// to the next line instead of stretching the box into one long line. + pub rect_width: f64, + /// `Some` when editing an existing entity; `None` for a fresh MText. + pub editing: Option, + /// Canvas-space anchor where the toolbar + text area are drawn (the + /// insertion-point click position). + pub screen_anchor: iced::Point, +} + +impl MTextEditorState { + pub fn new(pos: Vec3, initial: &str, height: f64, editing: Option) -> Self { + Self { + pos, + content: text_editor::Content::with_text(initial), + height: format!("{:.4}", height) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string(), + style: "Standard".to_string(), + font: String::new(), + color_aci: 256, + oblique: "0".to_string(), + width: "1".to_string(), + char_space: "0".to_string(), + preview_wires: Vec::new(), + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + // Default box ~20 characters wide; overwritten with the entity's + // own width when editing an existing MText. + rect_width: (height * 20.0).max(1.0), + editing, + screen_anchor: iced::Point::new(60.0, 90.0), + } + } + + pub fn height_value(&self) -> f64 { + self.height.trim().parse::().ok().filter(|h| *h > 0.0).unwrap_or(0.25) + } + + /// Compose the raw editor text with the global leading inline codes + /// (font / colour / oblique / width / char-spacing) the toolbar's + /// dropdowns and value fields set. Per-selection toggles already live + /// inside the text. + pub fn composed_value(&self) -> String { + 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())); + } + if self.color_aci != 256 { + prefix.push_str(&format!("\\C{};", self.color_aci)); + } + if let Some(v) = parse_non_default(&self.oblique, 0.0) { + prefix.push_str(&format!("\\Q{};", v)); + } + if let Some(v) = parse_non_default(&self.width, 1.0) { + prefix.push_str(&format!("\\W{};", v)); + } + if let Some(v) = parse_non_default(&self.char_space, 0.0) { + prefix.push_str(&format!("\\T{};", v)); + } + format!("{prefix}{body}") + } + + /// Build the MText entity from the current editor state for preview/commit. + pub fn build_mtext(&self) -> MText { + let h = self.height_value(); + MText { + value: self.composed_value(), + insertion_point: Vector3::new(self.pos.x as f64, self.pos.y as f64, self.pos.z as f64), + height: h, + rectangle_width: self.rect_width, + attachment_point: self.attachment, + line_spacing_factor: self.line_spacing as f64, + style: self.style.clone(), + ..Default::default() + } + } + + pub fn build_entity(&self) -> EntityType { + EntityType::MText(self.build_mtext()) + } +} + +/// Parse a numeric field, returning `Some(v)` only when it differs from the +/// control's default (so unchanged fields emit no inline code). +fn parse_non_default(s: &str, default: f64) -> Option { + let v = s.trim().parse::().ok()?; + if (v - default).abs() < 1e-9 { + None + } else { + Some(v) + } +} + +/// 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)> { + match kind { + // Bold / italic ride on a font run; the renderer reads the `b`/`i` + // flags of a `\f...;` code. Use a neutral family so any styled font + // still picks up the flag. + MTextFmt::Bold => Some(("\\fArial|b1;", "\\fArial|b0;")), + MTextFmt::Italic => Some(("\\fArial|i1;", "\\fArial|i0;")), + MTextFmt::Underline => Some(("\\L", "\\l")), + MTextFmt::Overline => Some(("\\O", "\\o")), + MTextFmt::Strike => Some(("\\K", "\\k")), + MTextFmt::Uppercase | MTextFmt::Lowercase => None, + } +} + +// ── App-side editor driver ────────────────────────────────────────────────── + +use crate::scene::tessellate; +use crate::scene::wire_model::WireModel; +use iced::widget::text_editor::{Action, Edit}; +use std::sync::Arc; + +impl super::OpenCADStudio { + /// Open the in-place editor for a new (`handle = None`) or existing MText. + pub(super) fn open_mtext_editor( + &mut self, + pos: Vec3, + handle: Option, + initial: &str, + height: f64, + ) { + let mut state = MTextEditorState::new(pos, initial, height, handle); + if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { + state.screen_anchor = p; + } + // Seed attachment / line-spacing from the entity being edited. + if let Some(h) = handle { + if let Some(EntityType::MText(m)) = self.tabs[self.active_tab].scene.document.get_entity(h) { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + if m.rectangle_width > 0.0 { + state.rect_width = m.rectangle_width; + } + } + } + self.mtext_editor = Some(state); + self.rebuild_mtext_preview(); + } + + /// Re-tessellate the current editor text into the editor's OWN preview + /// strokes (stored on the state, drawn in the dedicated preview area — + /// never on the drawing canvas). + 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 woff = self.tabs[i].scene.world_offset; + let anno = self.tabs[i].scene.annotation_scale; + let wires: Vec = tessellate::tessellate( + &self.tabs[i].scene.document, + ed.editing.unwrap_or(Handle::new(1)), + &entity, + false, + [0.92, 0.92, 0.92, 1.0], + 0.0, + [0.0; 8], + 1.0, + woff, + anno, + ); + if let Some(ed) = self.mtext_editor.as_mut() { + ed.preview_wires = wires; + } + } + + /// Apply a character-format toggle to the current selection (or insert at + /// the cursor when there is no 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)))); + } + self.rebuild_mtext_preview(); + } + + /// Prefix the selection 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)))); + } + self.rebuild_mtext_preview(); + } + + /// Commit the editor — create a new MText or update the edited one. + pub(super) fn mtext_commit(&mut self) { + let i = self.active_tab; + let Some(ed) = self.mtext_editor.take() else { return }; + let body_empty = ed.content.text().trim().is_empty(); + let mt = ed.build_mtext(); + if body_empty { + // Empty content: drop a new entity; leave an edited one untouched. + self.refresh_properties(); + return; + } + if let Some(h) = ed.editing { + self.push_undo_snapshot(i, "MTEXT"); + if let Some(EntityType::MText(t)) = + self.tabs[i].scene.document.get_entity_mut(h) + { + t.value = mt.value; + t.height = mt.height; + t.attachment_point = mt.attachment_point; + t.line_spacing_factor = mt.line_spacing_factor; + t.rectangle_width = mt.rectangle_width; + } + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } else { + self.push_undo_snapshot(i, "MTEXT"); + self.commit_entity(EntityType::MText(mt)); + self.tabs[i].dirty = true; + } + self.refresh_properties(); + } + + /// Discard the editor without changing the drawing. + pub(super) fn mtext_cancel(&mut self) { + self.mtext_editor = None; + } +} diff --git a/src/app/update.rs b/src/app/update.rs index 0c7dfa594..3d5aeeed2 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1230,6 +1230,11 @@ impl OpenCADStudio { } Message::CommandEscape => { + // Open MText editor swallows Escape (cancel without committing). + if self.mtext_editor.is_some() { + self.mtext_cancel(); + return Task::none(); + } // Grip popup intercepts Escape — dismisses the menu // without doing anything else. if self.grip_popup.take().is_some() { @@ -2929,6 +2934,20 @@ impl OpenCADStudio { let hit = scene::hit_test::click_hit(p, &all_wires[..], vp_mat, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { + // MText opens the in-place editor; other text types + // keep the command-line DDEDIT path. + if let Some(AcadEntityType::MText(m)) = + self.tabs[i].scene.document.get_entity(handle) + { + let pos = glam::Vec3::new( + m.insertion_point.x as f32, + m.insertion_point.y as f32, + m.insertion_point.z as f32, + ); + let (val, h) = (m.value.clone(), m.height); + self.open_mtext_editor(pos, Some(handle), &val, h); + return Task::none(); + } if let Some(entity) = self.tabs[i].scene.document.get_entity(handle) { use crate::modules::annotate::ddedit::{ entity_text, DdeditCommand, @@ -3441,6 +3460,94 @@ impl OpenCADStudio { Task::none() } + // ── In-place MText editor ─────────────────────────────────── + Message::MTextEdit(action) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.content.perform(action); + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextFmt(kind) => { + self.mtext_apply_fmt(kind); + Task::none() + } + Message::MTextHeight(s) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.height = s; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColor(aci) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.color_aci = aci; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextStyle(s) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.style = s; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextFont(f) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.font = if f == "[Style default]" { String::new() } else { f }; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextOblique(s) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.oblique = s; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextWidth(s) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.width = s; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextCharSpace(s) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.char_space = s; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextJustify(ap) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.attachment = ap; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextAlign(a) => { + self.mtext_apply_align(a); + Task::none() + } + Message::MTextLineSpacing(f) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.line_spacing = f; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextOk => { + self.mtext_commit(); + Task::none() + } + Message::MTextCancel => { + self.mtext_cancel(); + Task::none() + } + Message::DrawOrderSubmenuToggle => { let i = self.active_tab; let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/view.rs b/src/app/view.rs index 454a3f1fe..2ccf66459 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -794,6 +794,21 @@ impl OpenCADStudio { } } + // In-place MText editor (toolbar + text area), anchored at the + // insertion-point click. + if !tab.is_start { + if let Some(ed) = &self.mtext_editor { + let styles: Vec = tab + .scene + .document + .text_styles + .iter() + .map(|s| s.name.clone()) + .collect(); + viewport_stack = viewport_stack.push(mtext_editor_overlay(ed, styles)); + } + } + // Properties / layers panels carry no useful state on the Start tab. // Replace the properties panel with a Recent Documents list there. let properties_el: Element<'_, Message> = if tab.is_start { @@ -1306,6 +1321,286 @@ fn position_canvas_overlay<'a>( .into() } +// ── In-place MText editor overlay ─────────────────────────────────────────── + +const MTEXT_FONTS: [&str; 5] = [ + "[Style default]", + "Arial", + "Times New Roman", + "Courier New", + "ISOCPEUR", +]; +/// (label, ACI). 256 = ByLayer. +const MTEXT_COLORS: [(&str, u16); 8] = [ + ("ByLayer", 256), + ("Red", 1), + ("Yellow", 2), + ("Green", 3), + ("Cyan", 4), + ("Blue", 5), + ("Magenta", 6), + ("White", 7), +]; + +/// 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. +struct MTextPreview { + /// Disconnected polylines as (x, y) world points (NaN-split already done). + segments: Vec>, +} + +impl iced::widget::canvas::Program for MTextPreview { + type State = (); + fn draw( + &self, + _state: &(), + renderer: &iced::Renderer, + _theme: &Theme, + bounds: iced::Rectangle, + _cursor: iced::mouse::Cursor, + ) -> 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 map = |x: f32, y: f32| { + iced::Point::new(ox + (x - minx) * s, bounds.height - (oy + (y - miny) * s)) + }; + for seg in &self.segments { + if seg.len() < 2 { + continue; + } + let path = Path::new(|p| { + p.move_to(map(seg[0].0, seg[0].1)); + for &(x, y) in &seg[1..] { + p.line_to(map(x, y)); + } + }); + frame.stroke( + &path, + Stroke::default() + .with_color(Color { r: 0.93, g: 0.93, b: 0.93, a: 1.0 }) + .with_width(1.4), + ); + } + vec![frame.into_geometry()] + } +} + +/// Split every preview WireModel into finite (x, y) polyline runs. +fn mtext_preview_segments(ed: &super::mtext_editor::MTextEditorState) -> Vec> { + let mut out: Vec> = Vec::new(); + for w in &ed.preview_wires { + let mut run: Vec<(f32, f32)> = Vec::new(); + for p in &w.points { + if p[0].is_finite() && p[1].is_finite() { + run.push((p[0], p[1])); + } else if !run.is_empty() { + out.push(std::mem::take(&mut run)); + } + } + if !run.is_empty() { + out.push(run); + } + } + out +} + +fn mtext_editor_overlay<'a>( + ed: &'a super::mtext_editor::MTextEditorState, + styles: Vec, +) -> Element<'a, Message> { + use super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; + use iced::widget::{canvas, svg, text_editor}; + + const PANEL_BG: Color = Color { r: 0.16, g: 0.16, b: 0.16, a: 0.98 }; + const BORDER: Color = Color { r: 0.40, g: 0.40, b: 0.40, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; + const FIELD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; + + let btn_style = |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => { + Color { r: 0.28, g: 0.40, b: 0.55, a: 1.0 } + } + _ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }, + })), + text_color: TEXT_COL, + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + shadow: iced::Shadow::default(), + snap: false, + }; + let icon_btn = move |bytes: &'static [u8], msg: Message| -> Element<'static, Message> { + button(svg(svg::Handle::from_memory(bytes)).width(18).height(18)) + .on_press(msg) + .padding(3) + .style(btn_style) + .into() + }; + let lbl = |s: &'static str| text(s).size(11).color(TEXT_COL); + let small_input = |placeholder: &'static str, + val: &str, + on: fn(String) -> Message, + w: f32| + -> Element<'static, Message> { + text_input(placeholder, val) + .on_input(on) + .width(iced::Length::Fixed(w)) + .padding(3) + .size(12) + .into() + }; + + // ── Row 1: style / font / height · format icons · colour ────────────── + let style_opts: Vec = if styles.is_empty() { + vec!["Standard".to_string()] + } else { + styles + }; + let style_pl = pick_list(style_opts, Some(ed.style.clone()), Message::MTextStyle) + .text_size(11) + .width(iced::Length::Fixed(96.0)); + let font_sel = if ed.font.trim().is_empty() { + "[Style default]".to_string() + } else { + ed.font.clone() + }; + let font_pl = pick_list( + MTEXT_FONTS.iter().map(|s| s.to_string()).collect::>(), + Some(font_sel), + Message::MTextFont, + ) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let color_sel = MTEXT_COLORS + .iter() + .find(|(_, a)| *a == ed.color_aci) + .map(|(n, _)| n.to_string()) + .unwrap_or_else(|| "ByLayer".to_string()); + let color_pl = pick_list( + MTEXT_COLORS.iter().map(|(n, _)| n.to_string()).collect::>(), + Some(color_sel), + |name: String| { + let aci = MTEXT_COLORS + .iter() + .find(|(n, _)| *n == name) + .map(|(_, a)| *a) + .unwrap_or(256); + Message::MTextColor(aci) + }, + ) + .text_size(11) + .width(iced::Length::Fixed(96.0)); + + let row1 = row![ + style_pl, + font_pl, + small_input("2.5", &ed.height, Message::MTextHeight, 64.0), + iced::widget::Space::new().width(6), + icon_btn(include_bytes!("../../assets/icons/mt_bold.svg"), Message::MTextFmt(MTextFmt::Bold)), + icon_btn(include_bytes!("../../assets/icons/mt_italic.svg"), Message::MTextFmt(MTextFmt::Italic)), + icon_btn(include_bytes!("../../assets/icons/mt_underline.svg"), Message::MTextFmt(MTextFmt::Underline)), + icon_btn(include_bytes!("../../assets/icons/mt_overline.svg"), Message::MTextFmt(MTextFmt::Overline)), + icon_btn(include_bytes!("../../assets/icons/mt_strike.svg"), Message::MTextFmt(MTextFmt::Strike)), + icon_btn(include_bytes!("../../assets/icons/mt_upper.svg"), Message::MTextFmt(MTextFmt::Uppercase)), + icon_btn(include_bytes!("../../assets/icons/mt_lower.svg"), Message::MTextFmt(MTextFmt::Lowercase)), + iced::widget::Space::new().width(Fill), + color_pl, + ] + .spacing(4) + .align_y(iced::Alignment::Center); + + // ── Row 2: oblique / width / char-spacing · align · line spacing · OK ─ + let justify = pick_list( + JustifyChoice::ALL, + Some(JustifyChoice(ed.attachment)), + |c| Message::MTextJustify(c.0), + ) + .text_size(11) + .width(iced::Length::Fixed(112.0)); + let row2 = row![ + lbl("O"), + small_input("0", &ed.oblique, Message::MTextOblique, 48.0), + lbl("W"), + small_input("1", &ed.width, Message::MTextWidth, 48.0), + lbl("◊"), + small_input("0", &ed.char_space, Message::MTextCharSpace, 48.0), + iced::widget::Space::new().width(6), + icon_btn(include_bytes!("../../assets/icons/mt_align_left.svg"), Message::MTextAlign(ParaAlign::Left)), + icon_btn(include_bytes!("../../assets/icons/mt_align_center.svg"), Message::MTextAlign(ParaAlign::Center)), + icon_btn(include_bytes!("../../assets/icons/mt_align_right.svg"), Message::MTextAlign(ParaAlign::Right)), + icon_btn(include_bytes!("../../assets/icons/mt_align_justify.svg"), Message::MTextAlign(ParaAlign::Justify)), + iced::widget::Space::new().width(6), + justify, + lbl("LS"), + 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(Fill), + icon_btn(include_bytes!("../../assets/icons/mt_ok.svg"), Message::MTextOk), + icon_btn(include_bytes!("../../assets/icons/mt_cancel.svg"), Message::MTextCancel), + ] + .spacing(4) + .align_y(iced::Alignment::Center); + + // ── Dedicated preview area (rendered MText) ────────────────────────── + let preview = container( + canvas(MTextPreview { segments: mtext_preview_segments(ed) }) + .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); + + // ── 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)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { color: BORDER, width: 1.0, radius: 5.0.into() }, + ..Default::default() + }) + .padding(6) + .width(iced::Length::Fixed(640.0)); + + let anchor = iced::Point::new( + (ed.screen_anchor.x - 10.0).max(0.0), + (ed.screen_anchor.y - 90.0).max(0.0), + ); + position_canvas_overlay(anchor, panel.into()) +} + // ── Viewport right-click context menu ────────────────────────────────────── fn viewport_context_menu_overlay( diff --git a/src/command/mod.rs b/src/command/mod.rs index 5d9043923..c77655180 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -126,6 +126,15 @@ pub enum CmdResult { SetPlotWindow { p1: Vec3, p2: Vec3 }, /// Replace the text content of a Text/MText entity in-place. DdeditEntity { handle: Handle, new_text: String }, + /// Open the in-place MText editor (formatting toolbar + multi-line text + /// area with live viewport preview). `handle` is `Some` when editing an + /// existing MText, `None` when creating a new one at `pos`. + OpenMTextEditor { + pos: Vec3, + handle: Option, + initial: String, + height: f64, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index 3e3680827..bbcd936db 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,6 +1,3 @@ -use acadrust::types::Vector3; -use acadrust::{EntityType, MText}; - use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::wire_model::WireModel; @@ -19,7 +16,6 @@ pub fn tool() -> ToolDef { enum Step { InsertPoint, - WaitText(Vec3), } pub struct MTextCommand { @@ -42,16 +38,17 @@ impl CadCommand for MTextCommand { fn prompt(&self) -> String { match &self.step { Step::InsertPoint => "MTEXT Specify insertion point:".into(), - Step::WaitText(pos) => format!( - "MTEXT Type text, press Enter [at {:.2},{:.2}]:", - pos.x, pos.z - ), } } fn on_point(&mut self, pt: Vec3) -> CmdResult { - self.step = Step::WaitText(pt); - CmdResult::NeedPoint + // Hand off to the in-place editor (toolbar + text area + live preview). + CmdResult::OpenMTextEditor { + pos: pt, + handle: None, + initial: String::new(), + height: 0.25, + } } fn on_enter(&mut self) -> CmdResult { @@ -61,32 +58,6 @@ impl CadCommand for MTextCommand { CmdResult::Cancel } - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::WaitText(_)) - } - - fn wants_text_with_spaces(&self) -> bool { - matches!(self.step, Step::WaitText(_)) - } - - fn on_text_input(&mut self, text: &str) -> Option { - if let Step::WaitText(pos) = &self.step { - if text.trim().is_empty() { - return Some(CmdResult::Cancel); - } - let mt = MText { - insertion_point: Vector3::new(pos.x as f64, pos.y as f64, pos.z as f64), - value: text.to_string(), - height: 0.25, - rectangle_width: text.len() as f64 * 0.15, - ..Default::default() - }; - Some(CmdResult::CommitEntity(EntityType::MText(mt))) - } else { - None - } - } - fn on_mouse_move(&mut self, _pt: Vec3) -> Option { None }