From 8ed10ceb2abe70c49c2bb435ffb7e4bdf66ef474 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 11 Jul 2026 20:18:50 +0300 Subject: [PATCH] feat(mtext): structured editor with selection, copy, and visible bold Rewrite the in-app MTEXT editor to run on acadrust's structured MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell model, serialize via to_mtext_string. Retires the hand-rolled format-code string handling, the parallel visible_spans parser, the composed_value prefix, and the Edit-raw-code tab. Selection and copy: - double-click selects the word, triple-click selects all (click-counting) - Ctrl+A selects all text - Ctrl+C copies the selected text to the clipboard Formatting: - per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment - Bold renders as a visibly thicker SDF pen, keyed (family, char, bold) through the whole text pipeline; the resolved style font name is stamped so the \f...|b1; code survives serialization - per-selection font and color changes - editor color dropdown reuses the Properties panel color selector Global toolbar defaults (font/color/oblique/width/tracking) fold onto the document as real span properties instead of a leading code prefix; a trailing empty paragraph is preserved so Enter at the end keeps its line. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/blocks.rs | 12 + src/app/mod.rs | 18 +- src/app/mtext_editor.rs | 772 ++++++++++++++++++++--------- src/app/update/mod.rs | 55 +- src/app/update/style.rs | 3 + src/app/view/overlay.rs | 132 ++--- src/entities/attribute.rs | 1 + src/entities/multileader.rs | 1 + src/entities/table.rs | 1 + src/entities/text.rs | 1 + src/entities/text_support.rs | 5 + src/entities/tolerance.rs | 1 + src/scene/convert/acad_to_truck.rs | 2 + src/scene/convert/tessellate.rs | 57 ++- src/scene/text/complex_lt.rs | 1 + src/scene/text/glyph_quads.rs | 20 +- src/scene/text/sdf_atlas.rs | 41 +- 17 files changed, 718 insertions(+), 405 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 56718e304..2cfed47ed 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -54,6 +54,18 @@ impl OpenCADStudio { } "COPYCLIP" => { + // MText editor open: Ctrl+C copies its selected text, not the + // drawing's entities. + if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + return match self.mtext_selected_text() { + Some(text) => { + self.command_line + .push_info("Copied selected text to clipboard."); + Some(iced::clipboard::write(text)) + } + None => Some(Task::none()), + }; + } let handles: Vec<_> = self.tabs[i] .scene .selected_entities() diff --git a/src/app/mod.rs b/src/app/mod.rs index 7867a3dfe..2619c158c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -538,6 +538,12 @@ pub(super) struct OpenCADStudio { last_vp_click_time: Option, /// Screen position of the previous viewport left-click release. last_vp_click_pos: Option, + /// MText preview click tracking for double-click (select word) / triple-click + /// (select all): the previous click's time + visible offset, and the run + /// length of quick same-spot clicks (1..=3). + mtext_click_time: Option, + mtext_click_off: usize, + mtext_click_count: u8, /// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1". plot_scale: String, /// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None. @@ -789,6 +795,8 @@ pub enum ColorPickTarget { Ribbon, /// A layer's colour, by panel row index. Layer(usize), + /// The MText editor's selection (or global) colour. + MText, } /// Table records the clipboard entities depend on, snapshotted from the source @@ -1758,8 +1766,11 @@ pub enum Message { MTextWidth(String), /// Toolbar character-spacing field changed. MTextCharSpace(String), - /// Toolbar colour dropdown — global text colour (ACI index, 256 = ByLayer). - MTextColor(u16), + /// Toolbar colour picker (same widget as Properties) — applies to the + /// selection, or the whole text when nothing is selected. + MTextColorChanged(AcadColor), + /// Open / close the MText colour picker popup. + MTextColorPickerToggle, /// Toolbar justification / attachment-point change. MTextJustify(acadrust::entities::mtext::AttachmentPoint), /// Toolbar paragraph-alignment change. @@ -2194,6 +2205,9 @@ impl OpenCADStudio { layout_rename_state: None, last_vp_click_time: None, last_vp_click_pos: None, + mtext_click_time: None, + mtext_click_off: 0, + mtext_click_count: 0, plot_scale: "Fit".to_string(), plot_window: None, plot_format: crate::io::paper_sizes::PaperSize::A4, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index a8eec5cab..eac6b96fc 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -7,8 +7,8 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ - parse_mtext, MTextDocument, MTextParagraph, MTextSpan, SpanProperties, StackingData, - StackingType, + parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, + MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,17 +38,6 @@ pub enum ParaAlign { 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); @@ -123,6 +112,8 @@ pub struct MTextEditorState { /// 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)`. @@ -159,6 +150,7 @@ impl MTextEditorState { 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, @@ -178,39 +170,60 @@ impl MTextEditorState { self.height.trim().parse::().ok().filter(|h| *h > 0.0).unwrap_or(0.25) } - /// Compose the raw editor text with the global leading inline codes - /// (font / colour / oblique / width / char-spacing) the toolbar's - /// dropdowns and value fields set. Per-selection toggles already live - /// inside the text. - pub fn composed_value(&self) -> String { - // No trailing-newline strip: a trailing line break is a real empty - // line the user typed; keeping it lets the layout emit the caret slot - // so the caret shows on the new line right after Enter. - let body = self.content.text(); - let mut prefix = String::new(); - if !self.font.trim().is_empty() { - prefix.push_str(&format!("\\f{};", self.font.trim())); + /// 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; + } + } } - if self.color_aci != 256 { - prefix.push_str(&format!("\\C{};", self.color_aci)); + } + + /// 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()); } - 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}") + self.apply_globals(&mut doc); + doc.to_mtext_string() } /// 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(), + value: self.folded_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, @@ -220,7 +233,6 @@ impl MTextEditorState { ..Default::default() } } - } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -244,8 +256,9 @@ 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 (`\P`). - Break, + /// A paragraph break (`\P`), carrying the paragraph properties (alignment, + /// indents…) of the paragraph it opens, so they survive an edit round-trip. + Break(ParagraphProperties), /// 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. @@ -278,12 +291,14 @@ fn flatten_stack(st: &StackingData) -> String { } /// 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. +/// 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); + cells.push(Cell::Break(para.properties.clone())); } for span in ¶.spans { if let Some(st) = &span.stacking { @@ -306,18 +321,30 @@ fn doc_to_cells(doc: &MTextDocument) -> Vec { 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; a -/// `Stack` run's head cell re-emits the whole stacking span. -fn cells_to_doc(cells: &[Cell]) -> MTextDocument { +/// 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 => { + Cell::Break(props) => { doc.paragraphs .push(std::mem::replace(&mut para, MTextParagraph::new())); + para.properties = props.clone(); } Cell::Char(ch, props) => match para.spans.last_mut() { Some(s) if s.stacking.is_none() && &s.properties == props => s.text.push(*ch), @@ -336,16 +363,29 @@ fn cells_to_doc(cells: &[Cell]) -> MTextDocument { 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 `props`. -fn str_to_cells(s: &str, props: &SpanProperties) -> Vec { +/// 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 { - cells.push(Cell::Break); + cells.push(Cell::Break(para_props.clone())); } for ch in seg.chars() { - cells.push(Cell::Char(ch, props.clone())); + cells.push(Cell::Char(ch, span_props.clone())); } } cells @@ -383,6 +423,48 @@ fn content_has_trailing_break(raw: &str) -> bool { } } +/// 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 { @@ -401,111 +483,11 @@ fn cells_delete_range(cells: &mut Vec, mut a: usize, mut b: usize) -> usiz a } -/// Map each visible character of a raw MText value to its byte span -/// `(start, end)` in that raw string, in the same reading order the layout -/// counts glyph boxes (paragraphs split on `\P`/`\n`/`\N`, leading/trailing -/// spaces trimmed per paragraph, inline codes skipped). Still used by the -/// formatting (`\L`/`\Q`/…) splice path, which stays on the raw string until a -/// later phase. - -pub fn visible_spans(raw: &str) -> Vec<(usize, usize)> { - let mut result: Vec<(usize, usize)> = Vec::new(); - let mut para: Vec<(usize, usize, char)> = Vec::new(); - // No leading/trailing-space trim here: the editor's layout keeps those - // boxes (want_glyph_boxes), so caret offsets must count every space. - let flush = |para: &mut Vec<(usize, usize, char)>, result: &mut Vec<(usize, usize)>| { - for t in para.drain(..) { - result.push((t.0, t.1)); - } - }; - let mut it = raw.char_indices().peekable(); - while let Some((i, ch)) = it.next() { - match ch { - '\\' => match it.peek().map(|&(_, c)| c) { - Some('P') | Some('n') | Some('N') => { - let (j, c) = it.next().unwrap(); - // Paragraph break gets a caret slot (matches the layout's - // line-start box), then the paragraph flushes. - para.push((i, j + c.len_utf8(), '\n')); - flush(&mut para, &mut result); - } - Some('~') => { - let (j, c) = it.next().unwrap(); - para.push((i, j + c.len_utf8(), '\u{00A0}')); - } - Some('\\') | Some('{') | Some('}') => { - let (j, c) = it.next().unwrap(); - para.push((i, j + c.len_utf8(), c)); - } - Some(c) if "LlOoKk".contains(c) => { - it.next(); // value-less toggle, no visible glyph - } - Some(_) => { - // Value code (\f… \C… \H… \pxq… \U… etc) — skip to ';'. - it.next(); - while let Some(&(_, c)) = it.peek() { - it.next(); - if c == ';' { - break; - } - } - } - None => {} - }, - '{' | '}' => { /* group markers — not visible */ } - '\n' | '\r' => { - // Raw line break = paragraph break with a caret slot. - para.push((i, i + ch.len_utf8(), '\n')); - flush(&mut para, &mut result); - } - '%' if it.peek().map(|&(_, c)| c) == Some('%') => { - it.next(); // second '%' - match it.peek().copied() { - Some((k, '%')) => { - it.next(); - para.push((i, k + 1, '%')); - } - Some((_, d)) if d.is_ascii_digit() => { - let mut last = i; - let mut n = 0; - while n < 3 { - match it.peek().copied() { - Some((m, c)) if c.is_ascii_digit() => { - last = m; - it.next(); - n += 1; - } - _ => break, - } - } - para.push((i, last + 1, '\u{25A1}')); - } - Some((m, c)) => { - it.next(); - let g = match c { - 'd' | 'D' => '°', - 'c' | 'C' => 'Ø', - 'p' | 'P' => '±', - other => other, - }; - para.push((i, m + c.len_utf8(), g)); - } - None => para.push((i, i + 1, '%')), - } - } - _ => para.push((i, i + ch.len_utf8(), ch)), - } - } - flush(&mut para, &mut result); - result -} // ── App-side editor driver ────────────────────────────────────────────────── use crate::scene::convert::tessellate; use crate::scene::model::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. @@ -626,86 +608,156 @@ impl super::OpenCADStudio { /// 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. - fn mtext_splice_sel(&mut self, prefix: &str, suffix: &str, case: Option) -> bool { - let Some(ed) = self.mtext_editor.as_mut() else { return false }; - let Some((a, b)) = ed.sel else { return false }; - if a >= b { - return false; - } - let raw = ed.content.text(); - let spans = visible_spans(&raw); - if a >= spans.len() || b > spans.len() { - return false; - } - let start = spans[a].0; - let end = spans[b - 1].1; - let mut s = raw; - if let Some(upper) = case { - let slice = &s[start..end]; - let repl = if upper { slice.to_uppercase() } else { slice.to_lowercase() }; - s.replace_range(start..end, &repl); - // Length may change; recompute end for the suffix insert. - let new_end = start + repl.len(); - s.insert_str(new_end, suffix); - s.insert_str(start, prefix); - } else { - s.insert_str(end, suffix); - s.insert_str(start, prefix); - } - ed.content = iced::widget::text_editor::Content::with_text(&s); - ed.sel = None; - true - } - - /// Apply a character-format toggle to the preview selection (preferred) or - /// the Edit-box selection. The stroke-font renderer has no true bold / - /// italic, so Bold switches the run to the heavier Gothic stroke font and - /// Italic applies an oblique slant — both produce a visible effect. + /// 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) { - // Font to restore to after a Bold run (the current global font). - let restore = self - .mtext_editor - .as_ref() - .map(|e| { - if e.font.trim().is_empty() { - "Standard".to_string() - } else { - e.font.clone() - } + // 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, }) - .unwrap_or_else(|| "Standard".to_string()); - let (pre, suf, case): (String, String, Option) = match kind { - MTextFmt::Bold => ("\\fGothic;".into(), format!("\\f{restore};"), None), - MTextFmt::Italic => ("\\Q15;".into(), "\\Q0;".into(), None), - MTextFmt::Underline => ("\\L".into(), "\\l".into(), None), - MTextFmt::Overline => ("\\O".into(), "\\o".into(), None), - MTextFmt::Strike => ("\\K".into(), "\\k".into(), None), - MTextFmt::Uppercase => (String::new(), String::new(), Some(true)), - MTextFmt::Lowercase => (String::new(), String::new(), Some(false)), - }; - if !self.mtext_splice_sel(&pre, &suf, case) { - if let Some(ed) = self.mtext_editor.as_mut() { - let sel = ed.content.selection().unwrap_or_default(); - let text = match case { - Some(true) => sel.to_uppercase(), - Some(false) => sel.to_lowercase(), - None => format!("{pre}{sel}{suf}"), - }; - ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + } + // 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; + } + 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 => { + let on = !all_have(&cells[a..b], |p| p.oblique_angle == Some(15.0)); + each(&mut cells[a..b], |p| p.oblique_angle = on.then_some(15.0)); + } + 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(); } - /// Prefix the selection (or cursor) with a paragraph-alignment code. + /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { - let code = format!("\\pxq{};", align.code()); - if !self.mtext_splice_sel(&code, "", None) { - if let Some(ed) = self.mtext_editor.as_mut() { - let sel = ed.content.selection().unwrap_or_default(); - let text = format!("{code}{sel}"); - ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text)))); + if let Some(ed) = self.mtext_editor.as_mut() { + 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(); } @@ -732,6 +784,7 @@ impl super::OpenCADStudio { if let Some(ed) = self.mtext_editor.as_mut() { // 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 { @@ -740,12 +793,13 @@ impl super::OpenCADStudio { }; caret = clamp_insert(&cells, caret); let props = insert_props(&cells, caret); - let ins = str_to_cells(s, &props); + 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(&cells).to_mtext_string()); + text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.caret_blink_on = true; @@ -756,6 +810,7 @@ impl super::OpenCADStudio { /// 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() { + let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); let caret = match ed.sel { @@ -766,7 +821,7 @@ impl super::OpenCADStudio { _ => ed.caret.min(count), }; ed.content = - text_editor::Content::with_text(&cells_to_doc(&cells).to_mtext_string()); + text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.caret_blink_on = true; @@ -777,6 +832,7 @@ impl super::OpenCADStudio { /// 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() { + let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); let caret = match ed.sel { @@ -787,7 +843,7 @@ impl super::OpenCADStudio { _ => ed.caret.min(count), }; ed.content = - text_editor::Content::with_text(&cells_to_doc(&cells).to_mtext_string()); + text_editor::Content::with_text(&cells_to_doc(¶0, &cells).to_mtext_string()); ed.caret = caret; ed.sel = Some((caret, caret)); ed.caret_blink_on = true; @@ -814,6 +870,148 @@ impl super::OpenCADStudio { .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 { + 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 { + 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; @@ -869,7 +1067,8 @@ mod cell_tests { use super::*; fn rt(s: &str) -> String { - cells_to_doc(&doc_to_cells(&parse_mtext(s, true))).to_mtext_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) @@ -906,21 +1105,24 @@ mod cell_tests { let cells = vec![ Cell::Char('a', SpanProperties::default()), Cell::Char('b', SpanProperties::default()), - Cell::Break, + Cell::Break(ParagraphProperties::default()), ]; - let back = doc_to_cells(&cells_to_doc(&cells)); + 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())); - assert_eq!(paras(&cells_to_doc(&c2).to_mtext_string()), vec!["ab", "x"]); + 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())); - assert_eq!(paras(&cells_to_doc(&cells).to_mtext_string()), vec!["ab", "cd"]); + 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] @@ -928,14 +1130,14 @@ mod cell_tests { 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(&cells).to_mtext_string()), vec!["abcd"]); + 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(&cells).to_mtext_string()), vec!["aef"]); + assert_eq!(paras(&cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string()), vec!["aef"]); } #[test] @@ -950,6 +1152,102 @@ mod cell_tests { 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(Vec3::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) @@ -965,9 +1263,9 @@ mod cell_tests { let cells = vec![ Cell::Char('a', SpanProperties::default()), Cell::Char('b', SpanProperties::default()), - Cell::Break, + Cell::Break(ParagraphProperties::default()), ]; - let raw = cells_to_doc(&cells).to_mtext_string(); + 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) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 294589a51..f6b08ac4a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1865,11 +1865,14 @@ impl OpenCADStudio { self.rebuild_mtext_preview(); Task::none() } - Message::MTextColor(aci) => { + Message::MTextColorChanged(color) => { + self.mtext_apply_color(color); + Task::none() + } + Message::MTextColorPickerToggle => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.color_aci = aci; + ed.color_picker_open = !ed.color_picker_open; } - self.rebuild_mtext_preview(); Task::none() } Message::MTextStyle(s) => { @@ -1880,14 +1883,7 @@ impl OpenCADStudio { 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(); + self.mtext_apply_font(&f); Task::none() } Message::MTextOblique(s) => { @@ -1945,11 +1941,32 @@ impl OpenCADStudio { } } Message::MTextSelStart(off) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.sel_anchor = off; - ed.sel = Some((off, off)); - ed.caret = off; - ed.caret_blink_on = true; + // Count quick same-spot clicks: 1 = place caret, 2 = select the + // word, 3 = select all. + let now = Instant::now(); + let count = match self.mtext_click_time { + Some(t) + if now.duration_since(t).as_millis() < 400 + && off.abs_diff(self.mtext_click_off) <= 1 => + { + (self.mtext_click_count + 1).min(3) + } + _ => 1, + }; + self.mtext_click_time = Some(now); + self.mtext_click_off = off; + self.mtext_click_count = count; + match count { + 2 => self.mtext_select_word(off), + 3 => self.mtext_select_all(), + _ => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.sel_anchor = off; + ed.sel = Some((off, off)); + ed.caret = off; + ed.caret_blink_on = true; + } + } } Task::none() } @@ -1997,7 +2014,11 @@ impl OpenCADStudio { Message::SelectAllShortcut => { let i = self.active_tab; - if self.active_modal == Some(super::ModalKind::Layers) { + if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { + // Ctrl+A in the MText editor selects all of its text. + self.mtext_select_all(); + Task::none() + } else if self.active_modal == Some(super::ModalKind::Layers) { // Select every row in the Layer Manager (#236). let n = self.tabs[i].layers.layers.len(); self.tabs[i].layers.selected_multi = (0..n).collect(); diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 050b0b898..1af8519ec 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -969,6 +969,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Some(crate::app::ColorPickTarget::Properties) => { Some(Message::PropColorChanged(color)) } + Some(crate::app::ColorPickTarget::MText) => { + Some(Message::MTextColorChanged(color)) + } Some(crate::app::ColorPickTarget::Ribbon) => { Some(Message::RibbonColorChanged(color)) } diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 8f4de0ada..0e675330d 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -95,17 +95,6 @@ const MTEXT_FONTS: [&str; 10] = [ "RomanC", ]; /// (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. @@ -113,7 +102,7 @@ const MTEXT_PREVIEW_PAD: f32 = 12.0; struct MTextPreview { /// Disconnected polylines as (x, y) world points + colour (NaN-split done). - segments: Vec<(Vec<(f32, f32)>, Color)>, + segments: Vec<(Vec<(f32, f32)>, Color, f32)>, /// Per-visible-character boxes (world frame) for click-to-select. boxes: Vec, /// Current selection as a visible-char range. @@ -253,7 +242,7 @@ impl iced::widget::canvas::Program for MTextPreview { } } } - for (seg, col) in &self.segments { + for (seg, col, width) in &self.segments { if seg.len() < 2 { continue; } @@ -263,7 +252,7 @@ impl iced::widget::canvas::Program for MTextPreview { p.line_to(map(x, y)); } }); - frame.stroke(&path, Stroke::default().with_color(*col).with_width(1.4)); + frame.stroke(&path, Stroke::default().with_color(*col).with_width(*width)); } // Caret — a vertical bar at the caret's glyph boundary, shown when the // selection is empty (a plain text cursor). @@ -326,11 +315,12 @@ impl iced::widget::canvas::Program for MTextPreview { } /// Split every preview WireModel into finite (x, y) polyline runs, each -/// carrying its wire's colour so inline `\C` / the colour dropdown shows. +/// carrying its wire's colour (so inline `\C` / the colour dropdown shows) and a +/// stroke width (bold runs carry a wider pen via `line_weight_px`). fn mtext_preview_segments( ed: &super::super::mtext_editor::MTextEditorState, -) -> Vec<(Vec<(f32, f32)>, Color)> { - let mut out: Vec<(Vec<(f32, f32)>, Color)> = Vec::new(); +) -> Vec<(Vec<(f32, f32)>, Color, f32)> { + let mut out: Vec<(Vec<(f32, f32)>, Color, f32)> = Vec::new(); for w in &ed.preview_wires { let col = Color { r: w.color[0], @@ -338,16 +328,18 @@ fn mtext_preview_segments( b: w.color[2], a: 1.0, }; + // Bold text wires bake a wider pen (line_weight_px ~2.4); draw them thick. + let width = if w.line_weight_px > 1.5 { 2.6 } else { 1.4 }; 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), col)); + out.push((std::mem::take(&mut run), col, width)); } } if !run.is_empty() { - out.push((run, col)); + out.push((run, col, width)); } } out @@ -359,7 +351,7 @@ pub(super) fn mtext_editor_overlay<'a>( canvas_size: (f32, f32), ) -> Element<'a, Message> { use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign}; - use iced::widget::{canvas, svg, text_editor}; + use iced::widget::{canvas, svg}; const PANEL_BG: Color = Color { r: 0.16, @@ -455,28 +447,20 @@ pub(super) fn mtext_editor_overlay<'a>( ) .text_size(11) .width(iced::Length::Fixed(120.0)); - let color_sel = MTEXT_COLORS - .iter() - .find(|(_, a)| *a == ed.color_aci) - .map(|(n, _)| n.to_string()) - .unwrap_or_else(|| "ByLayer".to_string()); - let color_pl = pick_list( - MTEXT_COLORS - .iter() - .map(|(n, _)| n.to_string()) - .collect::>(), - Some(color_sel), - |name: String| { - let aci = MTEXT_COLORS - .iter() - .find(|(n, _)| *n == name) - .map(|(_, a)| *a) - .unwrap_or(256); - Message::MTextColor(aci) + // Same colour picker as the Properties panel (named swatches + "More…" full + // palette), applied to the selection or the whole text. + let color_pl = iced::widget::container(crate::ui::color_select::color_selector( + acadrust::types::Color::from_index(ed.color_aci as i16), + ed.color_picker_open, + crate::ui::color_select::ColorExtras { + by_layer: true, + by_block: false, }, - ) - .text_size(11) - .width(iced::Length::Fixed(96.0)); + Message::MTextColorChanged, + Message::MTextColorPickerToggle, + Message::OpenColorWindow(crate::app::ColorPickTarget::MText), + )) + .width(iced::Length::Fixed(150.0)); let row1 = row![ style_pl, @@ -578,62 +562,12 @@ pub(super) fn mtext_editor_overlay<'a>( .align_y(iced::Alignment::Center); // ── Segmented Edit | Preview toggle (between toolbar and body) ──────── - let seg_btn = - move |label: &'static str, active: bool, on: Message| -> Element<'static, Message> { - button(text(label).size(12).color(if active { - Color::WHITE - } else { - Color { - r: 0.80, - g: 0.80, - b: 0.80, - a: 1.0, - } - })) - .on_press(on) - .padding([4, 14]) - .style(move |_: &Theme, _| button::Style { - background: Some(Background::Color(if active { - Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - } - } else { - Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, - } - })), - text_color: TEXT_COL, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - }) - .into() - }; - let toggle = container( - row![ - seg_btn("Edit", !ed.show_preview, Message::MTextShowPreview(false)), - seg_btn("Preview", ed.show_preview, Message::MTextShowPreview(true)), - ] - .spacing(0), - ) - .padding([6, 0]); - - // ── Body: toggles between raw code input and rendered preview ──────── + // ── Body: the rendered preview (the editor is preview-only) ───────── const VIEW_H: f32 = 150.0; - let body: Element<'a, Message> = if ed.show_preview { + let body: Element<'a, Message> = { let segments = mtext_preview_segments(ed); let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); - for (seg, _) in &segments { + for (seg, _, _) in &segments { for &(x, y) in seg { minx = minx.min(x); miny = miny.min(y); @@ -685,17 +619,9 @@ pub(super) fn mtext_editor_overlay<'a>( .padding(2) .width(Fill) .into() - } else { - text_editor(&ed.content) - .id(iced::widget::Id::new(MTEXT_TEXT_ID)) - .on_action(Message::MTextEdit) - .height(iced::Length::Fixed(VIEW_H)) - .padding(6) - .size(13) - .into() }; - let panel = container(column![row1, row2, toggle, body].spacing(5)) + let panel = container(column![row1, row2, body].spacing(5)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 5c0105144..a5862ff57 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -306,6 +306,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument) width_factor, oblique: oblique_angle, tracking: 1.0, + bold: false, }), }); } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index 826500c9a..65be9a446 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -1529,6 +1529,7 @@ impl MultiLeaderTess for MultiLeader { run.oblique, run.tracking, &run.font, + run.bold, &run.text, ); crate::scene::pipeline::text_gpu::push_glyph_vertices( diff --git a/src/entities/table.rs b/src/entities/table.rs index 1085bb267..63856eb66 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -724,6 +724,7 @@ pub fn tessellate_table( run.oblique, run.tracking, &run.font, + run.bold, &run.text, ); crate::scene::pipeline::text_gpu::push_glyph_vertices( diff --git a/src/entities/text.rs b/src/entities/text.rs index 492bf6b76..41daf433f 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -123,6 +123,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { width_factor: p.width_factor, oblique: p.oblique_angle, tracking: 1.0, + bold: false, }), }]), snap_pts: vec![(snap_pt, SnapHint::Insertion)], diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index e5127bcda..9e4d81650 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -275,6 +275,8 @@ pub struct RunState { pub underline: bool, pub overline: bool, pub strike: bool, + /// Bold run — rendered with a wider SDF pen (thicker strokes). + pub bold: bool, } impl Default for RunState { @@ -290,6 +292,7 @@ impl Default for RunState { underline: false, overline: false, strike: false, + bold: false, } } } @@ -435,6 +438,7 @@ pub fn adapt_mtext_paragraphs( _ => 0, }, font: p.font.as_ref().map(|f| font_stem(&f.name)), + bold: p.font.as_ref().map(|f| f.bold).unwrap_or(false), color: color_of(p), underline: p.stroke.underline(), overline: p.stroke.overline(), @@ -1108,6 +1112,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { width_factor: signed_wf, oblique, tracking, + bold: atom.state.bold, }), }); if opts.want_glyph_boxes { diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 2f78fd0bc..2b9a7caf0 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -289,6 +289,7 @@ impl TruckConvertible for Tolerance { width_factor: 1.0, oblique: 0.0, tracking: 0.0, + bold: false, }), }); } diff --git a/src/scene/convert/acad_to_truck.rs b/src/scene/convert/acad_to_truck.rs index 52516b9f9..451d24bda 100644 --- a/src/scene/convert/acad_to_truck.rs +++ b/src/scene/convert/acad_to_truck.rs @@ -38,6 +38,8 @@ pub struct GlyphRun { pub width_factor: f32, pub oblique: f32, pub tracking: f32, + /// Bold run — the SDF glyph bakes with a wider pen (thicker strokes). + pub bold: bool, } #[allow(dead_code)] diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 52d7f5c56..2111eb4bf 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -179,9 +179,11 @@ pub fn tessellate( // Selection forces a single uniform colour — never split. let split_by_color = !selected; - // Bins: key = Some(rgb) + // Bins: key = (Some(rgb), bold). Bold strokes bin separately so + // the editor preview can draw them with a wider pen. struct TextBin { color: Option<[f32; 3]>, + bold: bool, pts: Vec<[f32; 3]>, pts_low: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, @@ -189,22 +191,26 @@ pub fn tessellate( } let mut bins: Vec = Vec::new(); let mut bin_first: Vec = Vec::new(); - let find_or_make = - |key: Option<[f32; 3]>, bins: &mut Vec, firsts: &mut Vec| -> usize { - if let Some(i) = bins.iter().position(|b| b.color == key) { - i - } else { - bins.push(TextBin { - color: key, - pts: Vec::new(), - pts_low: Vec::new(), - fill_tris: Vec::new(), - fill_tris_low: Vec::new(), - }); - firsts.push(true); - bins.len() - 1 - } - }; + let find_or_make = |key: Option<[f32; 3]>, + bold: bool, + bins: &mut Vec, + firsts: &mut Vec| + -> usize { + if let Some(i) = bins.iter().position(|b| b.color == key && b.bold == bold) { + i + } else { + bins.push(TextBin { + color: key, + bold, + pts: Vec::new(), + pts_low: Vec::new(), + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + }); + firsts.push(true); + bins.len() - 1 + } + }; let anno = anno_scale as f64; // SDF text is decided PER GROUP by whether the group carries a @@ -224,7 +230,8 @@ pub fn tessellate( let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v; let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_v; let bin_key = if split_by_color { group.color } else { None }; - let bi = find_or_make(bin_key, &mut bins, &mut bin_first); + let group_bold = group.run.as_ref().is_some_and(|r| r.bold); + let bi = find_or_make(bin_key, group_bold, &mut bins, &mut bin_first); // 1. Process outline strokes for stroke in &group.strokes { @@ -287,6 +294,7 @@ pub fn tessellate( run.oblique, run.tracking, &run.font, + run.bold, &run.text, ); crate::scene::pipeline::text_gpu::push_glyph_vertices( @@ -499,7 +507,16 @@ pub fn tessellate( Some([r, g, b]) => [r, g, b, color[3]], None => color, }; - + // Bold text strokes carry a wider pen so the editor preview + // draws them thicker (these stroke wires exist only when + // strokes are forced, i.e. the preview; the main render uses + // SDF where bold is a wider baked pen). + let bin_lw = if bin.bold { + line_weight_px.max(1.0) * 2.4 + } else { + line_weight_px + }; + if !bin.pts.is_empty() { let (snap, keys, tangents) = if is_first { is_first = false; @@ -520,7 +537,7 @@ pub fn tessellate( selected, pattern_length: 0.0, pattern: [0.0; 8], - line_weight_px, + line_weight_px: bin_lw, snap_pts: snap, tangent_geoms: tangents, aci: 0, diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index d07c81cae..c0c1c7ef5 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -189,6 +189,7 @@ pub fn apply_along( 0.0, 0.0, style, + false, &resolved, ); crate::scene::pipeline::text_gpu::push_glyph_vertices( diff --git a/src/scene/text/glyph_quads.rs b/src/scene/text/glyph_quads.rs index acfdf1dff..2256a5da1 100644 --- a/src/scene/text/glyph_quads.rs +++ b/src/scene/text/glyph_quads.rs @@ -43,6 +43,7 @@ pub fn layout_glyph_quads( oblique_angle: f32, tracking: f32, font_name: &str, + bold: bool, text: &str, ) -> Vec { if text.is_empty() || height <= 0.0 { @@ -129,7 +130,7 @@ pub fn layout_glyph_quads( continue; } }; - match atlas.get_or_insert(font_name, ch) { + match atlas.get_or_insert(font_name, ch, bold) { Some(e) => { let (lo, hi) = (e.plane_min, e.plane_max); quads.push(GlyphQuad { @@ -195,7 +196,7 @@ mod tests { #[test] fn glyphs_advance_rightward() { let mut atlas = GlyphAtlas::new(512, 512); - let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); + let quads = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); assert_eq!(quads.len(), 2, "two inked glyphs -> two quads"); assert!( center(&quads[1])[0] > center(&quads[0])[0], @@ -213,8 +214,8 @@ mod tests { #[test] fn space_emits_no_quad_but_advances() { let mut atlas = GlyphAtlas::new(512, 512); - let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); - let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A A"); + let ab = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); + let a_sp_a = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A A"); assert_eq!(a_sp_a.len(), 2, "space produces no quad"); assert!( center(&a_sp_a[1])[0] > center(&ab[1])[0], @@ -228,7 +229,7 @@ mod tests { // Use the baseline advance direction (glyph0 -> glyph1), which is +x when // flat and swings to +y after a 90° turn — independent of where a single // glyph's box centre sits within the cap height. - let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AA"); + let flat = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AA"); let turned = layout_glyph_quads( &mut atlas, 10.0, @@ -237,6 +238,7 @@ mod tests { 0.0, 1.0, "txt", + false, "AA", ); let adv = |qs: &[GlyphQuad]| { @@ -252,8 +254,8 @@ mod tests { #[test] fn taller_text_makes_bigger_quads() { let mut atlas = GlyphAtlas::new(512, 512); - let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "A"); - let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", "A"); + let small = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); + let big = layout_glyph_quads(&mut atlas, 20.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "A"); let extent = |q: &GlyphQuad| { let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]); for p in &q.corners { @@ -274,9 +276,9 @@ mod tests { #[test] fn underline_emits_an_extra_solid_quad() { let mut atlas = GlyphAtlas::new(512, 512); - let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "AB"); + let plain = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "AB"); // `\L…\l` underlines the run — same glyph quads plus one decoration quad. - let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", "\\LAB\\l"); + let under = layout_glyph_quads(&mut atlas, 10.0, 0.0, 1.0, 0.0, 1.0, "txt", false, "\\LAB\\l"); assert_eq!(under.len(), plain.len() + 1, "underline adds one quad"); // The decoration quad samples the atlas's solid texel (degenerate UV). let deco = under.last().unwrap(); diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index aabec4d8c..4d2922b9c 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -111,9 +111,10 @@ pub struct GlyphAtlas { height: u32, /// R8 SDF texels, row-major, row 0 = top. data: Vec, - /// Cache keyed by (font family, char). `None` = a whitespace/empty glyph - /// with no ink (nothing to draw), cached so we do not re-resolve it. - entries: HashMap<(String, char), Option>, + /// Cache keyed by (font family, char, bold). `None` = a whitespace/empty + /// glyph with no ink (nothing to draw), cached so we do not re-resolve it. + /// Bold bakes the same glyph with a wider pen (a separate atlas entry). + entries: HashMap<(String, char, bool), Option>, // Shelf packer cursor. cursor_x: u32, cursor_y: u32, @@ -201,15 +202,21 @@ impl GlyphAtlas { /// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the /// atlas is full (Phase 1 is single-page; callers should treat `None` as /// "skip / fall back"). - pub fn get_or_insert(&mut self, family: &str, ch: char) -> Option { - let key = (family.to_string(), ch); + pub fn get_or_insert(&mut self, family: &str, ch: char, bold: bool) -> Option { + let key = (family.to_string(), ch, bold); if let Some(cached) = self.entries.get(&key) { return *cached; } + // Bold stroke glyphs bake with a wider pen band (same shapes, thicker). + let pen_half = if bold { + PEN_HALF_UNITS * 1.7 + } else { + PEN_HALF_UNITS + }; let face = Face::resolve(family); let entry = face .glyph(ch) - .and_then(|g| bake_glyph(&g)) + .and_then(|g| bake_glyph(&g, pen_half)) .and_then(|tile| self.pack(tile)); self.entries.insert(key, entry); entry @@ -252,7 +259,7 @@ impl GlyphAtlas { // ── Baking ───────────────────────────────────────────────────────────────── /// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink. -fn bake_glyph(g: &Glyph) -> Option { +fn bake_glyph(g: &Glyph, pen_half: f32) -> Option { // TEXTFILL 0 renders TrueType glyphs hollow: bake them as a stroke field // (outline) instead of a signed fill field. Stroke fonts (no fill tris) are // unaffected. @@ -304,8 +311,8 @@ fn bake_glyph(g: &Glyph) -> Option { -d } } else { - // Band around the pen path. - PEN_HALF_UNITS - d + // Band around the pen path (wider for bold). + pen_half - d }; let t = (0.5 + sd / (2.0 * SPREAD_UNITS)).clamp(0.0, 1.0); data[(py * w + px) as usize] = (t * 255.0).round() as u8; @@ -423,7 +430,7 @@ mod tests { #[test] fn filled_glyph_inside_is_high_outside_is_low() { - let tile = bake_glyph(&filled_square()).expect("square bakes"); + let tile = bake_glyph(&filled_square(), PEN_HALF_UNITS).expect("square bakes"); // Deep inside the fill -> well above the 0.5 (128) edge. assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside"); // Well outside the fill -> below the edge. @@ -435,7 +442,7 @@ mod tests { #[test] fn stroke_glyph_on_line_is_high_off_line_is_low() { - let tile = bake_glyph(&diagonal_stroke()).expect("stroke bakes"); + let tile = bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).expect("stroke bakes"); // On the diagonal -> inside the pen band. assert!(sample(&tile, 2.0, 2.0) > 128, "on-stroke should be inside band"); // Off the diagonal by more than the pen half-width -> outside. @@ -452,14 +459,14 @@ mod tests { advance: 4.5, fill_tris: vec![], }; - assert!(bake_glyph(&space).is_none()); + assert!(bake_glyph(&space, PEN_HALF_UNITS).is_none()); } #[test] fn packing_places_tiles_without_overlap() { let mut atlas = GlyphAtlas::new(256, 256); - let a = atlas.pack(bake_glyph(&filled_square()).unwrap()).unwrap(); - let b = atlas.pack(bake_glyph(&diagonal_stroke()).unwrap()).unwrap(); + let a = atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).unwrap(); + let b = atlas.pack(bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).unwrap()).unwrap(); assert!(atlas.is_dirty()); // UVs are within the atlas. for e in [&a, &b] { @@ -476,7 +483,7 @@ mod tests { fn full_atlas_returns_none() { // A tiny atlas cannot fit even one padded tile. let mut atlas = GlyphAtlas::new(4, 4); - assert!(atlas.pack(bake_glyph(&filled_square()).unwrap()).is_none()); + assert!(atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).is_none()); } // Full path over a real, embedded stroke font (no system fonts needed): @@ -484,7 +491,7 @@ mod tests { #[test] fn real_lff_glyph_bakes_and_packs() { let mut atlas = GlyphAtlas::new(512, 512); - let e = atlas.get_or_insert("txt", 'A').expect("LFF 'A' bakes"); + let e = atlas.get_or_insert("txt", 'A', false).expect("LFF 'A' bakes"); assert!(atlas.is_dirty()); assert!( e.plane_max[0] > e.plane_min[0] && e.plane_max[1] > e.plane_min[1], @@ -499,7 +506,7 @@ mod tests { ); assert!(e.advance > 0.0); // Second lookup is cached: identical placement, no re-pack. - let e2 = atlas.get_or_insert("txt", 'A').expect("cached"); + let e2 = atlas.get_or_insert("txt", 'A', false).expect("cached"); assert_eq!(e.uv_min, e2.uv_min); assert_eq!(e.uv_max, e2.uv_max); }