// 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::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; use glam::DVec3; 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, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ParaNumber { FirstIndent, LeftIndent, RightIndent, SpaceBefore, SpaceAfter, } /// `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: DVec3, /// The editable text buffer (raw value with inline codes). pub content: text_editor::Content, /// Structured mirror of `content`, parsed via acadrust `parse_mtext` — the /// target authority for the editor. Phase 1: passive mirror kept in sync on /// every rebuild, cross-checked against the rendered glyph boxes; later /// phases make it the source of truth and delete the raw-string path. pub doc: MTextDocument, /// 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, pub rect_height: String, pub annotative: bool, pub column_type: i16, pub column_count: String, pub column_auto_height: bool, pub column_flow_reversed: bool, pub column_width: String, pub column_gutter: String, pub paragraph_first_indent: String, pub paragraph_left_indent: String, pub paragraph_right_indent: String, pub paragraph_space_before: String, pub paragraph_space_after: String, pub find_text: String, pub replace_text: String, undo: Vec, redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw /// code/text input. Toggled so the two never stack. pub show_preview: bool, /// Whether the toolbar colour picker popup is open. pub color_picker_open: bool, /// Per-visible-character boxes (world XY, world_offset already removed) for /// click-to-select in the preview, and the current selection as a visible- /// character range `[start, end)`. pub glyph_boxes: Vec, pub sel: Option<(usize, usize)>, /// Anchor offset for an in-progress drag selection. pub sel_anchor: usize, /// Text caret as a visible-character offset (0..=count). Used for typing /// directly into the preview. pub caret: usize, /// Blink phase — the caret is drawn only when true; reset to true on any /// edit/caret move so it's solid right after activity. pub caret_blink_on: bool, /// Canvas-space anchor where the toolbar + text area are drawn (the /// insertion-point click position). pub screen_anchor: iced::Point, /// The entity as it was opened, when editing an existing MText. `build_mtext` /// starts from this so every field the toolbar doesn't touch — columns, /// rotation, normal, background fill, defined height, annotative flag — /// survives the edit instead of resetting to `MText::default()`. `None` for /// a freshly created MText, which has nothing to preserve. pub original: Option, } impl MTextEditorState { pub fn new(pos: DVec3, initial: &str, height: f64, editing: Option) -> Self { Self { pos, content: text_editor::Content::with_text(initial), doc: parse_mtext(initial, true), 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(), show_preview: true, color_picker_open: false, glyph_boxes: Vec::new(), sel: None, sel_anchor: 0, caret: 0, caret_blink_on: true, 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), rect_height: "0".to_string(), annotative: false, column_type: 0, column_count: "2".to_string(), column_auto_height: false, column_flow_reversed: false, column_width: (height * 10.0).to_string(), column_gutter: (height * 5.0).to_string(), paragraph_first_indent: "0".to_string(), paragraph_left_indent: "0".to_string(), paragraph_right_indent: "0".to_string(), paragraph_space_before: "0".to_string(), paragraph_space_after: "0".to_string(), find_text: String::new(), replace_text: String::new(), undo: Vec::new(), redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, } } pub fn height_value(&self) -> f64 { self.height.trim().parse::().ok().filter(|h| *h > 0.0).unwrap_or(0.25) } /// Pixels per drawing unit used by the editor preview. The dominant glyph /// height is always displayed at the same screen size; wrapping width must /// never participate in this calculation. pub(in crate::app) fn preview_scale(&self) -> f32 { let mut heights: Vec = self .glyph_boxes .iter() .filter(|b| b.xmax - b.xmin > 1e-6) .map(|b| b.ymax - b.ymin) .filter(|height| height.is_finite() && *height > 1e-6) .collect(); let unit = if heights.is_empty() { self.height_value() as f32 } else { heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); let (mut best_height, mut best_count, mut index) = (heights[0], 0usize, 0usize); while index < heights.len() { let mut end = index; while end < heights.len() && heights[end] <= heights[index] * 1.05 { end += 1; } if end - index > best_count { best_count = end - index; best_height = heights[(index + end - 1) / 2]; } index = end; } best_height }; (super::view::overlay::MTEXT_PREVIEW_EM_PX / unit.max(1e-6)).clamp(1e-4, 1e6) } /// Fold the toolbar's global defaults (font / colour / oblique / width / /// char-spacing) onto every span that does not already override them. This /// replaces the old hand-built `\f;\C;…` prefix so the value is produced /// purely by acadrust's `to_mtext_string`. fn apply_globals(&self, doc: &mut MTextDocument) { let font = self.font.trim(); let color = (self.color_aci != 256 && self.color_aci != 0).then(|| MTextColor::Index(self.color_aci)); let oblique = parse_non_default(&self.oblique, 0.0); let width = parse_non_default(&self.width, 1.0); let tracking = parse_non_default(&self.char_space, 0.0); for para in &mut doc.paragraphs { for span in &mut para.spans { let p = &mut span.properties; if !font.is_empty() && p.font.is_none() { p.font = Some(MTextFont::from_name(font)); } if p.color.is_none() && p.color_rgb.is_none() { if let Some(ref c) = color { p.color = Some(c.clone()); } } if p.oblique_angle.is_none() { p.oblique_angle = oblique; } if p.width_factor.is_none() { p.width_factor = width; } if p.tracking.is_none() { p.tracking = tracking; } } } } /// The committed / previewed MText value: the structured body with the /// global toolbar defaults folded in, serialized by acadrust. fn folded_value(&self) -> String { let raw = self.content.text(); let mut doc = parse_mtext(&raw, true); // parse_mtext drops a trailing empty paragraph; restore it so Enter at // the end keeps its new (empty) line. if content_has_trailing_break(&raw) { doc.paragraphs.push(MTextParagraph::new()); } self.apply_globals(&mut doc); doc.to_mtext_string() } /// Build the MText entity from the current editor state for preview/commit. /// /// Starts from the original entity (when editing one) so fields the toolbar /// never exposes — columns above all, plus rotation, normal, defined height, /// background fill, annotative flag — round-trip untouched. Rebuilding from /// `MText::default()` here is what silently stripped a columnar MTEXT's /// columns the moment it was edited. pub fn build_mtext(&self) -> MText { let mut mt = self.original.clone().unwrap_or_default(); mt.value = self.folded_value(); mt.insertion_point = Vector3::new(self.pos.x, self.pos.y, self.pos.z); mt.height = self.height_value(); mt.rectangle_width = self.rect_width; mt.attachment_point = self.attachment; mt.line_spacing_factor = self.line_spacing as f64; mt.style = self.style.clone(); mt.is_annotative = self.annotative; let rectangle_height = self.rect_height.trim().parse::().unwrap_or(0.0); mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height); mt.column_data.column_type = self.column_type; if self.column_type == 0 { mt.column_data.column_count = 0; mt.column_data.heights.clear(); } else { mt.column_data.column_count = crate::entities::text_support::clamp_mtext_column_count( self.column_count .trim() .parse::() .ok() .filter(|count| *count > 0) .unwrap_or(1), ); mt.column_data.auto_height = self.column_auto_height; mt.column_data.flow_reversed = self.column_flow_reversed; if let Ok(width) = self.column_width.trim().parse::() { if width > 0.0 { mt.column_data.width = width; } } if let Ok(gutter) = self.column_gutter.trim().parse::() { if gutter >= 0.0 { mt.column_data.gutter = gutter; } } if !self.column_auto_height && rectangle_height > 0.0 { mt.column_data.heights = vec![ rectangle_height; mt.column_data.column_count as usize ]; } } mt } fn record_undo(&mut self) { let value = self.content.text(); if self.undo.last().is_none_or(|last| last != &value) { self.undo.push(value); if self.undo.len() > 100 { self.undo.remove(0); } } self.redo.clear(); } } /// 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) } } /// One editable unit in the MText editor's flat visible-index space — exactly /// one per caret slot, replacing the raw-string `visible_spans` walker for /// editing. The editor flattens its `doc` to a `Vec`, splices/drains it /// (trivial), and rebuilds a structured `MTextDocument`; the flat index IS the /// caret index, so caret/selection stay verbatim. #[derive(Clone, Debug, PartialEq)] enum Cell { /// A visible character carrying its span's formatting. Tabs count as a /// normal char here (one slot), matching the previous caret model. Char(char, SpanProperties), /// A paragraph break, carrying the paragraph properties (alignment, /// indents…) of the paragraph it opens, so they survive an edit round-trip. /// The `bool` is `starts_column`: `true` for a `\N` column break, `false` /// for a plain `\P`. Without it every edit flattens `\N` to `\P` and a /// columnar MTEXT loses its columns on the first keystroke. Break(ParagraphProperties, bool), /// One flattened glyph of a stacking (`\S`) span, kept atomic: continuation /// cells (`head=false`) extend the run begun by a `head=true` one, and edits /// never split a run. Stack { data: StackingData, props: SpanProperties, head: bool, }, } impl Cell { fn is_stack_cont(&self) -> bool { matches!(self, Cell::Stack { head: false, .. }) } } /// The flattened `numden` glyphs of a stacking span, in reading order. fn flatten_stack(st: &StackingData) -> String { let sep = match st.stacking_type { StackingType::Limit => '^', StackingType::Diagonal => '#', StackingType::Horizontal => '/', }; let mut flat = st.numerator.clone(); if !st.denominator.is_empty() { flat.push(sep); flat.push_str(&st.denominator); } flat } /// Flatten a document into one cell per visible slot (same order + count as the /// layout's glyph boxes). This is the editor's caret index space. The first /// paragraph's properties are the implicit para-0 default (see [`doc_para0`]); /// each `Break` carries the properties of the paragraph it opens. fn doc_to_cells(doc: &MTextDocument) -> Vec { let mut cells = Vec::new(); for (pi, para) in doc.paragraphs.iter().enumerate() { if pi > 0 { cells.push(Cell::Break(para.properties.clone(), para.starts_column)); } for span in ¶.spans { if let Some(st) = &span.stacking { let mut head = true; for _ in flatten_stack(st).chars() { cells.push(Cell::Stack { data: st.clone(), props: span.properties.clone(), head, }); head = false; } } else { for ch in span.text.chars() { cells.push(Cell::Char(ch, span.properties.clone())); } } } } cells } /// The first paragraph's properties (the para-0 default not carried by any /// `Break`), or the default for an empty document. fn doc_para0(doc: &MTextDocument) -> ParagraphProperties { doc.paragraphs .first() .map(|p| p.properties.clone()) .unwrap_or_default() } /// Rebuild a document from an edited cell list. Adjacent `Char` cells with equal /// properties coalesce into one span; a `Break` starts a new paragraph carrying /// its stored properties; a `Stack` run's head cell re-emits the stacking span. /// `para0` supplies the first paragraph's properties. fn cells_to_doc(para0: &ParagraphProperties, cells: &[Cell]) -> MTextDocument { let mut doc = MTextDocument::new(); doc.paragraphs.clear(); let mut para = MTextParagraph::new(); para.properties = para0.clone(); for cell in cells { match cell { Cell::Break(props, starts_column) => { doc.paragraphs .push(std::mem::replace(&mut para, MTextParagraph::new())); para.properties = props.clone(); para.starts_column = *starts_column; } Cell::Char(ch, props) => match para.spans.last_mut() { Some(s) if s.stacking.is_none() && &s.properties == props => s.text.push(*ch), _ => para.spans.push(MTextSpan::new(ch.to_string(), props.clone())), }, Cell::Stack { data, props, head } => { if *head { let mut s = MTextSpan::new(String::new(), props.clone()); s.stacking = Some(data.clone()); para.spans.push(s); } } } } doc.paragraphs.push(para); doc } /// The paragraph properties in force at flat index `caret` — the last `Break` /// before it, or the para-0 default. Used so a newly inserted break clones the /// current paragraph's properties. fn para_props_at(para0: &ParagraphProperties, cells: &[Cell], caret: usize) -> ParagraphProperties { let mut props = para0.clone(); for cell in cells.iter().take(caret) { if let Cell::Break(p, _) = cell { props = p.clone(); } } props } /// Turn an inserted string (which may carry `\P` breaks) into cells, tagging /// every plain char with `span_props` and every break with `para_props`. fn str_to_cells(s: &str, span_props: &SpanProperties, para_props: &ParagraphProperties) -> Vec { let mut cells = Vec::new(); for (i, seg) in s.split("\\P").enumerate() { if i > 0 { // A typed / pasted break is a plain `\P`; the editor offers no way to // insert a column break, so existing `\N`s are the only columnar ones. cells.push(Cell::Break(para_props.clone(), false)); } for ch in seg.chars() { cells.push(Cell::Char(ch, span_props.clone())); } } cells } /// Properties newly-typed text at `caret` inherits — the cell to its left, or /// the default at the start of a paragraph / document. fn insert_props(cells: &[Cell], caret: usize) -> SpanProperties { match caret.checked_sub(1).and_then(|i| cells.get(i)) { Some(Cell::Char(_, p)) | Some(Cell::Stack { props: p, .. }) => p.clone(), _ => SpanProperties::default(), } } /// Nudge an insertion index out of the interior of a stacking run (never split /// a fraction): step forward past any continuation cells. fn clamp_insert(cells: &[Cell], mut caret: usize) -> usize { while caret < cells.len() && cells[caret].is_stack_cont() { caret += 1; } caret } /// True when the serialized MText value ends with a hard paragraph break — /// i.e. a trailing empty paragraph, which `parse_mtext` drops. `to_mtext_string` /// wraps multi-paragraph output in `{…}`, so strip a trailing `}` first, then /// require an ODD run of backslashes before a final `P` (an even run is an /// escaped literal `\\`, not the `\P` break code). fn content_has_trailing_break(raw: &str) -> bool { let s = raw.trim_end(); let s = s.strip_suffix('}').unwrap_or(s); match s.strip_suffix('P') { Some(rest) => rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1, None => false, } } /// Character class for double-click word selection: a word run, a whitespace /// run, or a paragraph break (each selects a maximal same-class run). #[derive(PartialEq)] enum Class { Word, Space, Break, } fn cell_class(c: &Cell) -> Class { match c { Cell::Break(..) => Class::Break, Cell::Char(ch, _) if ch.is_whitespace() => Class::Space, // A char or an atomic stacking glyph is part of a word. _ => Class::Word, } } /// The `[start, end)` visible range of the word at flat index `off` — the /// maximal run of the same character class around it. Used for double-click. fn word_range(cells: &[Cell], off: usize) -> (usize, usize) { let n = cells.len(); if n == 0 { return (0, 0); } // Reference the cell at `off`, or the one before it when past the end. let idx = off.min(n - 1); let class = cell_class(&cells[idx]); if class == Class::Break { return (idx, idx + 1); } let mut a = idx; while a > 0 && cell_class(&cells[a - 1]) == class { a -= 1; } let mut b = idx + 1; while b < n && cell_class(&cells[b]) == class { b += 1; } (a, b) } /// Delete cells `[a, b)`, first widening the range so it never cuts a stacking /// run in half. Returns the (possibly widened) start index — the new caret. fn cells_delete_range(cells: &mut Vec, mut a: usize, mut b: usize) -> usize { let n = cells.len(); a = a.min(n); b = b.clamp(a, n); // Never start inside a stacking run — back up to its head cell. while a < cells.len() && cells[a].is_stack_cont() { a -= 1; } // ...and never stop inside one — extend to the run's end. while b < cells.len() && cells[b].is_stack_cont() { b += 1; } cells.drain(a..b); a } // ── App-side editor driver ────────────────────────────────────────────────── use crate::scene::convert::tessellate; use crate::scene::model::wire_model::WireModel; fn vertical_caret_target( boxes: &[crate::entities::text_support::GlyphBox], caret: usize, visible_count: usize, direction: i8, ) -> usize { if boxes.is_empty() || direction == 0 { return caret.min(visible_count); } let anchor = boxes .iter() .find(|item| item.vis == caret) .map(|item| ((if item.is_rtl { item.xmax } else { item.xmin }), item.ymin, item.ymax)) .or_else(|| { caret.checked_sub(1).and_then(|previous| { boxes .iter() .find(|item| item.vis == previous) .map(|item| ((if item.is_rtl { item.xmin } else { item.xmax }), item.ymin, item.ymax)) }) }) .or_else(|| boxes.first().map(|item| ((if item.is_rtl { item.xmax } else { item.xmin }), item.ymin, item.ymax))); let Some((anchor_x, anchor_y, anchor_top)) = anchor else { return caret.min(visible_count); }; let sign = f32::from(direction.signum()); let line_epsilon = ((anchor_top - anchor_y).abs() * 0.15).max(1e-5); let nearest_line = boxes .iter() .filter_map(|item| { let distance = (item.ymin - anchor_y) * sign; (distance > line_epsilon).then_some(distance) }) .fold(f32::INFINITY, f32::min); if !nearest_line.is_finite() { return caret.min(visible_count); } let line_tolerance = line_epsilon.max(nearest_line * 0.15); let mut best = (f32::INFINITY, caret.min(visible_count)); for item in boxes { let distance = (item.ymin - anchor_y) * sign; if (distance - nearest_line).abs() > line_tolerance { continue; } for (x, offset) in [(item.xmin, item.vis), (item.xmax, item.vis + 1)] { let score = (x - anchor_x).abs(); if score < best.0 { best = (score, offset.min(visible_count)); } } } best.1 } fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { state.attachment = m.attachment_point; state.line_spacing = m.line_spacing_factor as f32; state.rect_width = m.rectangle_width.max(0.0); state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string(); state.annotative = m.is_annotative; state.column_type = m.column_data.column_type; state.column_count = crate::entities::text_support::clamp_mtext_column_count( m.column_data.column_count, ) .to_string(); state.column_auto_height = m.column_data.auto_height; state.column_flow_reversed = m.column_data.flow_reversed; state.column_width = m.column_data.width.max(0.0).to_string(); state.column_gutter = m.column_data.gutter.max(0.0).to_string(); if !m.style.trim().is_empty() { state.style = m.style.clone(); } state.original = Some(m.clone()); } impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. /// Open the rich MText editor for a new or existing MText / MultiLeader. /// The committed slot is chosen by the edited entity's type. pub(super) fn open_mtext_editor( &mut self, pos: DVec3, handle: Option, initial: &str, height: f64, template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; } 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 / box width from the entity being edited. let has_template = template.is_some(); if let Some(h) = handle { match self.tabs[self.active_tab].scene.document.get_entity(h) { Some(EntityType::MText(m)) => { seed_mtext_state(&mut state, m); } Some(EntityType::MultiLeader(ml)) => { state.line_spacing = ml.context.line_spacing_factor as f32; if ml.context.text_width > 0.0 { state.rect_width = ml.context.text_width; } } _ => {} } } else if let Some(m) = template { seed_mtext_state(&mut state, &m); } else { // New MText inherits the document's current text style (STYLE), // not the "Standard" default. See #92. let cur_style = self.tabs[self.active_tab] .scene .document .header .current_text_style_name .clone(); if !cur_style.trim().is_empty() { state.style = cur_style; } } self.mtext_editor = Some(state); // Open centred at the editor's natural size: it renders through the // shared modal frame, which `modal_offset` positions and `modal_resize` // grows (both draggable, so reset on open). self.reset_modal_geometry(); self.rebuild_mtext_preview(); // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right // edge of the editor, placing both ruler and slider at their maximum. if handle.is_none() && !has_template { let initial_width = self.mtext_editor.as_ref().map(|ed| { super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale() }); if let (Some(ed), Some(width)) = (self.mtext_editor.as_mut(), initial_width) { ed.rect_width = f64::from(width.max(1e-6)); } self.rebuild_mtext_preview(); } // Place the caret at the end so typing works without a click first. let end = self.mtext_vis_count(); if let Some(ed) = self.mtext_editor.as_mut() { ed.caret = end; ed.sel = Some((end, end)); ed.sel_anchor = end; } } /// 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 mut mt = ed.build_mtext(); // A trailing empty paragraph (a fresh line after Enter) is dropped by the // layout's parser, so it emits no caret box there and the caret would be // invisible on that line. Inject a space into the PREVIEW value only // (never committed) so the layout lays the line out and the caret gets a // box on it. Keyed off the current content — `ed.doc` is only re-synced // further down in this function, so it is still stale here. if content_has_trailing_break(&ed.content.text()) { match mt.value.rfind('}') { Some(pos) => mt.value.insert(pos, ' '), None => mt.value.push(' '), } } // Lay the preview out at the origin, not at the entity's real insertion. // The panel fits the strokes to its own box and never places them on the // drawing, so world position is irrelevant here — and a UTM-scale // insertion cast to f32 (glyph boxes) or split high/low (strokes, whose // low half this 2-D canvas drops) garbles every glyph through // catastrophic cancellation. Insertion is a pure translation, so zeroing // it keeps the layout's shape, wrapping and alignment identical. mt.insertion_point = Vector3::new(0.0, 0.0, 0.0); let entity = EntityType::MText(mt.clone()); let anno = 1.0; let bg = self.tabs[i].scene.bg_color; 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, anno, None, None, bg, // Editor preview draws on a 2D canvas with no SDF shader — force the // glyph outline strokes so the text is visible (#308). true, ); // Per-character boxes share the preview wires' absolute coordinate frame. let boxes = crate::entities::mtext::glyph_boxes(&mt, &self.tabs[i].scene.document); if let Some(ed) = self.mtext_editor.as_mut() { ed.preview_wires = wires; ed.glyph_boxes = boxes; // Re-sync the structured doc from the (authoritative) content — the // single sync point, since every edit path ends in a rebuild. // `parse_mtext` drops a trailing empty paragraph, so restore it when // the content ends in a hard break, else Enter at the end would lose // the new line. let raw = ed.content.text(); let mut d = parse_mtext(&raw, true); if content_has_trailing_break(&raw) { d.paragraphs.push(MTextParagraph::new()); } ed.doc = d; } } /// Splice text around the preview selection (visible-char range) in the /// raw value. `case` optionally transforms the selected slice. Returns /// true when a preview selection was present and applied. pub(super) fn mtext_undo(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { if let Some(previous) = ed.undo.pop() { ed.redo.push(ed.content.text()); ed.content = text_editor::Content::with_text(&previous); ed.caret = previous.chars().count(); ed.sel = Some((ed.caret, ed.caret)); ed.sel_anchor = ed.caret; } } self.rebuild_mtext_preview(); } pub(super) fn mtext_redo(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { if let Some(next) = ed.redo.pop() { ed.undo.push(ed.content.text()); ed.content = text_editor::Content::with_text(&next); ed.caret = next.chars().count(); ed.sel = Some((ed.caret, ed.caret)); ed.sel_anchor = ed.caret; } } self.rebuild_mtext_preview(); } pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) { let Some(number) = value.trim().replace(',', ".").parse::().ok() else { return; }; let valid = match field { "height" => number > 0.0, "oblique" => (-85.0..=85.0).contains(&number), "width" => number > 0.0, "tracking" => number.is_finite(), _ => false, }; if !valid { return; } let has_selection = self .mtext_editor .as_ref() .and_then(|editor| editor.sel) .is_some_and(|(start, end)| start < end); if let Some(ed) = self.mtext_editor.as_mut() { if has_selection { let (start, end) = ed.sel.unwrap(); ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let end = end.min(cells.len()); for cell in &mut cells[start.min(end)..end] { if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { match field { "height" => props.height = Some(MTextScalar::Absolute(number)), "oblique" => props.oblique_angle = (number != 0.0).then_some(number), "width" => props.width_factor = (number != 1.0).then_some(number), "tracking" => props.tracking = (number != 0.0).then_some(number), _ => {} } } } ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.sel = Some((start, end)); ed.caret = end; } else { match field { "height" => ed.height = value, "oblique" => ed.oblique = value, "width" => ed.width = value, "tracking" => ed.char_space = value, _ => {} } } } self.rebuild_mtext_preview(); } pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { let Some(number) = value.trim().replace(',', ".").parse::().ok() else { return; }; if !number.is_finite() { return; } if let Some(ed) = self.mtext_editor.as_mut() { ed.record_undo(); match field { ParaNumber::FirstIndent => ed.paragraph_first_indent = value, ParaNumber::LeftIndent => ed.paragraph_left_indent = value, ParaNumber::RightIndent => ed.paragraph_right_indent = value, ParaNumber::SpaceBefore => ed.paragraph_space_before = value, ParaNumber::SpaceAfter => ed.paragraph_space_after = value, } let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (start, end) = ed .sel .filter(|(start, end)| start < end) .unwrap_or((ed.caret, ed.caret)); let paragraph_of = |index: usize| { cells .iter() .take(index.min(cells.len())) .filter(|cell| matches!(cell, Cell::Break(..))) .count() }; let first = paragraph_of(start); let last = paragraph_of(end.saturating_sub((end > start) as usize)); let mut doc = cells_to_doc(¶0, &cells); for paragraph in doc .paragraphs .iter_mut() .skip(first) .take(last.saturating_sub(first) + 1) { let optional = (number != 0.0).then_some(number); match field { ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, ParaNumber::RightIndent => paragraph.properties.right_margin = optional, ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, } } ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); } self.rebuild_mtext_preview(); } pub(super) fn mtext_stack_selection(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { return; }; let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let end = end.min(cells.len()); let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) .to_plain_text(); let Some((split, separator)) = plain .char_indices() .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) else { return; }; let separator_len = separator.len_utf8(); let data = StackingData { numerator: plain[..split].to_string(), denominator: plain[split + separator_len..].to_string(), stacking_type: StackingType::from_char(separator), }; if data.numerator.is_empty() || data.denominator.is_empty() { return; } let props = cells[start..end] .iter() .find_map(|cell| match cell { Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), Cell::Break(..) => None, }) .unwrap_or_default(); ed.record_undo(); let replacement = flatten_stack(&data) .chars() .enumerate() .map(|(index, _)| Cell::Stack { data: data.clone(), props: props.clone(), head: index == 0, }) .collect::>(); let replacement_len = replacement.len(); cells.splice(start..end, replacement); let new_end = start + replacement_len; ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.sel = Some((start, new_end)); ed.caret = new_end; ed.sel_anchor = start; } self.rebuild_mtext_preview(); } pub(super) fn mtext_clear_formatting(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let (start, end) = ed .sel .filter(|(start, end)| start < end) .unwrap_or((0, cells.len())); let end = end.min(cells.len()); ed.record_undo(); for cell in &mut cells[start.min(end)..end] { if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { *props = SpanProperties::default(); } } ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.sel = Some((start, end)); ed.caret = end; } self.rebuild_mtext_preview(); } pub(super) fn mtext_find_next(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { let needle: Vec = ed.find_text.chars().collect(); if needle.is_empty() { return; } let cells = doc_to_cells(&ed.doc); let chars: Vec> = cells .iter() .map(|cell| match cell { Cell::Char(ch, _) => Some(*ch), Cell::Break(..) => Some('\n'), Cell::Stack { .. } => None, }) .collect(); if needle.len() > chars.len() { return; } let start = ed.caret.min(chars.len()); let last_start = chars.len() - needle.len(); let found = (start..=last_start) .chain(0..start.min(last_start + 1)) .find(|index| { chars[*index..*index + needle.len()] .iter() .zip(&needle) .all(|(actual, expected)| actual == &Some(*expected)) }); if let Some(found) = found { ed.sel_anchor = found; ed.sel = Some((found, found + needle.len())); ed.caret = found + needle.len(); ed.caret_blink_on = true; } } } pub(super) fn mtext_replace_next(&mut self) { let replacement = self .mtext_editor .as_ref() .map(|editor| editor.replace_text.clone()) .unwrap_or_default(); let matches = self.mtext_editor.as_ref().is_some_and(|editor| { let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else { return false; }; let cells = doc_to_cells(&editor.doc); cells_to_doc( &ParagraphProperties::default(), &cells[start.min(cells.len())..end.min(cells.len())], ) .to_plain_text() == editor.find_text }); if matches { self.mtext_type(&replacement); } self.mtext_find_next(); } pub(super) fn mtext_replace_all(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { let needle: Vec = ed.find_text.chars().collect(); if needle.is_empty() { return; } let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let replacement = ed.replace_text.clone(); let mut matches = Vec::new(); let mut index = 0usize; while index + needle.len() <= cells.len() { let found = cells[index..index + needle.len()] .iter() .zip(&needle) .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); if found { matches.push(index); index += needle.len(); } else { index += 1; } } if matches.is_empty() { return; } ed.record_undo(); for start in matches.into_iter().rev() { let props = insert_props(&cells, start); let paragraph = para_props_at(¶0, &cells, start); let replacement_cells = str_to_cells(&replacement, &props, ¶graph); cells.splice(start..start + needle.len(), replacement_cells); } ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.caret = cells.len(); ed.sel = Some((ed.caret, ed.caret)); ed.sel_anchor = ed.caret; } self.rebuild_mtext_preview(); } /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° /// oblique; underline / overline / strike flip the stroke flags; case /// rewrites the glyphs. All are true ON/OFF toggles. pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) { // Does every span-carrying cell in the range satisfy `get`? fn all_have(cells: &[Cell], get: impl Fn(&SpanProperties) -> bool) -> bool { cells.iter().all(|c| match c { Cell::Char(_, p) | Cell::Stack { props: p, .. } => get(p), Cell::Break(..) => true, }) } // Apply `f` to every span-carrying cell's properties in the range. fn each(cells: &mut [Cell], mut f: impl FnMut(&mut SpanProperties)) { for c in cells { match c { Cell::Char(_, p) | Cell::Stack { props: p, .. } => f(p), Cell::Break(..) => {} } } } // The effective font name to stamp on bold runs. `to_mtext_string` drops // a font code whose name is empty, which would lose the bold flag on the // round-trip; stamping the resolved style (or global) font keeps bold AND // renders the same font, just with the wider bold pen. let bold_font: String = if matches!(kind, MTextFmt::Bold) { let doc = &self.tabs[self.active_tab].scene.document; self.mtext_editor .as_ref() .map(|ed| { if !ed.font.trim().is_empty() { ed.font.clone() } else { crate::entities::text_support::resolve_text_style(&ed.style, doc) .font_name } }) .unwrap_or_default() } else { String::new() }; if let Some(ed) = self.mtext_editor.as_mut() { let Some((a, b)) = ed.sel.filter(|&(a, b)| a < b) else { return; }; let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a >= b { return; } ed.record_undo(); match kind { MTextFmt::Underline => { let on = !all_have(&cells[a..b], |p| p.underline()); each(&mut cells[a..b], |p| p.set_underline(on)); } MTextFmt::Overline => { let on = !all_have(&cells[a..b], |p| p.overline()); each(&mut cells[a..b], |p| p.set_overline(on)); } MTextFmt::Strike => { let on = !all_have(&cells[a..b], |p| p.strikethrough()); each(&mut cells[a..b], |p| p.set_strikethrough(on)); } MTextFmt::Italic => { // Italic is the editor's canonical 15° oblique. An imported // run may instead carry the font italic flag (`\f…|i1;`); // recognise that as italic too, and clear it on toggle so // the one canonical form is the oblique — otherwise an // imported italic run could never be switched off. let on = !all_have(&cells[a..b], |p| { p.oblique_angle == Some(15.0) || p.font.as_ref().is_some_and(|f| f.italic) }); each(&mut cells[a..b], |p| { p.oblique_angle = on.then_some(15.0); if let Some(f) = p.font.as_mut() { f.italic = false; } }); } MTextFmt::Bold => { // Real bold: set the font's bold flag (the SDF renderer bakes // a wider pen), keeping the run's font name + italic. let on = !all_have(&cells[a..b], |p| { p.font.as_ref().is_some_and(|f| f.bold) }); each(&mut cells[a..b], |p| { let (name, italic) = p .font .as_ref() .map(|f| (f.name.clone(), f.italic)) .unwrap_or_default(); // Bold needs a non-empty name to serialize; stamp the // effective font when the run had none. let name = if name.trim().is_empty() { bold_font.clone() } else { name }; p.font = if on { Some(MTextFont::with_flags(name, true, italic)) } else if !italic && (name.is_empty() || name == bold_font) { None // bold was the only reason for the font override } else { Some(MTextFont::with_flags(name, false, italic)) }; }); } MTextFmt::Uppercase | MTextFmt::Lowercase => { let up = kind == MTextFmt::Uppercase; for c in &mut cells[a..b] { if let Cell::Char(ch, _) = c { let mapped = if up { ch.to_uppercase().next() } else { ch.to_lowercase().next() }; *ch = mapped.unwrap_or(*ch); } } } } ed.content = text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.sel = Some((a, b)); ed.caret = b; ed.caret_blink_on = true; } self.rebuild_mtext_preview(); } /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { if let Some(ed) = self.mtext_editor.as_mut() { ed.record_undo(); let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (a, b) = ed .sel .filter(|&(a, b)| a < b) .unwrap_or((ed.caret, ed.caret)); let a = a.min(cells.len()); let b = b.min(cells.len()); let want = Some(match align { ParaAlign::Left => MTextParagraphAlignment::Left, ParaAlign::Center => MTextParagraphAlignment::Center, ParaAlign::Right => MTextParagraphAlignment::Right, ParaAlign::Justify => MTextParagraphAlignment::Justified, }); // Paragraph index of a cell = number of breaks before it. let para_of = |idx: usize| cells.iter().take(idx).filter(|c| matches!(c, Cell::Break(..))).count(); let last_cell = if b > a { b - 1 } else { a }; let first = para_of(a); let last = para_of(last_cell); let mut doc = cells_to_doc(¶0, &cells); let end = last.min(doc.paragraphs.len().saturating_sub(1)); for p in first..=end { doc.paragraphs[p].properties.alignment = want; } ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); ed.caret_blink_on = true; } self.rebuild_mtext_preview(); } // ── Caret editing directly on the preview ─────────────────────────────── /// Insert text (or replace the current selection) at the preview caret. pub(super) fn mtext_type(&mut self, s: &str) { // MTEXT stores a hard line break as the `\P` code. A literal newline — // from Enter or a multi-line paste — is not a break to the layout (only // the caret logic honours it), so the preview would stay on one line. // Normalise every literal newline to `\P` so it breaks and the saved // value stays standard (#308). let normalized; let s: &str = if s.contains(['\n', '\r']) { normalized = s .replace("\r\n", "\\P") .replace('\n', "\\P") .replace('\r', "\\P"); &normalized } else { s }; if let Some(ed) = self.mtext_editor.as_mut() { ed.record_undo(); // Edit the structured document as a flat cell list, then serialize // it back into the raw content the preview/commit still read. let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); let mut caret = match ed.sel { Some((a, b)) if a < b && b <= count => cells_delete_range(&mut cells, a, b), _ => ed.caret.min(count), }; caret = clamp_insert(&cells, caret); let props = insert_props(&cells, caret); let para_props = para_props_at(¶0, &cells, caret); let ins = str_to_cells(s, &props, ¶_props); let added = ins.len(); cells.splice(caret..caret, ins); caret += added; ed.content = text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.sel_anchor = caret; ed.caret_blink_on = true; } self.rebuild_mtext_preview(); } /// Delete the selection, or the visible character before the caret. pub(super) fn mtext_backspace(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); let caret = match ed.sel { Some((a, b)) if a < b && b <= count => cells_delete_range(&mut cells, a, b), _ if ed.caret > 0 && ed.caret <= count => { cells_delete_range(&mut cells, ed.caret - 1, ed.caret) } _ => ed.caret.min(count), }; ed.content = text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.sel_anchor = caret; ed.caret_blink_on = true; } self.rebuild_mtext_preview(); } /// Delete the selection, or the visible character at the caret. pub(super) fn mtext_delete(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); let caret = match ed.sel { Some((a, b)) if a < b && b <= count => cells_delete_range(&mut cells, a, b), _ if ed.caret < count => { cells_delete_range(&mut cells, ed.caret, ed.caret + 1) } _ => ed.caret.min(count), }; ed.content = text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.sel_anchor = caret; ed.caret_blink_on = true; } self.rebuild_mtext_preview(); } /// Move the caret horizontally by `delta` visible characters. pub(super) fn mtext_caret_move(&mut self, delta: i32, extend_selection: bool) { if let Some(ed) = self.mtext_editor.as_mut() { let n = doc_to_cells(&ed.doc).len() as i32; let c = if extend_selection { (ed.caret as i32 + delta).clamp(0, n) as usize } else { match ed.sel { Some((start, end)) if start < end && delta < 0 => start.min(n as usize), Some((start, end)) if start < end && delta > 0 => end.min(n as usize), _ => (ed.caret as i32 + delta).clamp(0, n) as usize, } }; ed.caret = c; if extend_selection { ed.sel = Some((ed.sel_anchor.min(c), ed.sel_anchor.max(c))); } else { ed.sel = Some((c, c)); ed.sel_anchor = c; } ed.caret_blink_on = true; } } /// Move the caret to the visually adjacent text line while preserving its /// horizontal position as closely as the laid-out glyph boxes allow. pub(super) fn mtext_caret_move_vertical( &mut self, direction: i8, extend_selection: bool, ) { if let Some(ed) = self.mtext_editor.as_mut() { let visible_count = doc_to_cells(&ed.doc).len(); let caret = vertical_caret_target( &ed.glyph_boxes, ed.caret, visible_count, direction, ); ed.caret = caret; if extend_selection { ed.sel = Some((ed.sel_anchor.min(caret), ed.sel_anchor.max(caret))); } else { ed.sel = Some((caret, caret)); ed.sel_anchor = caret; } ed.caret_blink_on = true; } } /// Visible-character count of the current text. pub(super) fn mtext_vis_count(&self) -> usize { self.mtext_editor .as_ref() .map(|ed| doc_to_cells(&ed.doc).len()) .unwrap_or(0) } /// Select the whole editor text (Ctrl+A / triple-click). pub(super) fn mtext_select_all(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { let n = doc_to_cells(&ed.doc).len(); ed.sel_anchor = 0; ed.sel = Some((0, n)); ed.caret = n; ed.caret_blink_on = true; } } /// Apply a font choice: to the current selection (per-run) when there is /// one, otherwise to the whole text via the global font. `font` is a family /// name or `"[Style default]"`. pub(super) fn mtext_apply_font(&mut self, font: &str) { let default = font == "[Style default]"; // Effective name to stamp — never empty (so bold/italic survive // serialization): the picked font, or the resolved style font. let name: String = if default { let doc = &self.tabs[self.active_tab].scene.document; self.mtext_editor .as_ref() .map(|ed| { crate::entities::text_support::resolve_text_style(&ed.style, doc).font_name }) .unwrap_or_default() } else { font.to_string() }; let has_sel = self .mtext_editor .as_ref() .and_then(|e| e.sel) .is_some_and(|(a, b)| a < b); if has_sel { if let Some(ed) = self.mtext_editor.as_mut() { let (a, b) = ed.sel.unwrap(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { let (bold, italic) = p .font .as_ref() .map(|f| (f.bold, f.italic)) .unwrap_or_default(); p.font = if default && !bold && !italic { None // reset to inherit the style font } else { Some(MTextFont::with_flags(name.clone(), bold, italic)) }; } } ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.sel = Some((a, b)); ed.caret = b; ed.caret_blink_on = true; } } } else if let Some(ed) = self.mtext_editor.as_mut() { // No selection: the global font applies to the whole text. ed.font = if default { String::new() } else { font.to_string() }; } self.rebuild_mtext_preview(); } /// Apply a colour from the shared Properties-style picker: to the selection /// (per-run) when there is one, else the whole text via the global colour. /// Closes the picker popup. pub(super) fn mtext_apply_color(&mut self, color: acadrust::types::Color) { use acadrust::types::Color as C; let (mcolor, rgb): (Option, Option<(u8, u8, u8)>) = match color { C::Index(i) => (Some(MTextColor::Index(i as u16)), None), C::Rgb { r, g, b } => (None, Some((r, g, b))), _ => (None, None), // ByLayer / ByBlock → inherit }; let has_sel = self .mtext_editor .as_ref() .and_then(|e| e.sel) .is_some_and(|(a, b)| a < b); if has_sel { if let Some(ed) = self.mtext_editor.as_mut() { let (a, b) = ed.sel.unwrap(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { p.color = mcolor.clone(); p.color_rgb = rgb; } } ed.content = text_editor::Content::with_text( &cells_to_doc(¶0, &cells).to_mtext_string(), ); ed.sel = Some((a, b)); ed.caret = b; ed.caret_blink_on = true; } } } else if let Some(ed) = self.mtext_editor.as_mut() { // No selection: the global colour applies (ACI only; a true colour // with no selection falls back to ByLayer). ed.color_aci = match color { C::Index(i) => i as u16, _ => 256, }; } if let Some(ed) = self.mtext_editor.as_mut() { ed.color_picker_open = false; } self.rebuild_mtext_preview(); } /// Select the word at visible offset `off` (double-click). pub(super) fn mtext_select_word(&mut self, off: usize) { if let Some(ed) = self.mtext_editor.as_mut() { let cells = doc_to_cells(&ed.doc); let (a, b) = word_range(&cells, off); ed.sel_anchor = a; ed.sel = Some((a, b)); ed.caret = b; ed.caret_blink_on = true; } } /// The currently selected text as plain text (for Ctrl+C), or `None` when /// the selection is empty. pub(super) fn mtext_selected_text(&self) -> Option { let ed = self.mtext_editor.as_ref()?; let (a, b) = ed.sel.filter(|&(a, b)| a < b)?; let cells = doc_to_cells(&ed.doc); let slice = cells.get(a..b.min(cells.len()))?; Some(cells_to_doc(&ParagraphProperties::default(), slice).to_plain_text()) } /// Commit the editor — create a new MText or update the edited one. pub(super) fn mtext_commit(&mut self) -> bool { let i = self.active_tab; let Some(ed) = self.mtext_editor.take() else { return false }; if self.command_mtext_input { self.pending_command_editor_text = Some(ed.build_mtext().value); self.command_mtext_input = false; self.refresh_properties(); return true; } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); let annotative = mt.is_annotative || ed.editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, ); if annotative { mt.is_annotative = true; } if body_empty { // Empty content: drop a new entity; leave an edited one untouched. self.refresh_properties(); return false; } if let Some(h) = ed.editing { if self.tabs[i].scene.is_layer_locked(h) { self.refresh_properties(); return false; } self.push_undo_snapshot(i, "MTEXT"); match self.tabs[i].scene.document.get_entity_mut(h) { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; t.rectangle_height = mt.rectangle_height; t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; ml.context.text_height = mt.height; ml.context.line_spacing_factor = mt.line_spacing_factor; if mt.rectangle_width > 0.0 { ml.context.text_width = mt.rectangle_width; } } _ => {} } // Keep the displayed annotation context in sync. if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { crate::scene::annotative::set_entity_annotative( &mut self.tabs[i].scene.document, h, annotative, ); if annotative { if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, h, scale, ); } } } if matches!( self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene .sync_displayed_annotation_context(h); } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() } else { crate::command::WorkingPlane::default() }; let position = plane.to_local(glam::DVec3::new( mt.insertion_point.x, mt.insertion_point.y, mt.insertion_point.z, )); mt.insertion_point = acadrust::types::Vector3::new( position.x, position.y, position.z, ); self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); if annotative { let scale = self.tabs[i].scene.current_annotation_scale_handle(); if let (Some(handle), Some(scale)) = (handle, scale) { crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, handle, scale, ); } } self.tabs[i].dirty = true; } self.refresh_properties(); true } /// Apply the buffer to the drawing but keep the editor open — the /// style-manager model, where **Apply** commits and the dialog stays up /// (closing is the ✕). A brand-new MText is created on the first apply and /// then updated in place on later applies, so repeated Apply never leaves /// duplicate entities behind. pub(super) fn mtext_apply(&mut self) { if self.command_mtext_input { self.rebuild_mtext_preview(); return; } let i = self.active_tab; let (mut mt, editing, body_empty) = match self.mtext_editor.as_ref() { Some(ed) => ( ed.build_mtext(), ed.editing, ed.content.text().trim().is_empty(), ), None => return, }; let annotative = mt.is_annotative || editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, ); if annotative { mt.is_annotative = true; } if body_empty { return; } if let Some(h) = editing { if self.tabs[i].scene.is_layer_locked(h) { return; } self.push_undo_snapshot(i, "MTEXT"); match self.tabs[i].scene.document.get_entity_mut(h) { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; t.rectangle_height = mt.rectangle_height; t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; ml.context.text_height = mt.height; ml.context.line_spacing_factor = mt.line_spacing_factor; if mt.rectangle_width > 0.0 { ml.context.text_width = mt.rectangle_width; } } _ => {} } if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { crate::scene::annotative::set_entity_annotative( &mut self.tabs[i].scene.document, h, annotative, ); if annotative { if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, h, scale, ); } } } if matches!( self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene .sync_displayed_annotation_context(h); } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); self.tabs[i].dirty = true; } else { let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() } else { crate::command::WorkingPlane::default() }; let position = plane.to_local(glam::DVec3::new( mt.insertion_point.x, mt.insertion_point.y, mt.insertion_point.z, )); mt.insertion_point = acadrust::types::Vector3::new( position.x, position.y, position.z, ); self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); self.tabs[i].dirty = true; if annotative { let scale = self.tabs[i].scene.current_annotation_scale_handle(); if let (Some(handle), Some(scale)) = (handle, scale) { crate::scene::annotative::create_annotation_context( &mut self.tabs[i].scene.document, handle, scale, ); } } // Bind the editor to the fresh entity so the next Apply updates it. if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { ed.editing = Some(h); if let Some(EntityType::MText(mtext)) = self.tabs[i].scene.document.get_entity(h) { ed.original = Some(mtext.clone()); } } } self.refresh_properties(); } /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; self.command_mtext_input = false; self.pending_command_editor_text = None; self.reset_modal_geometry(); } } #[cfg(test)] mod cell_tests { use super::*; fn rt(s: &str) -> String { let doc = parse_mtext(s, true); cells_to_doc(&doc_para0(&doc), &doc_to_cells(&doc)).to_mtext_string() } fn paras(s: &str) -> Vec { parse_mtext(s, true) .paragraphs .iter() .map(|p| p.spans.iter().map(|sp| sp.text.clone()).collect()) .collect() } fn cells_of(s: &str) -> Vec { doc_to_cells(&parse_mtext(s, true)) } #[test] fn roundtrip_idempotent() { for s in ["hello", "a b c", "one\\Ptwo", "a\\Pb\\Pc", "trailing\\P", "a b", "\\S1/2;"] { let once = rt(s); let twice = rt(&once); assert_eq!(once, twice, "not idempotent for {s:?}: {once:?} vs {twice:?}"); } } #[test] fn cell_count_matches_plain() { assert_eq!(cells_of("ab\\Pcd").len(), 5); // a b Break c d // parse_mtext drops the trailing empty paragraph; the app restores it in // rebuild() (raw.ends_with("\\P")), so this pure-parse count is 2. assert_eq!(cells_of("ab\\P").len(), 2); } #[test] fn trailing_break_survives_doc_roundtrip() { // A trailing Break must round-trip at the DOC level (the level the ops // work at) even though to_mtext_string+parse would drop it. let cells = vec![ Cell::Char('a', SpanProperties::default()), Cell::Char('b', SpanProperties::default()), Cell::Break(ParagraphProperties::default(), false), ]; let back = doc_to_cells(&cells_to_doc(&ParagraphProperties::default(), &cells)); assert_eq!(back, cells); // Typing after the trailing break lands on the new (empty) paragraph. let mut c2 = cells.clone(); c2.splice(3..3, str_to_cells("x", &SpanProperties::default(), &ParagraphProperties::default())); assert_eq!( paras(&cells_to_doc(&ParagraphProperties::default(), &c2).to_mtext_string()), vec!["ab", "x"] ); } #[test] fn insert_break_splits_paragraph() { let mut cells = cells_of("abcd"); cells.splice(2..2, str_to_cells("\\P", &SpanProperties::default(), &ParagraphProperties::default())); assert_eq!(paras(&cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string()), vec!["ab", "cd"]); } #[test] fn backspace_break_merges_paragraphs() { let mut cells = cells_of("ab\\Pcd"); // [a,b,Break,c,d] let caret = cells_delete_range(&mut cells, 2, 3); // delete the Break slot assert_eq!(caret, 2); assert_eq!(paras(&cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string()), vec!["abcd"]); } #[test] fn delete_range_across_paragraphs() { let mut cells = cells_of("abc\\Pdef"); // [a,b,c,Break,d,e,f] cells_delete_range(&mut cells, 1, 5); // b c Break d assert_eq!(paras(&cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string()), vec!["aef"]); } #[test] fn stacking_is_atomic() { let cells = cells_of("\\S1/2;"); assert_eq!(cells.len(), 3); assert!(matches!(cells[0], Cell::Stack { head: true, .. })); assert!(cells[1].is_stack_cont() && cells[2].is_stack_cont()); let mut c2 = cells.clone(); let start = cells_delete_range(&mut c2, 1, 2); // interior delete widens to whole run assert_eq!(start, 0); assert!(c2.is_empty()); } fn centred_para(text: &str) -> MTextParagraph { let mut p = MTextParagraph::new(); p.properties.alignment = Some(MTextParagraphAlignment::Center); p.spans.push(MTextSpan::plain(text)); p } #[test] fn paragraph_props_survive_edit() { // Two paragraphs, the 2nd centre-aligned; round-trip through the cell // model (as every edit does) must keep the alignment. let mut doc = MTextDocument::new(); doc.paragraphs.clear(); let mut p0 = MTextParagraph::new(); p0.spans.push(MTextSpan::plain("ab")); doc.paragraphs.push(p0); doc.paragraphs.push(centred_para("cd")); let cells = doc_to_cells(&doc); let back = cells_to_doc(&doc_para0(&doc), &cells); assert_eq!( back.paragraphs[1].properties.alignment, Some(MTextParagraphAlignment::Center), "alignment must survive the cell round-trip" ); // A newly inserted break clones the current paragraph's alignment. let mut doc2 = MTextDocument::new(); doc2.paragraphs.clear(); doc2.paragraphs.push(centred_para("abc")); let p0 = doc_para0(&doc2); let mut cells2 = doc_to_cells(&doc2); let pp = para_props_at(&p0, &cells2, 2); cells2.splice(2..2, str_to_cells("\\P", &SpanProperties::default(), &pp)); let out = cells_to_doc(&p0, &cells2); assert_eq!(out.paragraphs.len(), 2); assert_eq!( out.paragraphs[0].properties.alignment, Some(MTextParagraphAlignment::Center) ); assert_eq!( out.paragraphs[1].properties.alignment, Some(MTextParagraphAlignment::Center), "split paragraph inherits alignment" ); } #[test] fn word_range_selection() { let cells = cells_of("hello world"); // h e l l o _ w o r l d assert_eq!(word_range(&cells, 2), (0, 5)); // inside "hello" assert_eq!(word_range(&cells, 8), (6, 11)); // inside "world" assert_eq!(word_range(&cells, 5), (5, 6)); // the space itself assert_eq!(word_range(&cells, 11), (6, 11)); // past end → last word // Double-click does not cross a paragraph break. let c2 = cells_of("ab\\Pcd"); // a b Break c d assert_eq!(word_range(&c2, 0), (0, 2)); // "ab" assert_eq!(word_range(&c2, 2), (2, 3)); // the break alone assert_eq!(word_range(&c2, 3), (3, 5)); // "cd" } // Per-selection colour/font must survive the doc → to_mtext_string → parse // round-trip so the renderer actually sees them. #[test] fn per_run_color_and_font_persist() { use acadrust::entities::mtext_format::{MTextColor, MTextFont}; let mut p = SpanProperties::default(); p.color = Some(MTextColor::Index(1)); // red p.font = Some(MTextFont::with_flags("Arial".to_string(), true, false)); // bold Arial let cells = vec![Cell::Char('X', p)]; let s = cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string(); let back = parse_mtext(&s, true); let sp = &back.paragraphs[0].spans[0]; assert_eq!(sp.properties.color, Some(MTextColor::Index(1))); let f = sp.properties.font.as_ref().unwrap(); assert_eq!(f.name, "Arial"); assert!(f.bold); } // The global toolbar defaults fold onto the text as real span properties // (replacing the old hand-built `\f;\C;…` prefix), and survive serialization. #[test] fn global_defaults_fold_into_spans() { let mut ed = MTextEditorState::new(DVec3::ZERO, "hello world", 2.5, None); ed.font = "Arial".to_string(); ed.color_aci = 1; // red ed.oblique = "15".to_string(); ed.width = "2".to_string(); let doc = parse_mtext(&ed.folded_value(), true); assert_eq!(doc.to_plain_text(), "hello world"); let p = &doc.paragraphs[0].spans[0].properties; assert_eq!(p.font.as_ref().map(|f| f.name.as_str()), Some("Arial")); assert_eq!(p.color, Some(MTextColor::Index(1))); assert_eq!(p.oblique_angle, Some(15.0)); assert_eq!(p.width_factor, Some(2.0)); } #[test] fn trailing_break_detection() { assert!(content_has_trailing_break("{ab\\P}")); // braced (to_mtext_string form) assert!(content_has_trailing_break("ab\\P")); // unbraced assert!(!content_has_trailing_break("{a\\Pb}")); // break in the middle assert!(!content_has_trailing_break("ab")); assert!(!content_has_trailing_break("{x\\\\P}")); // escaped literal backslash + P } #[test] fn enter_at_end_survives_rebuild() { // Type "ab", Enter at end -> cells [a, b, Break]. let cells = vec![ Cell::Char('a', SpanProperties::default()), Cell::Char('b', SpanProperties::default()), Cell::Break(ParagraphProperties::default(), false), ]; let raw = cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string(); // rebuild() re-parses + restores the trailing empty paragraph. let mut d = parse_mtext(&raw, true); if content_has_trailing_break(&raw) { d.paragraphs.push(MTextParagraph::new()); } assert_eq!(doc_to_cells(&d).len(), 3, "trailing break must survive rebuild"); } }