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
}