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.
6
assets/icons/mt_align_center.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="7" y1="11" x2="17" y2="11"/>
|
||||
<line x1="5" y1="16" x2="19" y2="16"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
6
assets/icons/mt_align_justify.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="11" x2="20" y2="11"/>
|
||||
<line x1="4" y1="16" x2="20" y2="16"/>
|
||||
<line x1="4" y1="21" x2="20" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
6
assets/icons/mt_align_left.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="4" y1="11" x2="14" y2="11"/>
|
||||
<line x1="4" y1="16" x2="18" y2="16"/>
|
||||
<line x1="4" y1="21" x2="12" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 322 B |
6
assets/icons/mt_align_right.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="4" y1="6" x2="20" y2="6"/>
|
||||
<line x1="10" y1="11" x2="20" y2="11"/>
|
||||
<line x1="6" y1="16" x2="20" y2="16"/>
|
||||
<line x1="12" y1="21" x2="20" y2="21"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 324 B |
3
assets/icons/mt_bold.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<path d="M8 5 h5 a3 3 0 0 1 0 6 h-5 z M8 11 h6 a3.2 3.2 0 0 1 0 6.4 h-6 z" fill="#e0e0e0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 185 B |
4
assets/icons/mt_cancel.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e06c6c" stroke-width="2.5" stroke-linecap="round">
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 242 B |
5
assets/icons/mt_italic.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round">
|
||||
<line x1="10" y1="5" x2="17" y2="5"/>
|
||||
<line x1="7" y1="19" x2="14" y2="19"/>
|
||||
<line x1="14" y1="5" x2="10" y2="19"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
6
assets/icons/mt_lower.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="7" cy="14" r="3.2"/>
|
||||
<line x1="10.2" y1="11" x2="10.2" y2="17.2"/>
|
||||
<circle cx="16.5" cy="14" r="3.2"/>
|
||||
<line x1="19.7" y1="11" x2="19.7" y2="17.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 353 B |
3
assets/icons/mt_ok.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#4ccf6f" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 13 l4 4 l10 -11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 219 B |
4
assets/icons/mt_overline.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="6" y1="4" x2="18" y2="4"/>
|
||||
<path d="M7 8 v6 a5 5 0 0 0 10 0 v-6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 265 B |
4
assets/icons/mt_strike.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 6 v4 a4 4 0 0 0 8 0 v-4"/>
|
||||
<line x1="5" y1="13" x2="19" y2="13"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
4
assets/icons/mt_underline.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M7 5 v6 a5 5 0 0 0 10 0 v-6"/>
|
||||
<line x1="6" y1="20" x2="18" y2="20"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
4
assets/icons/mt_upper.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#e0e0e0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 18 L7 6 L11 18 M4.3 14 H9.7"/>
|
||||
<path d="M14 18 L17.5 8 L21 18 M15.1 14.7 H19.9"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<mtext_editor::MTextEditorState>,
|
||||
/// Which layout tab has its context menu open (None = closed).
|
||||
layout_context_menu: Option<String>,
|
||||
/// 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,
|
||||
|
|
|
|||
349
src/app/mtext_editor.rs
Normal file
|
|
@ -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<l|c|r|j>;`.
|
||||
#[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<font>;` run ("" = style default).
|
||||
pub font: String,
|
||||
/// Global colour ACI (256 = ByLayer) applied via a leading `\C<aci>;`.
|
||||
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<WireModel>,
|
||||
/// 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<Handle>,
|
||||
/// 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<Handle>) -> 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::<f64>().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<f64> {
|
||||
let v = s.trim().parse::<f64>().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<Handle>,
|
||||
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<WireModel> = 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
295
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<String> = 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<Vec<(f32, f32)>>,
|
||||
}
|
||||
|
||||
impl iced::widget::canvas::Program<Message> for MTextPreview {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &iced::Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: iced::Rectangle,
|
||||
_cursor: iced::mouse::Cursor,
|
||||
) -> Vec<iced::widget::canvas::Geometry> {
|
||||
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<Vec<(f32, f32)>> {
|
||||
let mut out: Vec<Vec<(f32, f32)>> = 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<String>,
|
||||
) -> 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<String> = 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::<Vec<_>>(),
|
||||
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::<Vec<_>>(),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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<Handle>,
|
||||
initial: String,
|
||||
height: f64,
|
||||
},
|
||||
/// Apply new pattern/scale/angle to an existing hatch entity.
|
||||
HatcheditApply {
|
||||
handle: Handle,
|
||||
|
|
|
|||
|
|
@ -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<CmdResult> {
|
||||
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<WireModel> {
|
||||
None
|
||||
}
|
||||
|
|
|
|||