forked from 1b-agent/opencadstudio-upstream
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 <[email protected]>
This commit is contained in:
1 parent
4bc196affd
commit
8ed10ceb2a
17 files changed
+718
-405
No files matched your search
@@ -54,6 +54,18 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
"COPYCLIP" => {
|
"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]
|
let handles: Vec<_> = self.tabs[i]
|
||||||
.scene
|
.scene
|
||||||
.selected_entities()
|
.selected_entities()
|
||||||
|
|||||||
+16
-2
@@ -538,6 +538,12 @@ pub(super) struct OpenCADStudio {
|
|||||||
last_vp_click_time: Option<Instant>,
|
last_vp_click_time: Option<Instant>,
|
||||||
/// Screen position of the previous viewport left-click release.
|
/// Screen position of the previous viewport left-click release.
|
||||||
last_vp_click_pos: Option<Point>,
|
last_vp_click_pos: Option<Point>,
|
||||||
|
/// 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<Instant>,
|
||||||
|
mtext_click_off: usize,
|
||||||
|
mtext_click_count: u8,
|
||||||
/// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1".
|
/// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1".
|
||||||
plot_scale: String,
|
plot_scale: String,
|
||||||
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
|
/// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None.
|
||||||
@@ -789,6 +795,8 @@ pub enum ColorPickTarget {
|
|||||||
Ribbon,
|
Ribbon,
|
||||||
/// A layer's colour, by panel row index.
|
/// A layer's colour, by panel row index.
|
||||||
Layer(usize),
|
Layer(usize),
|
||||||
|
/// The MText editor's selection (or global) colour.
|
||||||
|
MText,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Table records the clipboard entities depend on, snapshotted from the source
|
/// Table records the clipboard entities depend on, snapshotted from the source
|
||||||
@@ -1758,8 +1766,11 @@ pub enum Message {
|
|||||||
MTextWidth(String),
|
MTextWidth(String),
|
||||||
/// Toolbar character-spacing field changed.
|
/// Toolbar character-spacing field changed.
|
||||||
MTextCharSpace(String),
|
MTextCharSpace(String),
|
||||||
/// Toolbar colour dropdown — global text colour (ACI index, 256 = ByLayer).
|
/// Toolbar colour picker (same widget as Properties) — applies to the
|
||||||
MTextColor(u16),
|
/// selection, or the whole text when nothing is selected.
|
||||||
|
MTextColorChanged(AcadColor),
|
||||||
|
/// Open / close the MText colour picker popup.
|
||||||
|
MTextColorPickerToggle,
|
||||||
/// Toolbar justification / attachment-point change.
|
/// Toolbar justification / attachment-point change.
|
||||||
MTextJustify(acadrust::entities::mtext::AttachmentPoint),
|
MTextJustify(acadrust::entities::mtext::AttachmentPoint),
|
||||||
/// Toolbar paragraph-alignment change.
|
/// Toolbar paragraph-alignment change.
|
||||||
@@ -2194,6 +2205,9 @@ impl OpenCADStudio {
|
|||||||
layout_rename_state: None,
|
layout_rename_state: None,
|
||||||
last_vp_click_time: None,
|
last_vp_click_time: None,
|
||||||
last_vp_click_pos: None,
|
last_vp_click_pos: None,
|
||||||
|
mtext_click_time: None,
|
||||||
|
mtext_click_off: 0,
|
||||||
|
mtext_click_count: 0,
|
||||||
plot_scale: "Fit".to_string(),
|
plot_scale: "Fit".to_string(),
|
||||||
plot_window: None,
|
plot_window: None,
|
||||||
plot_format: crate::io::paper_sizes::PaperSize::A4,
|
plot_format: crate::io::paper_sizes::PaperSize::A4,
|
||||||
|
|||||||
+535
-237
@@ -7,8 +7,8 @@
|
|||||||
|
|
||||||
use acadrust::entities::mtext::AttachmentPoint;
|
use acadrust::entities::mtext::AttachmentPoint;
|
||||||
use acadrust::entities::mtext_format::{
|
use acadrust::entities::mtext_format::{
|
||||||
parse_mtext, MTextDocument, MTextParagraph, MTextSpan, SpanProperties, StackingData,
|
parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment,
|
||||||
StackingType,
|
MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType,
|
||||||
};
|
};
|
||||||
use acadrust::types::Vector3;
|
use acadrust::types::Vector3;
|
||||||
use acadrust::{EntityType, Handle, MText};
|
use acadrust::{EntityType, Handle, MText};
|
||||||
@@ -38,17 +38,6 @@ pub enum ParaAlign {
|
|||||||
Justify,
|
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.
|
/// `pick_list`-friendly wrapper for the 9 attachment points.
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct JustifyChoice(pub AttachmentPoint);
|
pub struct JustifyChoice(pub AttachmentPoint);
|
||||||
@@ -123,6 +112,8 @@ pub struct MTextEditorState {
|
|||||||
/// When true the panel shows the rendered preview; when false the raw
|
/// When true the panel shows the rendered preview; when false the raw
|
||||||
/// code/text input. Toggled so the two never stack.
|
/// code/text input. Toggled so the two never stack.
|
||||||
pub show_preview: bool,
|
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
|
/// Per-visible-character boxes (world XY, world_offset already removed) for
|
||||||
/// click-to-select in the preview, and the current selection as a visible-
|
/// click-to-select in the preview, and the current selection as a visible-
|
||||||
/// character range `[start, end)`.
|
/// character range `[start, end)`.
|
||||||
@@ -159,6 +150,7 @@ impl MTextEditorState {
|
|||||||
char_space: "0".to_string(),
|
char_space: "0".to_string(),
|
||||||
preview_wires: Vec::new(),
|
preview_wires: Vec::new(),
|
||||||
show_preview: true,
|
show_preview: true,
|
||||||
|
color_picker_open: false,
|
||||||
glyph_boxes: Vec::new(),
|
glyph_boxes: Vec::new(),
|
||||||
sel: None,
|
sel: None,
|
||||||
sel_anchor: 0,
|
sel_anchor: 0,
|
||||||
@@ -178,39 +170,60 @@ impl MTextEditorState {
|
|||||||
self.height.trim().parse::<f64>().ok().filter(|h| *h > 0.0).unwrap_or(0.25)
|
self.height.trim().parse::<f64>().ok().filter(|h| *h > 0.0).unwrap_or(0.25)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compose the raw editor text with the global leading inline codes
|
/// Fold the toolbar's global defaults (font / colour / oblique / width /
|
||||||
/// (font / colour / oblique / width / char-spacing) the toolbar's
|
/// char-spacing) onto every span that does not already override them. This
|
||||||
/// dropdowns and value fields set. Per-selection toggles already live
|
/// replaces the old hand-built `\f;\C;…` prefix so the value is produced
|
||||||
/// inside the text.
|
/// purely by acadrust's `to_mtext_string`.
|
||||||
pub fn composed_value(&self) -> String {
|
fn apply_globals(&self, doc: &mut MTextDocument) {
|
||||||
// No trailing-newline strip: a trailing line break is a real empty
|
let font = self.font.trim();
|
||||||
// line the user typed; keeping it lets the layout emit the caret slot
|
let color =
|
||||||
// so the caret shows on the new line right after Enter.
|
(self.color_aci != 256 && self.color_aci != 0).then(|| MTextColor::Index(self.color_aci));
|
||||||
let body = self.content.text();
|
let oblique = parse_non_default(&self.oblique, 0.0);
|
||||||
let mut prefix = String::new();
|
let width = parse_non_default(&self.width, 1.0);
|
||||||
if !self.font.trim().is_empty() {
|
let tracking = parse_non_default(&self.char_space, 0.0);
|
||||||
prefix.push_str(&format!("\\f{};", self.font.trim()));
|
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) {
|
self.apply_globals(&mut doc);
|
||||||
prefix.push_str(&format!("\\Q{};", v));
|
doc.to_mtext_string()
|
||||||
}
|
|
||||||
if let Some(v) = parse_non_default(&self.width, 1.0) {
|
|
||||||
prefix.push_str(&format!("\\W{};", v));
|
|
||||||
}
|
|
||||||
if let Some(v) = parse_non_default(&self.char_space, 0.0) {
|
|
||||||
prefix.push_str(&format!("\\T{};", v));
|
|
||||||
}
|
|
||||||
format!("{prefix}{body}")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the MText entity from the current editor state for preview/commit.
|
/// Build the MText entity from the current editor state for preview/commit.
|
||||||
pub fn build_mtext(&self) -> MText {
|
pub fn build_mtext(&self) -> MText {
|
||||||
let h = self.height_value();
|
let h = self.height_value();
|
||||||
MText {
|
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),
|
insertion_point: Vector3::new(self.pos.x as f64, self.pos.y as f64, self.pos.z as f64),
|
||||||
height: h,
|
height: h,
|
||||||
rectangle_width: self.rect_width,
|
rectangle_width: self.rect_width,
|
||||||
@@ -220,7 +233,6 @@ impl MTextEditorState {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a numeric field, returning `Some(v)` only when it differs from the
|
/// 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
|
/// A visible character carrying its span's formatting. Tabs count as a
|
||||||
/// normal char here (one slot), matching the previous caret model.
|
/// normal char here (one slot), matching the previous caret model.
|
||||||
Char(char, SpanProperties),
|
Char(char, SpanProperties),
|
||||||
/// A paragraph break (`\P`).
|
/// A paragraph break (`\P`), carrying the paragraph properties (alignment,
|
||||||
Break,
|
/// 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
|
/// 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
|
/// cells (`head=false`) extend the run begun by a `head=true` one, and edits
|
||||||
/// never split a run.
|
/// 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
|
/// 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<Cell> {
|
fn doc_to_cells(doc: &MTextDocument) -> Vec<Cell> {
|
||||||
let mut cells = Vec::new();
|
let mut cells = Vec::new();
|
||||||
for (pi, para) in doc.paragraphs.iter().enumerate() {
|
for (pi, para) in doc.paragraphs.iter().enumerate() {
|
||||||
if pi > 0 {
|
if pi > 0 {
|
||||||
cells.push(Cell::Break);
|
cells.push(Cell::Break(para.properties.clone()));
|
||||||
}
|
}
|
||||||
for span in ¶.spans {
|
for span in ¶.spans {
|
||||||
if let Some(st) = &span.stacking {
|
if let Some(st) = &span.stacking {
|
||||||
@@ -306,18 +321,30 @@ fn doc_to_cells(doc: &MTextDocument) -> Vec<Cell> {
|
|||||||
cells
|
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
|
/// 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
|
/// properties coalesce into one span; a `Break` starts a new paragraph carrying
|
||||||
/// `Stack` run's head cell re-emits the whole stacking span.
|
/// its stored properties; a `Stack` run's head cell re-emits the stacking span.
|
||||||
fn cells_to_doc(cells: &[Cell]) -> MTextDocument {
|
/// `para0` supplies the first paragraph's properties.
|
||||||
|
fn cells_to_doc(para0: &ParagraphProperties, cells: &[Cell]) -> MTextDocument {
|
||||||
let mut doc = MTextDocument::new();
|
let mut doc = MTextDocument::new();
|
||||||
doc.paragraphs.clear();
|
doc.paragraphs.clear();
|
||||||
let mut para = MTextParagraph::new();
|
let mut para = MTextParagraph::new();
|
||||||
|
para.properties = para0.clone();
|
||||||
for cell in cells {
|
for cell in cells {
|
||||||
match cell {
|
match cell {
|
||||||
Cell::Break => {
|
Cell::Break(props) => {
|
||||||
doc.paragraphs
|
doc.paragraphs
|
||||||
.push(std::mem::replace(&mut para, MTextParagraph::new()));
|
.push(std::mem::replace(&mut para, MTextParagraph::new()));
|
||||||
|
para.properties = props.clone();
|
||||||
}
|
}
|
||||||
Cell::Char(ch, props) => match para.spans.last_mut() {
|
Cell::Char(ch, props) => match para.spans.last_mut() {
|
||||||
Some(s) if s.stacking.is_none() && &s.properties == props => s.text.push(*ch),
|
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
|
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
|
/// Turn an inserted string (which may carry `\P` breaks) into cells, tagging
|
||||||
/// every plain char with `props`.
|
/// every plain char with `span_props` and every break with `para_props`.
|
||||||
fn str_to_cells(s: &str, props: &SpanProperties) -> Vec<Cell> {
|
fn str_to_cells(s: &str, span_props: &SpanProperties, para_props: &ParagraphProperties) -> Vec<Cell> {
|
||||||
let mut cells = Vec::new();
|
let mut cells = Vec::new();
|
||||||
for (i, seg) in s.split("\\P").enumerate() {
|
for (i, seg) in s.split("\\P").enumerate() {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
cells.push(Cell::Break);
|
cells.push(Cell::Break(para_props.clone()));
|
||||||
}
|
}
|
||||||
for ch in seg.chars() {
|
for ch in seg.chars() {
|
||||||
cells.push(Cell::Char(ch, props.clone()));
|
cells.push(Cell::Char(ch, span_props.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cells
|
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
|
/// 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.
|
/// run in half. Returns the (possibly widened) start index — the new caret.
|
||||||
fn cells_delete_range(cells: &mut Vec<Cell>, mut a: usize, mut b: usize) -> usize {
|
fn cells_delete_range(cells: &mut Vec<Cell>, mut a: usize, mut b: usize) -> usize {
|
||||||
@@ -401,111 +483,11 @@ fn cells_delete_range(cells: &mut Vec<Cell>, mut a: usize, mut b: usize) -> usiz
|
|||||||
a
|
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 ──────────────────────────────────────────────────
|
// ── App-side editor driver ──────────────────────────────────────────────────
|
||||||
|
|
||||||
use crate::scene::convert::tessellate;
|
use crate::scene::convert::tessellate;
|
||||||
use crate::scene::model::wire_model::WireModel;
|
use crate::scene::model::wire_model::WireModel;
|
||||||
use iced::widget::text_editor::{Action, Edit};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
impl super::OpenCADStudio {
|
impl super::OpenCADStudio {
|
||||||
/// Open the in-place editor for a new (`handle = None`) or existing MText.
|
/// 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
|
/// Splice text around the preview selection (visible-char range) in the
|
||||||
/// raw value. `case` optionally transforms the selected slice. Returns
|
/// raw value. `case` optionally transforms the selected slice. Returns
|
||||||
/// true when a preview selection was present and applied.
|
/// true when a preview selection was present and applied.
|
||||||
fn mtext_splice_sel(&mut self, prefix: &str, suffix: &str, case: Option<bool>) -> bool {
|
/// Toggle a character format over the preview selection, on the structured
|
||||||
let Some(ed) = self.mtext_editor.as_mut() else { return false };
|
/// span properties. The stroke-font renderer has no true bold, so Bold
|
||||||
let Some((a, b)) = ed.sel else { return false };
|
/// switches the run to the heavier "Gothic" face; Italic applies a 15°
|
||||||
if a >= b {
|
/// oblique; underline / overline / strike flip the stroke flags; case
|
||||||
return false;
|
/// rewrites the glyphs. All are true ON/OFF toggles.
|
||||||
}
|
|
||||||
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.
|
|
||||||
pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) {
|
pub(super) fn mtext_apply_fmt(&mut self, kind: MTextFmt) {
|
||||||
// Font to restore to after a Bold run (the current global font).
|
// Does every span-carrying cell in the range satisfy `get`?
|
||||||
let restore = self
|
fn all_have(cells: &[Cell], get: impl Fn(&SpanProperties) -> bool) -> bool {
|
||||||
.mtext_editor
|
cells.iter().all(|c| match c {
|
||||||
.as_ref()
|
Cell::Char(_, p) | Cell::Stack { props: p, .. } => get(p),
|
||||||
.map(|e| {
|
Cell::Break(_) => true,
|
||||||
if e.font.trim().is_empty() {
|
|
||||||
"Standard".to_string()
|
|
||||||
} else {
|
|
||||||
e.font.clone()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| "Standard".to_string());
|
}
|
||||||
let (pre, suf, case): (String, String, Option<bool>) = match kind {
|
// Apply `f` to every span-carrying cell's properties in the range.
|
||||||
MTextFmt::Bold => ("\\fGothic;".into(), format!("\\f{restore};"), None),
|
fn each(cells: &mut [Cell], mut f: impl FnMut(&mut SpanProperties)) {
|
||||||
MTextFmt::Italic => ("\\Q15;".into(), "\\Q0;".into(), None),
|
for c in cells {
|
||||||
MTextFmt::Underline => ("\\L".into(), "\\l".into(), None),
|
match c {
|
||||||
MTextFmt::Overline => ("\\O".into(), "\\o".into(), None),
|
Cell::Char(_, p) | Cell::Stack { props: p, .. } => f(p),
|
||||||
MTextFmt::Strike => ("\\K".into(), "\\k".into(), None),
|
Cell::Break(_) => {}
|
||||||
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))));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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();
|
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) {
|
pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) {
|
||||||
let code = format!("\\pxq{};", align.code());
|
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||||
if !self.mtext_splice_sel(&code, "", None) {
|
let para0 = doc_para0(&ed.doc);
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
let cells = doc_to_cells(&ed.doc);
|
||||||
let sel = ed.content.selection().unwrap_or_default();
|
let (a, b) = ed
|
||||||
let text = format!("{code}{sel}");
|
.sel
|
||||||
ed.content.perform(Action::Edit(Edit::Paste(Arc::new(text))));
|
.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();
|
self.rebuild_mtext_preview();
|
||||||
}
|
}
|
||||||
@@ -732,6 +784,7 @@ impl super::OpenCADStudio {
|
|||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||||
// Edit the structured document as a flat cell list, then serialize
|
// Edit the structured document as a flat cell list, then serialize
|
||||||
// it back into the raw content the preview/commit still read.
|
// 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 mut cells = doc_to_cells(&ed.doc);
|
||||||
let count = cells.len();
|
let count = cells.len();
|
||||||
let mut caret = match ed.sel {
|
let mut caret = match ed.sel {
|
||||||
@@ -740,12 +793,13 @@ impl super::OpenCADStudio {
|
|||||||
};
|
};
|
||||||
caret = clamp_insert(&cells, caret);
|
caret = clamp_insert(&cells, caret);
|
||||||
let props = insert_props(&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();
|
let added = ins.len();
|
||||||
cells.splice(caret..caret, ins);
|
cells.splice(caret..caret, ins);
|
||||||
caret += added;
|
caret += added;
|
||||||
ed.content =
|
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.caret = caret;
|
||||||
ed.sel = Some((caret, caret));
|
ed.sel = Some((caret, caret));
|
||||||
ed.caret_blink_on = true;
|
ed.caret_blink_on = true;
|
||||||
@@ -756,6 +810,7 @@ impl super::OpenCADStudio {
|
|||||||
/// Delete the selection, or the visible character before the caret.
|
/// Delete the selection, or the visible character before the caret.
|
||||||
pub(super) fn mtext_backspace(&mut self) {
|
pub(super) fn mtext_backspace(&mut self) {
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||||
|
let para0 = doc_para0(&ed.doc);
|
||||||
let mut cells = doc_to_cells(&ed.doc);
|
let mut cells = doc_to_cells(&ed.doc);
|
||||||
let count = cells.len();
|
let count = cells.len();
|
||||||
let caret = match ed.sel {
|
let caret = match ed.sel {
|
||||||
@@ -766,7 +821,7 @@ impl super::OpenCADStudio {
|
|||||||
_ => ed.caret.min(count),
|
_ => ed.caret.min(count),
|
||||||
};
|
};
|
||||||
ed.content =
|
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.caret = caret;
|
||||||
ed.sel = Some((caret, caret));
|
ed.sel = Some((caret, caret));
|
||||||
ed.caret_blink_on = true;
|
ed.caret_blink_on = true;
|
||||||
@@ -777,6 +832,7 @@ impl super::OpenCADStudio {
|
|||||||
/// Delete the selection, or the visible character at the caret.
|
/// Delete the selection, or the visible character at the caret.
|
||||||
pub(super) fn mtext_delete(&mut self) {
|
pub(super) fn mtext_delete(&mut self) {
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||||
|
let para0 = doc_para0(&ed.doc);
|
||||||
let mut cells = doc_to_cells(&ed.doc);
|
let mut cells = doc_to_cells(&ed.doc);
|
||||||
let count = cells.len();
|
let count = cells.len();
|
||||||
let caret = match ed.sel {
|
let caret = match ed.sel {
|
||||||
@@ -787,7 +843,7 @@ impl super::OpenCADStudio {
|
|||||||
_ => ed.caret.min(count),
|
_ => ed.caret.min(count),
|
||||||
};
|
};
|
||||||
ed.content =
|
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.caret = caret;
|
||||||
ed.sel = Some((caret, caret));
|
ed.sel = Some((caret, caret));
|
||||||
ed.caret_blink_on = true;
|
ed.caret_blink_on = true;
|
||||||
@@ -814,6 +870,148 @@ impl super::OpenCADStudio {
|
|||||||
.unwrap_or(0)
|
.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<MTextColor>, 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<String> {
|
||||||
|
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.
|
/// Commit the editor — create a new MText or update the edited one.
|
||||||
pub(super) fn mtext_commit(&mut self) -> bool {
|
pub(super) fn mtext_commit(&mut self) -> bool {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
@@ -869,7 +1067,8 @@ mod cell_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn rt(s: &str) -> String {
|
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<String> {
|
fn paras(s: &str) -> Vec<String> {
|
||||||
parse_mtext(s, true)
|
parse_mtext(s, true)
|
||||||
@@ -906,21 +1105,24 @@ mod cell_tests {
|
|||||||
let cells = vec![
|
let cells = vec![
|
||||||
Cell::Char('a', SpanProperties::default()),
|
Cell::Char('a', SpanProperties::default()),
|
||||||
Cell::Char('b', 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);
|
assert_eq!(back, cells);
|
||||||
// Typing after the trailing break lands on the new (empty) paragraph.
|
// Typing after the trailing break lands on the new (empty) paragraph.
|
||||||
let mut c2 = cells.clone();
|
let mut c2 = cells.clone();
|
||||||
c2.splice(3..3, str_to_cells("x", &SpanProperties::default()));
|
c2.splice(3..3, str_to_cells("x", &SpanProperties::default(), &ParagraphProperties::default()));
|
||||||
assert_eq!(paras(&cells_to_doc(&c2).to_mtext_string()), vec!["ab", "x"]);
|
assert_eq!(
|
||||||
|
paras(&cells_to_doc(&ParagraphProperties::default(), &c2).to_mtext_string()),
|
||||||
|
vec!["ab", "x"]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn insert_break_splits_paragraph() {
|
fn insert_break_splits_paragraph() {
|
||||||
let mut cells = cells_of("abcd");
|
let mut cells = cells_of("abcd");
|
||||||
cells.splice(2..2, str_to_cells("\\P", &SpanProperties::default()));
|
cells.splice(2..2, str_to_cells("\\P", &SpanProperties::default(), &ParagraphProperties::default()));
|
||||||
assert_eq!(paras(&cells_to_doc(&cells).to_mtext_string()), vec!["ab", "cd"]);
|
assert_eq!(paras(&cells_to_doc(&ParagraphProperties::default(), &cells).to_mtext_string()), vec!["ab", "cd"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -928,14 +1130,14 @@ mod cell_tests {
|
|||||||
let mut cells = cells_of("ab\\Pcd"); // [a,b,Break,c,d]
|
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
|
let caret = cells_delete_range(&mut cells, 2, 3); // delete the Break slot
|
||||||
assert_eq!(caret, 2);
|
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]
|
#[test]
|
||||||
fn delete_range_across_paragraphs() {
|
fn delete_range_across_paragraphs() {
|
||||||
let mut cells = cells_of("abc\\Pdef"); // [a,b,c,Break,d,e,f]
|
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
|
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]
|
#[test]
|
||||||
@@ -950,6 +1152,102 @@ mod cell_tests {
|
|||||||
assert!(c2.is_empty());
|
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]
|
#[test]
|
||||||
fn trailing_break_detection() {
|
fn trailing_break_detection() {
|
||||||
assert!(content_has_trailing_break("{ab\\P}")); // braced (to_mtext_string form)
|
assert!(content_has_trailing_break("{ab\\P}")); // braced (to_mtext_string form)
|
||||||
@@ -965,9 +1263,9 @@ mod cell_tests {
|
|||||||
let cells = vec![
|
let cells = vec![
|
||||||
Cell::Char('a', SpanProperties::default()),
|
Cell::Char('a', SpanProperties::default()),
|
||||||
Cell::Char('b', 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.
|
// rebuild() re-parses + restores the trailing empty paragraph.
|
||||||
let mut d = parse_mtext(&raw, true);
|
let mut d = parse_mtext(&raw, true);
|
||||||
if content_has_trailing_break(&raw) {
|
if content_has_trailing_break(&raw) {
|
||||||
|
|||||||
+38
-17
@@ -1865,11 +1865,14 @@ impl OpenCADStudio {
|
|||||||
self.rebuild_mtext_preview();
|
self.rebuild_mtext_preview();
|
||||||
Task::none()
|
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() {
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::MTextStyle(s) => {
|
Message::MTextStyle(s) => {
|
||||||
@@ -1880,14 +1883,7 @@ impl OpenCADStudio {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::MTextFont(f) => {
|
Message::MTextFont(f) => {
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
self.mtext_apply_font(&f);
|
||||||
ed.font = if f == "[Style default]" {
|
|
||||||
String::new()
|
|
||||||
} else {
|
|
||||||
f
|
|
||||||
};
|
|
||||||
}
|
|
||||||
self.rebuild_mtext_preview();
|
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
Message::MTextOblique(s) => {
|
Message::MTextOblique(s) => {
|
||||||
@@ -1945,11 +1941,32 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::MTextSelStart(off) => {
|
Message::MTextSelStart(off) => {
|
||||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
// Count quick same-spot clicks: 1 = place caret, 2 = select the
|
||||||
ed.sel_anchor = off;
|
// word, 3 = select all.
|
||||||
ed.sel = Some((off, off));
|
let now = Instant::now();
|
||||||
ed.caret = off;
|
let count = match self.mtext_click_time {
|
||||||
ed.caret_blink_on = true;
|
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()
|
Task::none()
|
||||||
}
|
}
|
||||||
@@ -1997,7 +2014,11 @@ impl OpenCADStudio {
|
|||||||
|
|
||||||
Message::SelectAllShortcut => {
|
Message::SelectAllShortcut => {
|
||||||
let i = self.active_tab;
|
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).
|
// Select every row in the Layer Manager (#236).
|
||||||
let n = self.tabs[i].layers.layers.len();
|
let n = self.tabs[i].layers.layers.len();
|
||||||
self.tabs[i].layers.selected_multi = (0..n).collect();
|
self.tabs[i].layers.selected_multi = (0..n).collect();
|
||||||
|
|||||||
@@ -969,6 +969,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task<Message> {
|
|||||||
Some(crate::app::ColorPickTarget::Properties) => {
|
Some(crate::app::ColorPickTarget::Properties) => {
|
||||||
Some(Message::PropColorChanged(color))
|
Some(Message::PropColorChanged(color))
|
||||||
}
|
}
|
||||||
|
Some(crate::app::ColorPickTarget::MText) => {
|
||||||
|
Some(Message::MTextColorChanged(color))
|
||||||
|
}
|
||||||
Some(crate::app::ColorPickTarget::Ribbon) => {
|
Some(crate::app::ColorPickTarget::Ribbon) => {
|
||||||
Some(Message::RibbonColorChanged(color))
|
Some(Message::RibbonColorChanged(color))
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-103
@@ -95,17 +95,6 @@ const MTEXT_FONTS: [&str; 10] = [
|
|||||||
"RomanC",
|
"RomanC",
|
||||||
];
|
];
|
||||||
/// (label, ACI). 256 = ByLayer.
|
/// (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
|
/// Canvas program that renders the tessellated MText strokes inside the
|
||||||
/// editor's own preview area (never on the drawing). Strokes lie in 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.
|
/// 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 {
|
struct MTextPreview {
|
||||||
/// Disconnected polylines as (x, y) world points + colour (NaN-split done).
|
/// 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.
|
/// Per-visible-character boxes (world frame) for click-to-select.
|
||||||
boxes: Vec<crate::entities::text_support::GlyphBox>,
|
boxes: Vec<crate::entities::text_support::GlyphBox>,
|
||||||
/// Current selection as a visible-char range.
|
/// Current selection as a visible-char range.
|
||||||
@@ -253,7 +242,7 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (seg, col) in &self.segments {
|
for (seg, col, width) in &self.segments {
|
||||||
if seg.len() < 2 {
|
if seg.len() < 2 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -263,7 +252,7 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
|
|||||||
p.line_to(map(x, y));
|
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
|
// Caret — a vertical bar at the caret's glyph boundary, shown when the
|
||||||
// selection is empty (a plain text cursor).
|
// selection is empty (a plain text cursor).
|
||||||
@@ -326,11 +315,12 @@ impl iced::widget::canvas::Program<Message> for MTextPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Split every preview WireModel into finite (x, y) polyline runs, each
|
/// 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(
|
fn mtext_preview_segments(
|
||||||
ed: &super::super::mtext_editor::MTextEditorState,
|
ed: &super::super::mtext_editor::MTextEditorState,
|
||||||
) -> Vec<(Vec<(f32, f32)>, Color)> {
|
) -> Vec<(Vec<(f32, f32)>, Color, f32)> {
|
||||||
let mut out: Vec<(Vec<(f32, f32)>, Color)> = Vec::new();
|
let mut out: Vec<(Vec<(f32, f32)>, Color, f32)> = Vec::new();
|
||||||
for w in &ed.preview_wires {
|
for w in &ed.preview_wires {
|
||||||
let col = Color {
|
let col = Color {
|
||||||
r: w.color[0],
|
r: w.color[0],
|
||||||
@@ -338,16 +328,18 @@ fn mtext_preview_segments(
|
|||||||
b: w.color[2],
|
b: w.color[2],
|
||||||
a: 1.0,
|
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();
|
let mut run: Vec<(f32, f32)> = Vec::new();
|
||||||
for p in &w.points {
|
for p in &w.points {
|
||||||
if p[0].is_finite() && p[1].is_finite() {
|
if p[0].is_finite() && p[1].is_finite() {
|
||||||
run.push((p[0], p[1]));
|
run.push((p[0], p[1]));
|
||||||
} else if !run.is_empty() {
|
} 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() {
|
if !run.is_empty() {
|
||||||
out.push((run, col));
|
out.push((run, col, width));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -359,7 +351,7 @@ pub(super) fn mtext_editor_overlay<'a>(
|
|||||||
canvas_size: (f32, f32),
|
canvas_size: (f32, f32),
|
||||||
) -> Element<'a, Message> {
|
) -> Element<'a, Message> {
|
||||||
use super::super::mtext_editor::{JustifyChoice, MTextFmt, ParaAlign};
|
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 {
|
const PANEL_BG: Color = Color {
|
||||||
r: 0.16,
|
r: 0.16,
|
||||||
@@ -455,28 +447,20 @@ pub(super) fn mtext_editor_overlay<'a>(
|
|||||||
)
|
)
|
||||||
.text_size(11)
|
.text_size(11)
|
||||||
.width(iced::Length::Fixed(120.0));
|
.width(iced::Length::Fixed(120.0));
|
||||||
let color_sel = MTEXT_COLORS
|
// Same colour picker as the Properties panel (named swatches + "More…" full
|
||||||
.iter()
|
// palette), applied to the selection or the whole text.
|
||||||
.find(|(_, a)| *a == ed.color_aci)
|
let color_pl = iced::widget::container(crate::ui::color_select::color_selector(
|
||||||
.map(|(n, _)| n.to_string())
|
acadrust::types::Color::from_index(ed.color_aci as i16),
|
||||||
.unwrap_or_else(|| "ByLayer".to_string());
|
ed.color_picker_open,
|
||||||
let color_pl = pick_list(
|
crate::ui::color_select::ColorExtras {
|
||||||
MTEXT_COLORS
|
by_layer: true,
|
||||||
.iter()
|
by_block: false,
|
||||||
.map(|(n, _)| n.to_string())
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
Some(color_sel),
|
|
||||||
|name: String| {
|
|
||||||
let aci = MTEXT_COLORS
|
|
||||||
.iter()
|
|
||||||
.find(|(n, _)| *n == name)
|
|
||||||
.map(|(_, a)| *a)
|
|
||||||
.unwrap_or(256);
|
|
||||||
Message::MTextColor(aci)
|
|
||||||
},
|
},
|
||||||
)
|
Message::MTextColorChanged,
|
||||||
.text_size(11)
|
Message::MTextColorPickerToggle,
|
||||||
.width(iced::Length::Fixed(96.0));
|
Message::OpenColorWindow(crate::app::ColorPickTarget::MText),
|
||||||
|
))
|
||||||
|
.width(iced::Length::Fixed(150.0));
|
||||||
|
|
||||||
let row1 = row![
|
let row1 = row![
|
||||||
style_pl,
|
style_pl,
|
||||||
@@ -578,62 +562,12 @@ pub(super) fn mtext_editor_overlay<'a>(
|
|||||||
.align_y(iced::Alignment::Center);
|
.align_y(iced::Alignment::Center);
|
||||||
|
|
||||||
// ── Segmented Edit | Preview toggle (between toolbar and body) ────────
|
// ── Segmented Edit | Preview toggle (between toolbar and body) ────────
|
||||||
let seg_btn =
|
// ── Body: the rendered preview (the editor is preview-only) ─────────
|
||||||
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 ────────
|
|
||||||
const VIEW_H: f32 = 150.0;
|
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 segments = mtext_preview_segments(ed);
|
||||||
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN);
|
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 {
|
for &(x, y) in seg {
|
||||||
minx = minx.min(x);
|
minx = minx.min(x);
|
||||||
miny = miny.min(y);
|
miny = miny.min(y);
|
||||||
@@ -685,17 +619,9 @@ pub(super) fn mtext_editor_overlay<'a>(
|
|||||||
.padding(2)
|
.padding(2)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
.into()
|
.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 {
|
.style(move |_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(PANEL_BG)),
|
background: Some(Background::Color(PANEL_BG)),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|||||||
@@ -306,6 +306,7 @@ fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument)
|
|||||||
width_factor,
|
width_factor,
|
||||||
oblique: oblique_angle,
|
oblique: oblique_angle,
|
||||||
tracking: 1.0,
|
tracking: 1.0,
|
||||||
|
bold: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1529,6 +1529,7 @@ impl MultiLeaderTess for MultiLeader {
|
|||||||
run.oblique,
|
run.oblique,
|
||||||
run.tracking,
|
run.tracking,
|
||||||
&run.font,
|
&run.font,
|
||||||
|
run.bold,
|
||||||
&run.text,
|
&run.text,
|
||||||
);
|
);
|
||||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||||
|
|||||||
@@ -724,6 +724,7 @@ pub fn tessellate_table(
|
|||||||
run.oblique,
|
run.oblique,
|
||||||
run.tracking,
|
run.tracking,
|
||||||
&run.font,
|
&run.font,
|
||||||
|
run.bold,
|
||||||
&run.text,
|
&run.text,
|
||||||
);
|
);
|
||||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity {
|
|||||||
width_factor: p.width_factor,
|
width_factor: p.width_factor,
|
||||||
oblique: p.oblique_angle,
|
oblique: p.oblique_angle,
|
||||||
tracking: 1.0,
|
tracking: 1.0,
|
||||||
|
bold: false,
|
||||||
}),
|
}),
|
||||||
}]),
|
}]),
|
||||||
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
snap_pts: vec![(snap_pt, SnapHint::Insertion)],
|
||||||
|
|||||||
@@ -275,6 +275,8 @@ pub struct RunState {
|
|||||||
pub underline: bool,
|
pub underline: bool,
|
||||||
pub overline: bool,
|
pub overline: bool,
|
||||||
pub strike: bool,
|
pub strike: bool,
|
||||||
|
/// Bold run — rendered with a wider SDF pen (thicker strokes).
|
||||||
|
pub bold: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RunState {
|
impl Default for RunState {
|
||||||
@@ -290,6 +292,7 @@ impl Default for RunState {
|
|||||||
underline: false,
|
underline: false,
|
||||||
overline: false,
|
overline: false,
|
||||||
strike: false,
|
strike: false,
|
||||||
|
bold: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,6 +438,7 @@ pub fn adapt_mtext_paragraphs(
|
|||||||
_ => 0,
|
_ => 0,
|
||||||
},
|
},
|
||||||
font: p.font.as_ref().map(|f| font_stem(&f.name)),
|
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),
|
color: color_of(p),
|
||||||
underline: p.stroke.underline(),
|
underline: p.stroke.underline(),
|
||||||
overline: p.stroke.overline(),
|
overline: p.stroke.overline(),
|
||||||
@@ -1108,6 +1112,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||||||
width_factor: signed_wf,
|
width_factor: signed_wf,
|
||||||
oblique,
|
oblique,
|
||||||
tracking,
|
tracking,
|
||||||
|
bold: atom.state.bold,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if opts.want_glyph_boxes {
|
if opts.want_glyph_boxes {
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ impl TruckConvertible for Tolerance {
|
|||||||
width_factor: 1.0,
|
width_factor: 1.0,
|
||||||
oblique: 0.0,
|
oblique: 0.0,
|
||||||
tracking: 0.0,
|
tracking: 0.0,
|
||||||
|
bold: false,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ pub struct GlyphRun {
|
|||||||
pub width_factor: f32,
|
pub width_factor: f32,
|
||||||
pub oblique: f32,
|
pub oblique: f32,
|
||||||
pub tracking: f32,
|
pub tracking: f32,
|
||||||
|
/// Bold run — the SDF glyph bakes with a wider pen (thicker strokes).
|
||||||
|
pub bold: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -179,9 +179,11 @@ pub fn tessellate(
|
|||||||
// Selection forces a single uniform colour — never split.
|
// Selection forces a single uniform colour — never split.
|
||||||
let split_by_color = !selected;
|
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 {
|
struct TextBin {
|
||||||
color: Option<[f32; 3]>,
|
color: Option<[f32; 3]>,
|
||||||
|
bold: bool,
|
||||||
pts: Vec<[f32; 3]>,
|
pts: Vec<[f32; 3]>,
|
||||||
pts_low: Vec<[f32; 3]>,
|
pts_low: Vec<[f32; 3]>,
|
||||||
fill_tris: Vec<[f32; 3]>,
|
fill_tris: Vec<[f32; 3]>,
|
||||||
@@ -189,22 +191,26 @@ pub fn tessellate(
|
|||||||
}
|
}
|
||||||
let mut bins: Vec<TextBin> = Vec::new();
|
let mut bins: Vec<TextBin> = Vec::new();
|
||||||
let mut bin_first: Vec<bool> = Vec::new();
|
let mut bin_first: Vec<bool> = Vec::new();
|
||||||
let find_or_make =
|
let find_or_make = |key: Option<[f32; 3]>,
|
||||||
|key: Option<[f32; 3]>, bins: &mut Vec<TextBin>, firsts: &mut Vec<bool>| -> usize {
|
bold: bool,
|
||||||
if let Some(i) = bins.iter().position(|b| b.color == key) {
|
bins: &mut Vec<TextBin>,
|
||||||
i
|
firsts: &mut Vec<bool>|
|
||||||
} else {
|
-> usize {
|
||||||
bins.push(TextBin {
|
if let Some(i) = bins.iter().position(|b| b.color == key && b.bold == bold) {
|
||||||
color: key,
|
i
|
||||||
pts: Vec::new(),
|
} else {
|
||||||
pts_low: Vec::new(),
|
bins.push(TextBin {
|
||||||
fill_tris: Vec::new(),
|
color: key,
|
||||||
fill_tris_low: Vec::new(),
|
bold,
|
||||||
});
|
pts: Vec::new(),
|
||||||
firsts.push(true);
|
pts_low: Vec::new(),
|
||||||
bins.len() - 1
|
fill_tris: Vec::new(),
|
||||||
}
|
fill_tris_low: Vec::new(),
|
||||||
};
|
});
|
||||||
|
firsts.push(true);
|
||||||
|
bins.len() - 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let anno = anno_scale as f64;
|
let anno = anno_scale as f64;
|
||||||
// SDF text is decided PER GROUP by whether the group carries a
|
// 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 slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v;
|
||||||
let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_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 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
|
// 1. Process outline strokes
|
||||||
for stroke in &group.strokes {
|
for stroke in &group.strokes {
|
||||||
@@ -287,6 +294,7 @@ pub fn tessellate(
|
|||||||
run.oblique,
|
run.oblique,
|
||||||
run.tracking,
|
run.tracking,
|
||||||
&run.font,
|
&run.font,
|
||||||
|
run.bold,
|
||||||
&run.text,
|
&run.text,
|
||||||
);
|
);
|
||||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||||
@@ -499,7 +507,16 @@ pub fn tessellate(
|
|||||||
Some([r, g, b]) => [r, g, b, color[3]],
|
Some([r, g, b]) => [r, g, b, color[3]],
|
||||||
None => color,
|
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() {
|
if !bin.pts.is_empty() {
|
||||||
let (snap, keys, tangents) = if is_first {
|
let (snap, keys, tangents) = if is_first {
|
||||||
is_first = false;
|
is_first = false;
|
||||||
@@ -520,7 +537,7 @@ pub fn tessellate(
|
|||||||
selected,
|
selected,
|
||||||
pattern_length: 0.0,
|
pattern_length: 0.0,
|
||||||
pattern: [0.0; 8],
|
pattern: [0.0; 8],
|
||||||
line_weight_px,
|
line_weight_px: bin_lw,
|
||||||
snap_pts: snap,
|
snap_pts: snap,
|
||||||
tangent_geoms: tangents,
|
tangent_geoms: tangents,
|
||||||
aci: 0,
|
aci: 0,
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ pub fn apply_along(
|
|||||||
0.0,
|
0.0,
|
||||||
0.0,
|
0.0,
|
||||||
style,
|
style,
|
||||||
|
false,
|
||||||
&resolved,
|
&resolved,
|
||||||
);
|
);
|
||||||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub fn layout_glyph_quads(
|
|||||||
oblique_angle: f32,
|
oblique_angle: f32,
|
||||||
tracking: f32,
|
tracking: f32,
|
||||||
font_name: &str,
|
font_name: &str,
|
||||||
|
bold: bool,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Vec<GlyphQuad> {
|
) -> Vec<GlyphQuad> {
|
||||||
if text.is_empty() || height <= 0.0 {
|
if text.is_empty() || height <= 0.0 {
|
||||||
@@ -129,7 +130,7 @@ pub fn layout_glyph_quads(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match atlas.get_or_insert(font_name, ch) {
|
match atlas.get_or_insert(font_name, ch, bold) {
|
||||||
Some(e) => {
|
Some(e) => {
|
||||||
let (lo, hi) = (e.plane_min, e.plane_max);
|
let (lo, hi) = (e.plane_min, e.plane_max);
|
||||||
quads.push(GlyphQuad {
|
quads.push(GlyphQuad {
|
||||||
@@ -195,7 +196,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn glyphs_advance_rightward() {
|
fn glyphs_advance_rightward() {
|
||||||
let mut atlas = GlyphAtlas::new(512, 512);
|
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_eq!(quads.len(), 2, "two inked glyphs -> two quads");
|
||||||
assert!(
|
assert!(
|
||||||
center(&quads[1])[0] > center(&quads[0])[0],
|
center(&quads[1])[0] > center(&quads[0])[0],
|
||||||
@@ -213,8 +214,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn space_emits_no_quad_but_advances() {
|
fn space_emits_no_quad_but_advances() {
|
||||||
let mut atlas = GlyphAtlas::new(512, 512);
|
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 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", "A A");
|
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_eq!(a_sp_a.len(), 2, "space produces no quad");
|
||||||
assert!(
|
assert!(
|
||||||
center(&a_sp_a[1])[0] > center(&ab[1])[0],
|
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
|
// 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
|
// flat and swings to +y after a 90° turn — independent of where a single
|
||||||
// glyph's box centre sits within the cap height.
|
// 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(
|
let turned = layout_glyph_quads(
|
||||||
&mut atlas,
|
&mut atlas,
|
||||||
10.0,
|
10.0,
|
||||||
@@ -237,6 +238,7 @@ mod tests {
|
|||||||
0.0,
|
0.0,
|
||||||
1.0,
|
1.0,
|
||||||
"txt",
|
"txt",
|
||||||
|
false,
|
||||||
"AA",
|
"AA",
|
||||||
);
|
);
|
||||||
let adv = |qs: &[GlyphQuad]| {
|
let adv = |qs: &[GlyphQuad]| {
|
||||||
@@ -252,8 +254,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn taller_text_makes_bigger_quads() {
|
fn taller_text_makes_bigger_quads() {
|
||||||
let mut atlas = GlyphAtlas::new(512, 512);
|
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 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", "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 extent = |q: &GlyphQuad| {
|
||||||
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
|
let (mut lo, mut hi) = ([f32::MAX; 2], [f32::MIN; 2]);
|
||||||
for p in &q.corners {
|
for p in &q.corners {
|
||||||
@@ -274,9 +276,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn underline_emits_an_extra_solid_quad() {
|
fn underline_emits_an_extra_solid_quad() {
|
||||||
let mut atlas = GlyphAtlas::new(512, 512);
|
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.
|
// `\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");
|
assert_eq!(under.len(), plain.len() + 1, "underline adds one quad");
|
||||||
// The decoration quad samples the atlas's solid texel (degenerate UV).
|
// The decoration quad samples the atlas's solid texel (degenerate UV).
|
||||||
let deco = under.last().unwrap();
|
let deco = under.last().unwrap();
|
||||||
|
|||||||
+24
-17
@@ -111,9 +111,10 @@ pub struct GlyphAtlas {
|
|||||||
height: u32,
|
height: u32,
|
||||||
/// R8 SDF texels, row-major, row 0 = top.
|
/// R8 SDF texels, row-major, row 0 = top.
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
/// Cache keyed by (font family, char). `None` = a whitespace/empty glyph
|
/// Cache keyed by (font family, char, bold). `None` = a whitespace/empty
|
||||||
/// with no ink (nothing to draw), cached so we do not re-resolve it.
|
/// glyph with no ink (nothing to draw), cached so we do not re-resolve it.
|
||||||
entries: HashMap<(String, char), Option<AtlasEntry>>,
|
/// Bold bakes the same glyph with a wider pen (a separate atlas entry).
|
||||||
|
entries: HashMap<(String, char, bool), Option<AtlasEntry>>,
|
||||||
// Shelf packer cursor.
|
// Shelf packer cursor.
|
||||||
cursor_x: u32,
|
cursor_x: u32,
|
||||||
cursor_y: u32,
|
cursor_y: u32,
|
||||||
@@ -201,15 +202,21 @@ impl GlyphAtlas {
|
|||||||
/// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the
|
/// 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
|
/// atlas is full (Phase 1 is single-page; callers should treat `None` as
|
||||||
/// "skip / fall back").
|
/// "skip / fall back").
|
||||||
pub fn get_or_insert(&mut self, family: &str, ch: char) -> Option<AtlasEntry> {
|
pub fn get_or_insert(&mut self, family: &str, ch: char, bold: bool) -> Option<AtlasEntry> {
|
||||||
let key = (family.to_string(), ch);
|
let key = (family.to_string(), ch, bold);
|
||||||
if let Some(cached) = self.entries.get(&key) {
|
if let Some(cached) = self.entries.get(&key) {
|
||||||
return *cached;
|
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 face = Face::resolve(family);
|
||||||
let entry = face
|
let entry = face
|
||||||
.glyph(ch)
|
.glyph(ch)
|
||||||
.and_then(|g| bake_glyph(&g))
|
.and_then(|g| bake_glyph(&g, pen_half))
|
||||||
.and_then(|tile| self.pack(tile));
|
.and_then(|tile| self.pack(tile));
|
||||||
self.entries.insert(key, entry);
|
self.entries.insert(key, entry);
|
||||||
entry
|
entry
|
||||||
@@ -252,7 +259,7 @@ impl GlyphAtlas {
|
|||||||
// ── Baking ─────────────────────────────────────────────────────────────────
|
// ── Baking ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink.
|
/// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink.
|
||||||
fn bake_glyph(g: &Glyph) -> Option<BakedTile> {
|
fn bake_glyph(g: &Glyph, pen_half: f32) -> Option<BakedTile> {
|
||||||
// TEXTFILL 0 renders TrueType glyphs hollow: bake them as a stroke field
|
// 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
|
// (outline) instead of a signed fill field. Stroke fonts (no fill tris) are
|
||||||
// unaffected.
|
// unaffected.
|
||||||
@@ -304,8 +311,8 @@ fn bake_glyph(g: &Glyph) -> Option<BakedTile> {
|
|||||||
-d
|
-d
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Band around the pen path.
|
// Band around the pen path (wider for bold).
|
||||||
PEN_HALF_UNITS - d
|
pen_half - d
|
||||||
};
|
};
|
||||||
let t = (0.5 + sd / (2.0 * SPREAD_UNITS)).clamp(0.0, 1.0);
|
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;
|
data[(py * w + px) as usize] = (t * 255.0).round() as u8;
|
||||||
@@ -423,7 +430,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filled_glyph_inside_is_high_outside_is_low() {
|
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.
|
// Deep inside the fill -> well above the 0.5 (128) edge.
|
||||||
assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside");
|
assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside");
|
||||||
// Well outside the fill -> below the edge.
|
// Well outside the fill -> below the edge.
|
||||||
@@ -435,7 +442,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stroke_glyph_on_line_is_high_off_line_is_low() {
|
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.
|
// On the diagonal -> inside the pen band.
|
||||||
assert!(sample(&tile, 2.0, 2.0) > 128, "on-stroke should be inside 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.
|
// Off the diagonal by more than the pen half-width -> outside.
|
||||||
@@ -452,14 +459,14 @@ mod tests {
|
|||||||
advance: 4.5,
|
advance: 4.5,
|
||||||
fill_tris: vec![],
|
fill_tris: vec![],
|
||||||
};
|
};
|
||||||
assert!(bake_glyph(&space).is_none());
|
assert!(bake_glyph(&space, PEN_HALF_UNITS).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn packing_places_tiles_without_overlap() {
|
fn packing_places_tiles_without_overlap() {
|
||||||
let mut atlas = GlyphAtlas::new(256, 256);
|
let mut atlas = GlyphAtlas::new(256, 256);
|
||||||
let a = atlas.pack(bake_glyph(&filled_square()).unwrap()).unwrap();
|
let a = atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).unwrap();
|
||||||
let b = atlas.pack(bake_glyph(&diagonal_stroke()).unwrap()).unwrap();
|
let b = atlas.pack(bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).unwrap()).unwrap();
|
||||||
assert!(atlas.is_dirty());
|
assert!(atlas.is_dirty());
|
||||||
// UVs are within the atlas.
|
// UVs are within the atlas.
|
||||||
for e in [&a, &b] {
|
for e in [&a, &b] {
|
||||||
@@ -476,7 +483,7 @@ mod tests {
|
|||||||
fn full_atlas_returns_none() {
|
fn full_atlas_returns_none() {
|
||||||
// A tiny atlas cannot fit even one padded tile.
|
// A tiny atlas cannot fit even one padded tile.
|
||||||
let mut atlas = GlyphAtlas::new(4, 4);
|
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):
|
// Full path over a real, embedded stroke font (no system fonts needed):
|
||||||
@@ -484,7 +491,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn real_lff_glyph_bakes_and_packs() {
|
fn real_lff_glyph_bakes_and_packs() {
|
||||||
let mut atlas = GlyphAtlas::new(512, 512);
|
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!(atlas.is_dirty());
|
||||||
assert!(
|
assert!(
|
||||||
e.plane_max[0] > e.plane_min[0] && e.plane_max[1] > e.plane_min[1],
|
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);
|
assert!(e.advance > 0.0);
|
||||||
// Second lookup is cached: identical placement, no re-pack.
|
// 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_min, e2.uv_min);
|
||||||
assert_eq!(e.uv_max, e2.uv_max);
|
assert_eq!(e.uv_max, e2.uv_max);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user