Merge pull request #883 from ramox81/codex/complete-mtext
Complete multiline text creation, editing, properties, and layout
This commit is contained in:
commit
5b302fbd7b
18 changed files with 1591 additions and 123 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
|
|||
[[package]]
|
||||
name = "acadrust"
|
||||
version = "0.4.1"
|
||||
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc"
|
||||
source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043"
|
||||
dependencies = [
|
||||
"ahash 0.8.12",
|
||||
"anyhow",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
|
|||
rfd = "0.17"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
env_logger = "0.11"
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] }
|
||||
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] }
|
||||
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] }
|
||||
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
|
||||
flate2 = "1"
|
||||
|
|
@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa
|
|||
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
|
||||
|
||||
[patch."https://github.com/HakanSeven12/cadcodec.git"]
|
||||
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" }
|
||||
acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] }
|
||||
|
|
|
|||
|
|
@ -3716,10 +3716,11 @@ impl OpenCADStudio {
|
|||
handle,
|
||||
initial,
|
||||
height,
|
||||
template,
|
||||
} => {
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.open_mtext_editor(pos, handle, &initial, height);
|
||||
self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m));
|
||||
}
|
||||
CmdResult::SuspendForMTextInput {
|
||||
pos,
|
||||
|
|
@ -3732,7 +3733,7 @@ impl OpenCADStudio {
|
|||
self.restore_pre_cmd_tangent();
|
||||
self.command_mtext_input = true;
|
||||
self.pending_command_editor_text = None;
|
||||
self.open_mtext_editor(pos, None, &initial, height);
|
||||
self.open_mtext_editor(pos, None, &initial, height, None);
|
||||
}
|
||||
CmdResult::OpenTextEditor {
|
||||
pos,
|
||||
|
|
|
|||
|
|
@ -1312,7 +1312,13 @@ impl OpenCADStudio {
|
|||
&self.tabs[i].scene.document,
|
||||
)
|
||||
.height;
|
||||
let new_cmd = MTextCommand::with_height(height);
|
||||
let style = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.header
|
||||
.current_text_style_name
|
||||
.clone();
|
||||
let new_cmd = MTextCommand::with_defaults(height, style);
|
||||
self.command_line.push_info(&new_cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2659,6 +2659,31 @@ pub enum Message {
|
|||
MTextWidth(String),
|
||||
/// Toolbar character-spacing field changed.
|
||||
MTextCharSpace(String),
|
||||
/// Undo / redo editor text and inline-format operations.
|
||||
MTextUndo,
|
||||
MTextRedo,
|
||||
/// Convert the selected numerator/separator/denominator to a stacked run.
|
||||
MTextStack,
|
||||
/// Remove inline character formatting from the selection (or all text).
|
||||
MTextClearFormatting,
|
||||
/// Insert a predefined symbol or field token at the caret.
|
||||
MTextInsert(String),
|
||||
/// Per-object annotation flag edited from the text toolbar.
|
||||
MTextAnnotative(bool),
|
||||
/// Column layout controls.
|
||||
MTextColumnMode(String),
|
||||
MTextColumnCount(String),
|
||||
MTextColumnWidth(String),
|
||||
MTextColumnGutter(String),
|
||||
MTextColumnHeight(String),
|
||||
MTextColumnFlowReversed(bool),
|
||||
/// Paragraph indent/spacing controls.
|
||||
MTextParagraphNumber(mtext_editor::ParaNumber, String),
|
||||
MTextFindText(String),
|
||||
MTextReplaceText(String),
|
||||
MTextFindNext,
|
||||
MTextReplaceNext,
|
||||
MTextReplaceAll,
|
||||
/// Toolbar colour picker (same widget as Properties) — applies to the
|
||||
/// selection, or the whole text when nothing is selected.
|
||||
MTextColorChanged(AcadColor),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
use acadrust::entities::mtext::AttachmentPoint;
|
||||
use acadrust::entities::mtext_format::{
|
||||
parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment,
|
||||
MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType,
|
||||
MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType,
|
||||
};
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{EntityType, Handle, MText};
|
||||
|
|
@ -38,6 +38,15 @@ pub enum ParaAlign {
|
|||
Justify,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ParaNumber {
|
||||
FirstIndent,
|
||||
LeftIndent,
|
||||
RightIndent,
|
||||
SpaceBefore,
|
||||
SpaceAfter,
|
||||
}
|
||||
|
||||
/// `pick_list`-friendly wrapper for the 9 attachment points.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub struct JustifyChoice(pub AttachmentPoint);
|
||||
|
|
@ -107,6 +116,23 @@ pub struct MTextEditorState {
|
|||
/// it is NOT derived from the typed content, so adding characters wraps
|
||||
/// to the next line instead of stretching the box into one long line.
|
||||
pub rect_width: f64,
|
||||
pub rect_height: String,
|
||||
pub annotative: bool,
|
||||
pub column_type: i16,
|
||||
pub column_count: String,
|
||||
pub column_auto_height: bool,
|
||||
pub column_flow_reversed: bool,
|
||||
pub column_width: String,
|
||||
pub column_gutter: String,
|
||||
pub paragraph_first_indent: String,
|
||||
pub paragraph_left_indent: String,
|
||||
pub paragraph_right_indent: String,
|
||||
pub paragraph_space_before: String,
|
||||
pub paragraph_space_after: String,
|
||||
pub find_text: String,
|
||||
pub replace_text: String,
|
||||
undo: Vec<String>,
|
||||
redo: Vec<String>,
|
||||
/// `Some` when editing an existing entity; `None` for a fresh MText.
|
||||
pub editing: Option<Handle>,
|
||||
/// When true the panel shows the rendered preview; when false the raw
|
||||
|
|
@ -167,6 +193,23 @@ impl MTextEditorState {
|
|||
// Default box ~20 characters wide; overwritten with the entity's
|
||||
// own width when editing an existing MText.
|
||||
rect_width: (height * 20.0).max(1.0),
|
||||
rect_height: "0".to_string(),
|
||||
annotative: false,
|
||||
column_type: 0,
|
||||
column_count: "2".to_string(),
|
||||
column_auto_height: false,
|
||||
column_flow_reversed: false,
|
||||
column_width: (height * 10.0).to_string(),
|
||||
column_gutter: (height * 5.0).to_string(),
|
||||
paragraph_first_indent: "0".to_string(),
|
||||
paragraph_left_indent: "0".to_string(),
|
||||
paragraph_right_indent: "0".to_string(),
|
||||
paragraph_space_before: "0".to_string(),
|
||||
paragraph_space_after: "0".to_string(),
|
||||
find_text: String::new(),
|
||||
replace_text: String::new(),
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
editing,
|
||||
screen_anchor: iced::Point::new(60.0, 90.0),
|
||||
original: None,
|
||||
|
|
@ -274,8 +317,54 @@ impl MTextEditorState {
|
|||
mt.attachment_point = self.attachment;
|
||||
mt.line_spacing_factor = self.line_spacing as f64;
|
||||
mt.style = self.style.clone();
|
||||
mt.is_annotative = self.annotative;
|
||||
let rectangle_height = self.rect_height.trim().parse::<f64>().unwrap_or(0.0);
|
||||
mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height);
|
||||
mt.column_data.column_type = self.column_type;
|
||||
if self.column_type == 0 {
|
||||
mt.column_data.column_count = 0;
|
||||
mt.column_data.heights.clear();
|
||||
} else {
|
||||
mt.column_data.column_count = crate::entities::text_support::clamp_mtext_column_count(
|
||||
self.column_count
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.ok()
|
||||
.filter(|count| *count > 0)
|
||||
.unwrap_or(1),
|
||||
);
|
||||
mt.column_data.auto_height = self.column_auto_height;
|
||||
mt.column_data.flow_reversed = self.column_flow_reversed;
|
||||
if let Ok(width) = self.column_width.trim().parse::<f64>() {
|
||||
if width > 0.0 {
|
||||
mt.column_data.width = width;
|
||||
}
|
||||
}
|
||||
if let Ok(gutter) = self.column_gutter.trim().parse::<f64>() {
|
||||
if gutter >= 0.0 {
|
||||
mt.column_data.gutter = gutter;
|
||||
}
|
||||
}
|
||||
if !self.column_auto_height && rectangle_height > 0.0 {
|
||||
mt.column_data.heights = vec![
|
||||
rectangle_height;
|
||||
mt.column_data.column_count as usize
|
||||
];
|
||||
}
|
||||
}
|
||||
mt
|
||||
}
|
||||
|
||||
fn record_undo(&mut self) {
|
||||
let value = self.content.text();
|
||||
if self.undo.last().is_none_or(|last| last != &value) {
|
||||
self.undo.push(value);
|
||||
if self.undo.len() > 100 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
self.redo.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a numeric field, returning `Some(v)` only when it differs from the
|
||||
|
|
@ -595,6 +684,27 @@ fn vertical_caret_target(
|
|||
best.1
|
||||
}
|
||||
|
||||
fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) {
|
||||
state.attachment = m.attachment_point;
|
||||
state.line_spacing = m.line_spacing_factor as f32;
|
||||
state.rect_width = m.rectangle_width.max(0.0);
|
||||
state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string();
|
||||
state.annotative = m.is_annotative;
|
||||
state.column_type = m.column_data.column_type;
|
||||
state.column_count = crate::entities::text_support::clamp_mtext_column_count(
|
||||
m.column_data.column_count,
|
||||
)
|
||||
.to_string();
|
||||
state.column_auto_height = m.column_data.auto_height;
|
||||
state.column_flow_reversed = m.column_data.flow_reversed;
|
||||
state.column_width = m.column_data.width.max(0.0).to_string();
|
||||
state.column_gutter = m.column_data.gutter.max(0.0).to_string();
|
||||
if !m.style.trim().is_empty() {
|
||||
state.style = m.style.clone();
|
||||
}
|
||||
state.original = Some(m.clone());
|
||||
}
|
||||
|
||||
impl super::OpenCADStudio {
|
||||
/// Open the in-place editor for a new (`handle = None`) or existing MText.
|
||||
/// Open the rich MText editor for a new or existing MText / MultiLeader.
|
||||
|
|
@ -605,6 +715,7 @@ impl super::OpenCADStudio {
|
|||
handle: Option<Handle>,
|
||||
initial: &str,
|
||||
height: f64,
|
||||
template: Option<MText>,
|
||||
) {
|
||||
if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) {
|
||||
return;
|
||||
|
|
@ -614,20 +725,11 @@ impl super::OpenCADStudio {
|
|||
state.screen_anchor = p;
|
||||
}
|
||||
// Seed attachment / line-spacing / box width from the entity being edited.
|
||||
let has_template = template.is_some();
|
||||
if let Some(h) = handle {
|
||||
match self.tabs[self.active_tab].scene.document.get_entity(h) {
|
||||
Some(EntityType::MText(m)) => {
|
||||
state.attachment = m.attachment_point;
|
||||
state.line_spacing = m.line_spacing_factor as f32;
|
||||
if !m.style.trim().is_empty() {
|
||||
state.style = m.style.clone();
|
||||
}
|
||||
if m.rectangle_width > 0.0 {
|
||||
state.rect_width = m.rectangle_width;
|
||||
}
|
||||
// Keep the whole entity so `build_mtext` preserves the fields
|
||||
// the toolbar can't edit (columns, rotation, background…).
|
||||
state.original = Some(m.clone());
|
||||
seed_mtext_state(&mut state, m);
|
||||
}
|
||||
Some(EntityType::MultiLeader(ml)) => {
|
||||
state.line_spacing = ml.context.line_spacing_factor as f32;
|
||||
|
|
@ -637,6 +739,8 @@ impl super::OpenCADStudio {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if let Some(m) = template {
|
||||
seed_mtext_state(&mut state, &m);
|
||||
} else {
|
||||
// New MText inherits the document's current text style (STYLE),
|
||||
// not the "Standard" default. See #92.
|
||||
|
|
@ -659,7 +763,7 @@ impl super::OpenCADStudio {
|
|||
// The preview uses a fixed on-screen text size. Therefore the initial
|
||||
// wrap width is the drawing-unit span that exactly reaches the right
|
||||
// edge of the editor, placing both ruler and slider at their maximum.
|
||||
if handle.is_none() {
|
||||
if handle.is_none() && !has_template {
|
||||
let initial_width = self.mtext_editor.as_ref().map(|ed| {
|
||||
super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale()
|
||||
});
|
||||
|
|
@ -746,6 +850,322 @@ 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.
|
||||
pub(super) fn mtext_undo(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
if let Some(previous) = ed.undo.pop() {
|
||||
ed.redo.push(ed.content.text());
|
||||
ed.content = text_editor::Content::with_text(&previous);
|
||||
ed.caret = previous.chars().count();
|
||||
ed.sel = Some((ed.caret, ed.caret));
|
||||
ed.sel_anchor = ed.caret;
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_redo(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
if let Some(next) = ed.redo.pop() {
|
||||
ed.undo.push(ed.content.text());
|
||||
ed.content = text_editor::Content::with_text(&next);
|
||||
ed.caret = next.chars().count();
|
||||
ed.sel = Some((ed.caret, ed.caret));
|
||||
ed.sel_anchor = ed.caret;
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) {
|
||||
let Some(number) = value.trim().replace(',', ".").parse::<f64>().ok() else {
|
||||
return;
|
||||
};
|
||||
let valid = match field {
|
||||
"height" => number > 0.0,
|
||||
"oblique" => (-85.0..=85.0).contains(&number),
|
||||
"width" => number > 0.0,
|
||||
"tracking" => number.is_finite(),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return;
|
||||
}
|
||||
let has_selection = self
|
||||
.mtext_editor
|
||||
.as_ref()
|
||||
.and_then(|editor| editor.sel)
|
||||
.is_some_and(|(start, end)| start < end);
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
if has_selection {
|
||||
let (start, end) = ed.sel.unwrap();
|
||||
ed.record_undo();
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let end = end.min(cells.len());
|
||||
for cell in &mut cells[start.min(end)..end] {
|
||||
if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell {
|
||||
match field {
|
||||
"height" => props.height = Some(MTextScalar::Absolute(number)),
|
||||
"oblique" => props.oblique_angle = (number != 0.0).then_some(number),
|
||||
"width" => props.width_factor = (number != 1.0).then_some(number),
|
||||
"tracking" => props.tracking = (number != 0.0).then_some(number),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
ed.content = text_editor::Content::with_text(
|
||||
&cells_to_doc(¶0, &cells).to_mtext_string(),
|
||||
);
|
||||
ed.sel = Some((start, end));
|
||||
ed.caret = end;
|
||||
} else {
|
||||
match field {
|
||||
"height" => ed.height = value,
|
||||
"oblique" => ed.oblique = value,
|
||||
"width" => ed.width = value,
|
||||
"tracking" => ed.char_space = value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) {
|
||||
let Some(number) = value.trim().replace(',', ".").parse::<f64>().ok() else {
|
||||
return;
|
||||
};
|
||||
if !number.is_finite() {
|
||||
return;
|
||||
}
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.record_undo();
|
||||
match field {
|
||||
ParaNumber::FirstIndent => ed.paragraph_first_indent = value,
|
||||
ParaNumber::LeftIndent => ed.paragraph_left_indent = value,
|
||||
ParaNumber::RightIndent => ed.paragraph_right_indent = value,
|
||||
ParaNumber::SpaceBefore => ed.paragraph_space_before = value,
|
||||
ParaNumber::SpaceAfter => ed.paragraph_space_after = value,
|
||||
}
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let cells = doc_to_cells(&ed.doc);
|
||||
let (start, end) = ed
|
||||
.sel
|
||||
.filter(|(start, end)| start < end)
|
||||
.unwrap_or((ed.caret, ed.caret));
|
||||
let paragraph_of = |index: usize| {
|
||||
cells
|
||||
.iter()
|
||||
.take(index.min(cells.len()))
|
||||
.filter(|cell| matches!(cell, Cell::Break(..)))
|
||||
.count()
|
||||
};
|
||||
let first = paragraph_of(start);
|
||||
let last = paragraph_of(end.saturating_sub((end > start) as usize));
|
||||
let mut doc = cells_to_doc(¶0, &cells);
|
||||
for paragraph in doc
|
||||
.paragraphs
|
||||
.iter_mut()
|
||||
.skip(first)
|
||||
.take(last.saturating_sub(first) + 1)
|
||||
{
|
||||
let optional = (number != 0.0).then_some(number);
|
||||
match field {
|
||||
ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional,
|
||||
ParaNumber::LeftIndent => paragraph.properties.left_margin = optional,
|
||||
ParaNumber::RightIndent => paragraph.properties.right_margin = optional,
|
||||
ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional,
|
||||
ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional,
|
||||
}
|
||||
}
|
||||
ed.content = text_editor::Content::with_text(&doc.to_mtext_string());
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_stack_selection(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else {
|
||||
return;
|
||||
};
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let end = end.min(cells.len());
|
||||
let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end])
|
||||
.to_plain_text();
|
||||
let Some((split, separator)) = plain
|
||||
.char_indices()
|
||||
.find(|(_, ch)| matches!(ch, '/' | '#' | '^'))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let separator_len = separator.len_utf8();
|
||||
let data = StackingData {
|
||||
numerator: plain[..split].to_string(),
|
||||
denominator: plain[split + separator_len..].to_string(),
|
||||
stacking_type: StackingType::from_char(separator),
|
||||
};
|
||||
if data.numerator.is_empty() || data.denominator.is_empty() {
|
||||
return;
|
||||
}
|
||||
let props = cells[start..end]
|
||||
.iter()
|
||||
.find_map(|cell| match cell {
|
||||
Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()),
|
||||
Cell::Break(..) => None,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ed.record_undo();
|
||||
let replacement = flatten_stack(&data)
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(index, _)| Cell::Stack {
|
||||
data: data.clone(),
|
||||
props: props.clone(),
|
||||
head: index == 0,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let replacement_len = replacement.len();
|
||||
cells.splice(start..end, replacement);
|
||||
let new_end = start + replacement_len;
|
||||
ed.content = text_editor::Content::with_text(
|
||||
&cells_to_doc(¶0, &cells).to_mtext_string(),
|
||||
);
|
||||
ed.sel = Some((start, new_end));
|
||||
ed.caret = new_end;
|
||||
ed.sel_anchor = start;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_clear_formatting(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let (start, end) = ed
|
||||
.sel
|
||||
.filter(|(start, end)| start < end)
|
||||
.unwrap_or((0, cells.len()));
|
||||
let end = end.min(cells.len());
|
||||
ed.record_undo();
|
||||
for cell in &mut cells[start.min(end)..end] {
|
||||
if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell {
|
||||
*props = SpanProperties::default();
|
||||
}
|
||||
}
|
||||
ed.content = text_editor::Content::with_text(
|
||||
&cells_to_doc(¶0, &cells).to_mtext_string(),
|
||||
);
|
||||
ed.sel = Some((start, end));
|
||||
ed.caret = end;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_find_next(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let needle: Vec<char> = ed.find_text.chars().collect();
|
||||
if needle.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cells = doc_to_cells(&ed.doc);
|
||||
let chars: Vec<Option<char>> = cells
|
||||
.iter()
|
||||
.map(|cell| match cell {
|
||||
Cell::Char(ch, _) => Some(*ch),
|
||||
Cell::Break(..) => Some('\n'),
|
||||
Cell::Stack { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
if needle.len() > chars.len() {
|
||||
return;
|
||||
}
|
||||
let start = ed.caret.min(chars.len());
|
||||
let last_start = chars.len() - needle.len();
|
||||
let found = (start..=last_start)
|
||||
.chain(0..start.min(last_start + 1))
|
||||
.find(|index| {
|
||||
chars[*index..*index + needle.len()]
|
||||
.iter()
|
||||
.zip(&needle)
|
||||
.all(|(actual, expected)| actual == &Some(*expected))
|
||||
});
|
||||
if let Some(found) = found {
|
||||
ed.sel_anchor = found;
|
||||
ed.sel = Some((found, found + needle.len()));
|
||||
ed.caret = found + needle.len();
|
||||
ed.caret_blink_on = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mtext_replace_next(&mut self) {
|
||||
let replacement = self
|
||||
.mtext_editor
|
||||
.as_ref()
|
||||
.map(|editor| editor.replace_text.clone())
|
||||
.unwrap_or_default();
|
||||
let matches = self.mtext_editor.as_ref().is_some_and(|editor| {
|
||||
let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else {
|
||||
return false;
|
||||
};
|
||||
let cells = doc_to_cells(&editor.doc);
|
||||
cells_to_doc(
|
||||
&ParagraphProperties::default(),
|
||||
&cells[start.min(cells.len())..end.min(cells.len())],
|
||||
)
|
||||
.to_plain_text()
|
||||
== editor.find_text
|
||||
});
|
||||
if matches {
|
||||
self.mtext_type(&replacement);
|
||||
}
|
||||
self.mtext_find_next();
|
||||
}
|
||||
|
||||
pub(super) fn mtext_replace_all(&mut self) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
let needle: Vec<char> = ed.find_text.chars().collect();
|
||||
if needle.is_empty() {
|
||||
return;
|
||||
}
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let replacement = ed.replace_text.clone();
|
||||
let mut matches = Vec::new();
|
||||
let mut index = 0usize;
|
||||
while index + needle.len() <= cells.len() {
|
||||
let found = cells[index..index + needle.len()]
|
||||
.iter()
|
||||
.zip(&needle)
|
||||
.all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected));
|
||||
if found {
|
||||
matches.push(index);
|
||||
index += needle.len();
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
if matches.is_empty() {
|
||||
return;
|
||||
}
|
||||
ed.record_undo();
|
||||
for start in matches.into_iter().rev() {
|
||||
let props = insert_props(&cells, start);
|
||||
let paragraph = para_props_at(¶0, &cells, start);
|
||||
let replacement_cells = str_to_cells(&replacement, &props, ¶graph);
|
||||
cells.splice(start..start + needle.len(), replacement_cells);
|
||||
}
|
||||
ed.content = text_editor::Content::with_text(
|
||||
&cells_to_doc(¶0, &cells).to_mtext_string(),
|
||||
);
|
||||
ed.caret = cells.len();
|
||||
ed.sel = Some((ed.caret, ed.caret));
|
||||
ed.sel_anchor = ed.caret;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
}
|
||||
|
||||
/// Toggle a character format over the preview selection, on the structured
|
||||
/// span properties. The stroke-font renderer has no true bold, so Bold
|
||||
/// switches the run to the heavier "Gothic" face; Italic applies a 15°
|
||||
|
|
@ -798,6 +1218,7 @@ impl super::OpenCADStudio {
|
|||
if a >= b {
|
||||
return;
|
||||
}
|
||||
ed.record_undo();
|
||||
match kind {
|
||||
MTextFmt::Underline => {
|
||||
let on = !all_have(&cells[a..b], |p| p.underline());
|
||||
|
|
@ -882,6 +1303,7 @@ impl super::OpenCADStudio {
|
|||
/// Set the alignment of every paragraph the selection (or caret) touches.
|
||||
pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.record_undo();
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let cells = doc_to_cells(&ed.doc);
|
||||
let (a, b) = ed
|
||||
|
|
@ -933,6 +1355,7 @@ impl super::OpenCADStudio {
|
|||
s
|
||||
};
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.record_undo();
|
||||
// Edit the structured document as a flat cell list, then serialize
|
||||
// it back into the raw content the preview/commit still read.
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
|
|
@ -962,6 +1385,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() {
|
||||
ed.record_undo();
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let count = cells.len();
|
||||
|
|
@ -985,6 +1409,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() {
|
||||
ed.record_undo();
|
||||
let para0 = doc_para0(&ed.doc);
|
||||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let count = cells.len();
|
||||
|
|
@ -1104,6 +1529,7 @@ impl super::OpenCADStudio {
|
|||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let b = b.min(cells.len());
|
||||
if a < b {
|
||||
ed.record_undo();
|
||||
for c in &mut cells[a..b] {
|
||||
if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c {
|
||||
let (bold, italic) = p
|
||||
|
|
@ -1155,6 +1581,7 @@ impl super::OpenCADStudio {
|
|||
let mut cells = doc_to_cells(&ed.doc);
|
||||
let b = b.min(cells.len());
|
||||
if a < b {
|
||||
ed.record_undo();
|
||||
for c in &mut cells[a..b] {
|
||||
if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c {
|
||||
p.color = mcolor.clone();
|
||||
|
|
@ -1217,7 +1644,8 @@ impl super::OpenCADStudio {
|
|||
}
|
||||
let body_empty = ed.content.text().trim().is_empty();
|
||||
let mut mt = ed.build_mtext();
|
||||
let annotative = ed.editing.is_none()
|
||||
let annotative = mt.is_annotative
|
||||
|| ed.editing.is_none()
|
||||
&& crate::scene::annotative::text_style_is_annotative(
|
||||
&self.tabs[i].scene.document,
|
||||
&mt.style,
|
||||
|
|
@ -1240,9 +1668,12 @@ impl super::OpenCADStudio {
|
|||
Some(EntityType::MText(t)) => {
|
||||
t.value = mt.value;
|
||||
t.height = mt.height;
|
||||
t.style = mt.style;
|
||||
t.attachment_point = mt.attachment_point;
|
||||
t.line_spacing_factor = mt.line_spacing_factor;
|
||||
t.rectangle_width = mt.rectangle_width;
|
||||
t.rectangle_height = mt.rectangle_height;
|
||||
t.column_data = mt.column_data;
|
||||
}
|
||||
Some(EntityType::MultiLeader(ml)) => {
|
||||
ml.context.text_string = mt.value;
|
||||
|
|
@ -1255,9 +1686,25 @@ impl super::OpenCADStudio {
|
|||
_ => {}
|
||||
}
|
||||
// Keep the displayed annotation context in sync.
|
||||
if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) {
|
||||
crate::scene::annotative::set_entity_annotative(
|
||||
&mut self.tabs[i].scene.document,
|
||||
h,
|
||||
annotative,
|
||||
);
|
||||
if annotative {
|
||||
if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() {
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
&mut self.tabs[i].scene.document,
|
||||
h,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
self.tabs[i].scene.document.get_entity(h),
|
||||
Some(EntityType::MultiLeader(_))
|
||||
Some(EntityType::MText(_) | EntityType::MultiLeader(_))
|
||||
) {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
|
|
@ -1283,7 +1730,6 @@ impl super::OpenCADStudio {
|
|||
position.y,
|
||||
position.z,
|
||||
);
|
||||
mt.rotation = 0.0;
|
||||
self.push_undo_snapshot(i, "MTEXT");
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
|
||||
if annotative {
|
||||
|
|
@ -1321,7 +1767,8 @@ impl super::OpenCADStudio {
|
|||
),
|
||||
None => return,
|
||||
};
|
||||
let annotative = editing.is_none()
|
||||
let annotative = mt.is_annotative
|
||||
|| editing.is_none()
|
||||
&& crate::scene::annotative::text_style_is_annotative(
|
||||
&self.tabs[i].scene.document,
|
||||
&mt.style,
|
||||
|
|
@ -1341,9 +1788,12 @@ impl super::OpenCADStudio {
|
|||
Some(EntityType::MText(t)) => {
|
||||
t.value = mt.value;
|
||||
t.height = mt.height;
|
||||
t.style = mt.style;
|
||||
t.attachment_point = mt.attachment_point;
|
||||
t.line_spacing_factor = mt.line_spacing_factor;
|
||||
t.rectangle_width = mt.rectangle_width;
|
||||
t.rectangle_height = mt.rectangle_height;
|
||||
t.column_data = mt.column_data;
|
||||
}
|
||||
Some(EntityType::MultiLeader(ml)) => {
|
||||
ml.context.text_string = mt.value;
|
||||
|
|
@ -1355,9 +1805,25 @@ impl super::OpenCADStudio {
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) {
|
||||
crate::scene::annotative::set_entity_annotative(
|
||||
&mut self.tabs[i].scene.document,
|
||||
h,
|
||||
annotative,
|
||||
);
|
||||
if annotative {
|
||||
if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() {
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
&mut self.tabs[i].scene.document,
|
||||
h,
|
||||
scale,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
self.tabs[i].scene.document.get_entity(h),
|
||||
Some(EntityType::MultiLeader(_))
|
||||
Some(EntityType::MText(_) | EntityType::MultiLeader(_))
|
||||
) {
|
||||
self.tabs[i]
|
||||
.scene
|
||||
|
|
@ -1383,7 +1849,6 @@ impl super::OpenCADStudio {
|
|||
position.y,
|
||||
position.z,
|
||||
);
|
||||
mt.rotation = 0.0;
|
||||
self.push_undo_snapshot(i, "MTEXT");
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
|
||||
self.tabs[i].dirty = true;
|
||||
|
|
@ -1400,6 +1865,11 @@ impl super::OpenCADStudio {
|
|||
// Bind the editor to the fresh entity so the next Apply updates it.
|
||||
if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) {
|
||||
ed.editing = Some(h);
|
||||
if let Some(EntityType::MText(mtext)) =
|
||||
self.tabs[i].scene.document.get_entity(h)
|
||||
{
|
||||
ed.original = Some(mtext.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
self.refresh_properties();
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ impl super::OpenCADStudio {
|
|||
};
|
||||
|
||||
if field.is_rich() {
|
||||
self.open_mtext_editor(pos, Some(target), &value, height);
|
||||
self.open_mtext_editor(pos, Some(target), &value, height, None);
|
||||
self.unfocus_widgets()
|
||||
} else {
|
||||
self.open_text_inline(pos, Some(target), &value, height, field, None);
|
||||
|
|
|
|||
|
|
@ -3904,10 +3904,7 @@ impl OpenCADStudio {
|
|||
Task::none()
|
||||
}
|
||||
Message::MTextHeight(s) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.height = s;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
self.mtext_apply_span_number("height", s);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextRectWidth(width) => {
|
||||
|
|
@ -3939,26 +3936,128 @@ impl OpenCADStudio {
|
|||
Task::none()
|
||||
}
|
||||
Message::MTextOblique(s) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.oblique = s;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
self.mtext_apply_span_number("oblique", s);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextWidth(s) => {
|
||||
self.mtext_apply_span_number("width", s);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextCharSpace(s) => {
|
||||
self.mtext_apply_span_number("tracking", s);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextUndo => {
|
||||
self.mtext_undo();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextRedo => {
|
||||
self.mtext_redo();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextStack => {
|
||||
self.mtext_stack_selection();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextClearFormatting => {
|
||||
self.mtext_clear_formatting();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextInsert(value) => {
|
||||
self.mtext_type(&value);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextAnnotative(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.width = s;
|
||||
ed.annotative = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextCharSpace(s) => {
|
||||
Message::MTextColumnMode(mode) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.char_space = s;
|
||||
match mode.as_str() {
|
||||
"Static" => {
|
||||
ed.column_type = 1;
|
||||
ed.column_auto_height = false;
|
||||
}
|
||||
"Dynamic auto" => {
|
||||
ed.column_type = 2;
|
||||
ed.column_auto_height = true;
|
||||
}
|
||||
"Dynamic manual" => {
|
||||
ed.column_type = 2;
|
||||
ed.column_auto_height = false;
|
||||
}
|
||||
_ => ed.column_type = 0,
|
||||
}
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextColumnCount(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.column_count = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextColumnWidth(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.column_width = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextColumnGutter(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.column_gutter = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextColumnHeight(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.rect_height = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextColumnFlowReversed(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.column_flow_reversed = value;
|
||||
}
|
||||
self.rebuild_mtext_preview();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextParagraphNumber(field, value) => {
|
||||
self.mtext_apply_paragraph_number(field, value);
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextFindText(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.find_text = value;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextReplaceText(value) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.replace_text = value;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextFindNext => {
|
||||
self.mtext_find_next();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextReplaceNext => {
|
||||
self.mtext_replace_next();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextReplaceAll => {
|
||||
self.mtext_replace_all();
|
||||
Task::none()
|
||||
}
|
||||
Message::MTextJustify(ap) => {
|
||||
if let Some(ed) = self.mtext_editor.as_mut() {
|
||||
ed.attachment = ap;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ use iced::advanced::renderer;
|
|||
use iced::advanced::widget;
|
||||
use iced::advanced::{Layout, Shell, Widget};
|
||||
use iced::widget::{
|
||||
button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space,
|
||||
button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input,
|
||||
Space,
|
||||
};
|
||||
use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector};
|
||||
use crate::t;
|
||||
|
|
@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>(
|
|||
iced::widget::Space::new().width(Fill).height(Fill),
|
||||
t!("Text Editor"),
|
||||
content,
|
||||
Message::MTextCancel,
|
||||
Message::MTextOk,
|
||||
modal_offset,
|
||||
crate::ui::modal::ModalOptions::STANDARD,
|
||||
)
|
||||
|
|
@ -450,6 +451,9 @@ fn mtext_editor_content<'a>(
|
|||
.on_select(Message::MTextStyle)
|
||||
.text_size(11)
|
||||
.width(iced::Length::Fixed(96.0));
|
||||
let annotative = checkbox(ed.annotative)
|
||||
.on_toggle(Message::MTextAnnotative)
|
||||
.size(14);
|
||||
let font_sel = if ed.font.trim().is_empty() {
|
||||
"[Style default]".to_string()
|
||||
} else {
|
||||
|
|
@ -487,6 +491,9 @@ fn mtext_editor_content<'a>(
|
|||
|
||||
let row1 = row![
|
||||
style_pl,
|
||||
row![annotative, text(t!("Annotative")).size(11)]
|
||||
.spacing(3)
|
||||
.align_y(iced::Alignment::Center),
|
||||
font_pl,
|
||||
small_input("2.5", &ed.height, Message::MTextHeight, 64.0),
|
||||
iced::widget::Space::new().width(6),
|
||||
|
|
@ -535,6 +542,14 @@ fn mtext_editor_content<'a>(
|
|||
.text_size(11)
|
||||
.width(iced::Length::Fixed(112.0));
|
||||
let row2 = row![
|
||||
button(lbl("↶"))
|
||||
.on_press(Message::MTextUndo)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("↷"))
|
||||
.on_press(Message::MTextRedo)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
lbl("O"),
|
||||
small_input("0", &ed.oblique, Message::MTextOblique, 48.0),
|
||||
lbl("W"),
|
||||
|
|
@ -573,6 +588,144 @@ fn mtext_editor_content<'a>(
|
|||
.on_press(Message::MTextLineSpacing(2.0))
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("Stack"))
|
||||
.on_press(Message::MTextStack)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("Clear"))
|
||||
.on_press(Message::MTextClearFormatting)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("°"))
|
||||
.on_press(Message::MTextInsert("°".to_string()))
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("±"))
|
||||
.on_press(Message::MTextInsert("±".to_string()))
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("⌀"))
|
||||
.on_press(Message::MTextInsert("⌀".to_string()))
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(width);
|
||||
|
||||
let column_mode = match (ed.column_type, ed.column_auto_height) {
|
||||
(1, _) => "Static",
|
||||
(2, true) => "Dynamic auto",
|
||||
(2, false) => "Dynamic manual",
|
||||
_ => "No columns",
|
||||
}
|
||||
.to_string();
|
||||
let column_picker = iced::widget::pick_list(
|
||||
Some(column_mode),
|
||||
["No columns", "Static", "Dynamic auto", "Dynamic manual"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>(),
|
||||
|value| value.to_string(),
|
||||
)
|
||||
.on_select(Message::MTextColumnMode)
|
||||
.text_size(11)
|
||||
.width(iced::Length::Fixed(120.0));
|
||||
let reverse = checkbox(ed.column_flow_reversed)
|
||||
.on_toggle(Message::MTextColumnFlowReversed)
|
||||
.size(14);
|
||||
let row3 = row![
|
||||
lbl("Columns"),
|
||||
column_picker,
|
||||
lbl("Count"),
|
||||
small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0),
|
||||
lbl("Height"),
|
||||
text_input("0", &ed.rect_height)
|
||||
.on_input(Message::MTextColumnHeight)
|
||||
.width(iced::Length::Fixed(58.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
lbl("Width"),
|
||||
small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0),
|
||||
lbl("Gutter"),
|
||||
small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0),
|
||||
reverse,
|
||||
lbl("Reverse"),
|
||||
iced::widget::Space::new().width(8),
|
||||
lbl("First"),
|
||||
text_input("0", &ed.paragraph_first_indent)
|
||||
.on_input(|value| Message::MTextParagraphNumber(
|
||||
super::super::mtext_editor::ParaNumber::FirstIndent,
|
||||
value,
|
||||
))
|
||||
.width(iced::Length::Fixed(48.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
lbl("Left"),
|
||||
text_input("0", &ed.paragraph_left_indent)
|
||||
.on_input(|value| Message::MTextParagraphNumber(
|
||||
super::super::mtext_editor::ParaNumber::LeftIndent,
|
||||
value,
|
||||
))
|
||||
.width(iced::Length::Fixed(48.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
lbl("Right"),
|
||||
text_input("0", &ed.paragraph_right_indent)
|
||||
.on_input(|value| Message::MTextParagraphNumber(
|
||||
super::super::mtext_editor::ParaNumber::RightIndent,
|
||||
value,
|
||||
))
|
||||
.width(iced::Length::Fixed(48.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
lbl("Before"),
|
||||
text_input("0", &ed.paragraph_space_before)
|
||||
.on_input(|value| Message::MTextParagraphNumber(
|
||||
super::super::mtext_editor::ParaNumber::SpaceBefore,
|
||||
value,
|
||||
))
|
||||
.width(iced::Length::Fixed(48.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
lbl("After"),
|
||||
text_input("0", &ed.paragraph_space_after)
|
||||
.on_input(|value| Message::MTextParagraphNumber(
|
||||
super::super::mtext_editor::ParaNumber::SpaceAfter,
|
||||
value,
|
||||
))
|
||||
.width(iced::Length::Fixed(48.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
.width(width);
|
||||
let row4 = row![
|
||||
lbl("Find"),
|
||||
text_input("Find", &ed.find_text)
|
||||
.on_input(Message::MTextFindText)
|
||||
.width(iced::Length::Fixed(140.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
button(lbl("Next"))
|
||||
.on_press(Message::MTextFindNext)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
lbl("Replace"),
|
||||
text_input("Replace", &ed.replace_text)
|
||||
.on_input(Message::MTextReplaceText)
|
||||
.width(iced::Length::Fixed(140.0))
|
||||
.padding(3)
|
||||
.size(12),
|
||||
button(lbl("Replace"))
|
||||
.on_press(Message::MTextReplaceNext)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
button(lbl("Replace All"))
|
||||
.on_press(Message::MTextReplaceAll)
|
||||
.padding(3)
|
||||
.style(btn_style),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center)
|
||||
|
|
@ -710,13 +863,13 @@ fn mtext_editor_content<'a>(
|
|||
.into()
|
||||
};
|
||||
|
||||
// ── Top action bar: Apply on the right, exactly like the style managers'
|
||||
// toolbar strip. Closing (the modal ✕) without applying discards the
|
||||
// buffer, so there is no separate Cancel.
|
||||
// ── Top action bar: Apply keeps editing; Close saves and exits. Escape
|
||||
// remains the explicit discard path.
|
||||
let action_bar = container(
|
||||
row![
|
||||
iced::widget::Space::new().width(width),
|
||||
crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true),
|
||||
crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true),
|
||||
]
|
||||
.align_y(iced::Alignment::Center),
|
||||
)
|
||||
|
|
@ -730,7 +883,7 @@ fn mtext_editor_content<'a>(
|
|||
.padding([5, 8]);
|
||||
|
||||
container(
|
||||
column![action_bar, row1, row2, width_slider, body]
|
||||
column![action_bar, row1, row2, row3, row4, width_slider, body]
|
||||
.spacing(6)
|
||||
.width(width)
|
||||
.height(height),
|
||||
|
|
|
|||
|
|
@ -1427,6 +1427,11 @@ pub enum CmdResult {
|
|||
handle: Option<Handle>,
|
||||
initial: String,
|
||||
height: f64,
|
||||
/// Optional entity defaults collected by the interactive MTEXT
|
||||
/// command (boundary, rotation, attachment, spacing and columns).
|
||||
/// Existing-entity edits leave this as `None` and load the document
|
||||
/// entity instead.
|
||||
template: Option<Box<acadrust::MText>>,
|
||||
},
|
||||
/// Collect rich text without creating an MText entity.
|
||||
SuspendForMTextInput {
|
||||
|
|
|
|||
|
|
@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
|
|||
oblique_angle,
|
||||
is_backward: width_factor < 0.0,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
};
|
||||
let layout = layout_mtext(&MTextRenderOpts {
|
||||
// Not an MTEXT: text in a fixed box, never columnar.
|
||||
|
|
@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ use crate::entities::common::{
|
|||
edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip,
|
||||
};
|
||||
use crate::entities::text_support::{
|
||||
layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor,
|
||||
clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns,
|
||||
MTextRenderOpts, MTextVAnchor,
|
||||
};
|
||||
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
|
||||
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
|
||||
|
|
@ -24,10 +25,19 @@ fn columns_of(t: &MText) -> MTextColumns {
|
|||
if c.column_type == 0 {
|
||||
return MTextColumns::default();
|
||||
}
|
||||
let count = clamp_mtext_column_count(c.column_count) as usize;
|
||||
MTextColumns {
|
||||
count: c.column_count.max(0) as usize,
|
||||
count,
|
||||
width: c.width as f32,
|
||||
gutter: c.gutter as f32,
|
||||
flow_reversed: c.flow_reversed,
|
||||
auto_height: c.auto_height,
|
||||
heights: c
|
||||
.heights
|
||||
.iter()
|
||||
.take(count)
|
||||
.map(|height| *height as f32)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -120,7 +130,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox>
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: t.line_spacing_factor as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
|
||||
exact_line_spacing: matches!(
|
||||
t.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|
||||
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
|
||||
&& resolved_style.is_vertical,
|
||||
want_glyph_boxes: true,
|
||||
columns: columns_of(t),
|
||||
});
|
||||
|
|
@ -169,7 +186,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity {
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: t.line_spacing_factor as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
|
||||
exact_line_spacing: matches!(
|
||||
t.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32,
|
||||
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom)
|
||||
|| matches!(t.drawing_direction, DrawingDirection::ByStyle)
|
||||
&& resolved_style.is_vertical,
|
||||
want_glyph_boxes: false,
|
||||
columns: columns_of(t),
|
||||
});
|
||||
|
|
@ -231,8 +255,43 @@ fn grips(t: &MText) -> Vec<GripDef> {
|
|||
t.insertion_point.z,
|
||||
);
|
||||
let (dir, k) = width_grip_axis(t);
|
||||
let width_grip = p + dir * (k * t.rectangle_width.max(0.0));
|
||||
vec![square_grip(0, p), triangle_grip(1, width_grip)]
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
let columns_active =
|
||||
t.column_data.column_type != 0 && column_count > 1 && t.column_data.width > 0.0;
|
||||
let block_width = if columns_active {
|
||||
t.column_data.width * column_count as f64
|
||||
+ t.column_data.gutter * (column_count - 1) as f64
|
||||
} else {
|
||||
t.rectangle_width.max(0.0)
|
||||
};
|
||||
let width_grip = p + dir * (k * block_width);
|
||||
let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)];
|
||||
|
||||
let height = t
|
||||
.rectangle_height
|
||||
.or_else(|| t.column_data.heights.first().copied())
|
||||
.unwrap_or(0.0);
|
||||
if height > 0.0 && !(columns_active && t.column_data.auto_height) {
|
||||
let (sin, cos) = t.rotation.sin_cos();
|
||||
let down = glam::DVec3::new(sin, -cos, 0.0);
|
||||
let (_, vertical) = attach_anchors(t);
|
||||
let factor = match vertical {
|
||||
MTextVAnchor::Top
|
||||
| MTextVAnchor::MiddleOfTopLine
|
||||
| MTextVAnchor::BottomOfTopLine => 1.0,
|
||||
MTextVAnchor::Middle => 0.5,
|
||||
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
|
||||
};
|
||||
result.push(triangle_grip(2, p + down * (factor * height)));
|
||||
}
|
||||
if columns_active {
|
||||
result.push(triangle_grip(3, p + dir * (k * t.column_data.width)));
|
||||
result.push(triangle_grip(
|
||||
4,
|
||||
p + dir * (k * (t.column_data.width + t.column_data.gutter)),
|
||||
));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str {
|
||||
|
|
@ -248,10 +307,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
// by the line-spacing factor.
|
||||
let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor;
|
||||
let text_frame_on = (t.background_fill_flags & 0x10) != 0;
|
||||
// Defined width is only live without columns; defined height is live for
|
||||
// static columns or manual-height dynamic columns, grayed otherwise.
|
||||
// Defined width is reported by Properties but is not edited there. Defined
|
||||
// height remains live for static columns or manual-height dynamic columns.
|
||||
let col_type = t.column_data.column_type;
|
||||
let width_editable = col_type == 0;
|
||||
let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height);
|
||||
vec![
|
||||
PropSection {
|
||||
|
|
@ -353,7 +411,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
.collect(),
|
||||
},
|
||||
},
|
||||
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable),
|
||||
num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false),
|
||||
num_row(
|
||||
t!("Defined height").as_ref(),
|
||||
"rect_h",
|
||||
|
|
@ -371,20 +429,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
|
|||
.collect(),
|
||||
},
|
||||
},
|
||||
// Count / width / gutter are live only when columns are on.
|
||||
num_row(
|
||||
t!("Column count").as_ref(),
|
||||
"col_count",
|
||||
t.column_data.column_count as f64,
|
||||
col_type != 0,
|
||||
),
|
||||
num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0),
|
||||
num_row(
|
||||
t!("Column gutter").as_ref(),
|
||||
"col_gutter",
|
||||
t.column_data.gutter,
|
||||
col_type != 0,
|
||||
),
|
||||
Property {
|
||||
label: t!("Text frame").into_owned(),
|
||||
field: "text_frame",
|
||||
|
|
@ -502,21 +546,20 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) {
|
|||
"ins_y" => t.insertion_point.y = v,
|
||||
"ins_z" => t.insertion_point.z = v,
|
||||
"height" if v > 0.0 => t.height = v,
|
||||
"rect_w" if v > 0.0 => t.rectangle_width = v,
|
||||
"rect_h" if v > 0.0 => t.rectangle_height = Some(v),
|
||||
"rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v),
|
||||
"rotation" => t.rotation = v.to_radians(),
|
||||
"line_spacing" if v > 0.0 => t.line_spacing_factor = v,
|
||||
"line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v,
|
||||
// Editing the absolute distance back-solves the line-spacing factor so
|
||||
// the two stay consistent (distance = height × 5/3 × factor).
|
||||
"line_space_distance" if v > 0.0 => {
|
||||
let denom = t.height * 1.666_666_666_666_667;
|
||||
if denom > 0.0 {
|
||||
t.line_spacing_factor = v / denom;
|
||||
let factor = v / denom;
|
||||
if (0.25..=4.0).contains(&factor) {
|
||||
t.line_spacing_factor = factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
"col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32,
|
||||
"col_width" if v > 0.0 => t.column_data.width = v,
|
||||
"col_gutter" if v >= 0.0 => t.column_data.gutter = v,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -541,7 +584,60 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) {
|
|||
let dx = p.x as f64 - t.insertion_point.x;
|
||||
let dy = p.y as f64 - t.insertion_point.y;
|
||||
let projected = dx * dir.x + dy * dir.y;
|
||||
t.rectangle_width = (projected / k).max(0.01);
|
||||
let width = (projected / k).max(0.0);
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
if t.column_data.column_type != 0 && column_count > 1 {
|
||||
t.column_data.column_count = column_count;
|
||||
let gaps = (column_count - 1) as f64;
|
||||
t.column_data.width =
|
||||
((width - t.column_data.gutter * gaps) / column_count as f64)
|
||||
.max(0.01);
|
||||
} else {
|
||||
t.rectangle_width = width;
|
||||
}
|
||||
}
|
||||
(2, GripApply::Absolute(p)) => {
|
||||
let (sin, cos) = t.rotation.sin_cos();
|
||||
let down = glam::DVec3::new(sin, -cos, 0.0);
|
||||
let delta = glam::DVec3::new(
|
||||
p.x as f64 - t.insertion_point.x,
|
||||
p.y as f64 - t.insertion_point.y,
|
||||
p.z as f64 - t.insertion_point.z,
|
||||
);
|
||||
let (_, vertical) = attach_anchors(t);
|
||||
let factor = match vertical {
|
||||
MTextVAnchor::Top
|
||||
| MTextVAnchor::MiddleOfTopLine
|
||||
| MTextVAnchor::BottomOfTopLine => 1.0,
|
||||
MTextVAnchor::Middle => 0.5,
|
||||
MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0,
|
||||
};
|
||||
let height = (delta.dot(down) / factor).max(0.01);
|
||||
t.rectangle_height = Some(height);
|
||||
if t.column_data.column_type != 0 && !t.column_data.auto_height {
|
||||
let column_count = clamp_mtext_column_count(t.column_data.column_count);
|
||||
t.column_data.column_count = column_count;
|
||||
t.column_data.heights = vec![height; column_count as usize];
|
||||
}
|
||||
}
|
||||
(3, GripApply::Absolute(p)) => {
|
||||
let (dir, k) = width_grip_axis(t);
|
||||
let delta = glam::DVec3::new(
|
||||
p.x as f64 - t.insertion_point.x,
|
||||
p.y as f64 - t.insertion_point.y,
|
||||
0.0,
|
||||
);
|
||||
t.column_data.width = (delta.dot(dir) / k).max(0.01);
|
||||
}
|
||||
(4, GripApply::Absolute(p)) => {
|
||||
let (dir, k) = width_grip_axis(t);
|
||||
let delta = glam::DVec3::new(
|
||||
p.x as f64 - t.insertion_point.x,
|
||||
p.y as f64 - t.insertion_point.y,
|
||||
0.0,
|
||||
);
|
||||
t.column_data.gutter =
|
||||
(delta.dot(dir) / k - t.column_data.width).max(0.0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<Rende
|
|||
},
|
||||
),
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
}))
|
||||
|
|
@ -412,6 +417,7 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
|
|||
oblique_angle: 0.0,
|
||||
is_backward: false,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
};
|
||||
let layout = layout_mtext(&MTextRenderOpts {
|
||||
columns: Default::default(),
|
||||
|
|
@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) {
|
|||
attach_h_anchor: 0.0,
|
||||
v_anchor: MTextVAnchor::Top,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: vertical,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader {
|
|||
attach_h_anchor: h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: ctx.line_spacing_factor as f32,
|
||||
exact_line_spacing: matches!(
|
||||
ctx.line_spacing_style,
|
||||
acadrust::entities::LineSpacingStyle::Exactly
|
||||
),
|
||||
rectangle_height: 0.0,
|
||||
// Vertical flow (top-to-bottom) is stored per-context.
|
||||
vertical_text: matches!(
|
||||
ctx.text_flow_direction,
|
||||
|
|
|
|||
|
|
@ -1187,6 +1187,7 @@ impl RenderConvertible for Table {
|
|||
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
|
||||
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
|
||||
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
|
||||
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1287,6 +1288,8 @@ impl RenderConvertible for Table {
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
@ -1453,6 +1456,7 @@ pub fn tessellate_table(
|
|||
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
|
||||
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
|
||||
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
|
||||
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1920,6 +1924,8 @@ pub fn tessellate_table(
|
|||
attach_h_anchor,
|
||||
v_anchor,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub struct ResolvedTextStyle {
|
|||
pub oblique_angle: f32,
|
||||
pub is_backward: bool,
|
||||
pub is_upside_down: bool,
|
||||
pub is_vertical: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle {
|
||||
|
|
@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
|
|||
oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0),
|
||||
is_backward: style.map(|s| s.is_backward()).unwrap_or(false),
|
||||
is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false),
|
||||
is_vertical: style.map(|s| s.is_vertical).unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> {
|
|||
pub v_anchor: MTextVAnchor,
|
||||
/// DXF code 44 — multiplier on the default 5/3-em baseline gap.
|
||||
pub line_spacing_factor: f32,
|
||||
/// `true` fixes every baseline advance to the entity spacing. `false`
|
||||
/// treats it as a minimum and expands for taller inline runs.
|
||||
pub exact_line_spacing: bool,
|
||||
/// User-defined text-box/column height. Zero means content-driven.
|
||||
pub rectangle_height: f32,
|
||||
/// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2).
|
||||
pub vertical_text: bool,
|
||||
/// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with
|
||||
|
|
@ -1025,10 +1032,15 @@ pub struct MTextRenderOpts<'a> {
|
|||
|
||||
/// An MTEXT's column layout, flattened from its `column_data`.
|
||||
///
|
||||
/// Content moves to the next column at a `\N` break. Filling a column and
|
||||
/// spilling into the next on its own is not modelled: `heights` / `auto_height`
|
||||
/// are not read, so a column runs as long as its content does.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
/// Content moves to the next column at an explicit `\N` break or when the
|
||||
/// configured manual/automatic column height is exhausted.
|
||||
pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256;
|
||||
|
||||
pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 {
|
||||
count.clamp(1, MAX_MTEXT_COLUMNS)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MTextColumns {
|
||||
/// Column count. 0 or 1 both mean "no column layout".
|
||||
pub count: usize,
|
||||
|
|
@ -1036,6 +1048,12 @@ pub struct MTextColumns {
|
|||
pub width: f32,
|
||||
/// Gap between two adjacent columns, world units.
|
||||
pub gutter: f32,
|
||||
/// Whether logical column flow starts at the opposite side.
|
||||
pub flow_reversed: bool,
|
||||
/// Dynamic-column height is balanced from content when true.
|
||||
pub auto_height: bool,
|
||||
/// Optional manual height for each logical column.
|
||||
pub heights: Vec<f32>,
|
||||
}
|
||||
|
||||
impl MTextColumns {
|
||||
|
|
@ -1046,7 +1064,17 @@ impl MTextColumns {
|
|||
|
||||
/// Left edge of column `i`, relative to the text block's own left edge.
|
||||
pub fn offset_of(&self, i: usize) -> f32 {
|
||||
i as f32 * (self.width + self.gutter)
|
||||
let physical = if self.flow_reversed {
|
||||
self.count.saturating_sub(1).saturating_sub(i)
|
||||
} else {
|
||||
i
|
||||
};
|
||||
physical as f32 * (self.width + self.gutter)
|
||||
}
|
||||
|
||||
fn total_width(&self) -> f32 {
|
||||
self.width * self.count as f32
|
||||
+ self.gutter.max(0.0) * self.count.saturating_sub(1) as f32
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1118,6 +1146,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: f32,
|
||||
tab_stops: Vec<TabStop>,
|
||||
is_first_in_paragraph: bool,
|
||||
starts_column: bool,
|
||||
/// Which column this line lives in; always 0 without a column layout.
|
||||
column: usize,
|
||||
/// Extra gap above (paragraph's first wrapped line only) / below (last
|
||||
|
|
@ -1128,7 +1157,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
line_spacing: Option<ParaLineSpacing>,
|
||||
}
|
||||
|
||||
let cols = opts.columns;
|
||||
let cols = opts.columns.clone();
|
||||
// A `\N` past the last column has nowhere to go — keep those lines in the
|
||||
// last one rather than laying them out beyond the block.
|
||||
let last_col = cols.count.saturating_sub(1);
|
||||
|
|
@ -1255,6 +1284,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: para.indent_right,
|
||||
tab_stops: para.tab_stops.clone(),
|
||||
is_first_in_paragraph: idx == 0,
|
||||
starts_column: idx == 0 && para.starts_column,
|
||||
column,
|
||||
// The gap above rides the first wrapped line; the gap below the
|
||||
// last, so an interior wrap keeps normal single spacing.
|
||||
|
|
@ -1277,6 +1307,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
indent_right: 0.0,
|
||||
tab_stops: Vec::new(),
|
||||
is_first_in_paragraph: true,
|
||||
starts_column: false,
|
||||
column: 0,
|
||||
space_before: 0.0,
|
||||
space_after: 0.0,
|
||||
|
|
@ -1366,15 +1397,86 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
};
|
||||
// A paragraph's own `\psm#`/`\pse#` overrides the entity factor for
|
||||
// its lines; `Exact` fixes the baseline gap outright.
|
||||
let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing();
|
||||
match s.line_spacing {
|
||||
Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e,
|
||||
Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => {
|
||||
mh * m * (5.0 / 3.0) * base_font.line_spacing()
|
||||
}
|
||||
_ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(),
|
||||
_ if opts.exact_line_spacing => entity_gap,
|
||||
_ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing())
|
||||
.max(entity_gap),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Flow lines into the next column when the active column's configured
|
||||
// height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic
|
||||
// auto-height balances the content; static/manual columns use their stored
|
||||
// height (falling back to the entity rectangle height).
|
||||
if cols.active() {
|
||||
let mut pending_after = 0.0_f32;
|
||||
let total_advance: f32 = sub_lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, line)| {
|
||||
if line.starts_column {
|
||||
pending_after = 0.0;
|
||||
}
|
||||
let spacing = if line.is_first_in_paragraph {
|
||||
line.space_before + pending_after
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
pending_after = line.space_after;
|
||||
per_line_h.get(index).copied().unwrap_or(entity_h) + spacing
|
||||
})
|
||||
.sum();
|
||||
let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h);
|
||||
let mut current_column = 0usize;
|
||||
let mut used = 0.0_f32;
|
||||
let mut pending_after = 0.0_f32;
|
||||
for (index, line) in sub_lines.iter_mut().enumerate() {
|
||||
if line.starts_column {
|
||||
current_column = (current_column + 1).min(cols.count - 1);
|
||||
used = 0.0;
|
||||
pending_after = 0.0;
|
||||
}
|
||||
let limit = if cols.auto_height {
|
||||
balanced
|
||||
} else {
|
||||
cols.heights
|
||||
.get(current_column)
|
||||
.copied()
|
||||
.filter(|height| *height > 0.0)
|
||||
.unwrap_or(opts.rectangle_height)
|
||||
};
|
||||
let line_advance = per_line_h.get(index).copied().unwrap_or(entity_h);
|
||||
let spacing = if line.is_first_in_paragraph {
|
||||
line.space_before + pending_after
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let mut advance = line_advance + spacing;
|
||||
if limit > 0.0
|
||||
&& used > 0.0
|
||||
&& used + advance > limit
|
||||
&& current_column + 1 < cols.count
|
||||
{
|
||||
current_column += 1;
|
||||
used = 0.0;
|
||||
advance = line_advance
|
||||
+ if line.is_first_in_paragraph {
|
||||
line.space_before
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
line.column = current_column;
|
||||
used += advance;
|
||||
pending_after = line.space_after;
|
||||
}
|
||||
}
|
||||
// Per-line text height — the tallest Word on the line, an empty line
|
||||
// inheriting the previous line's height (same rule as `per_line_h`). Unlike
|
||||
// `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text
|
||||
|
|
@ -1442,7 +1544,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
MTextVAnchor::BottomOfTopLine => 0.0,
|
||||
};
|
||||
let attach_h_anchor = opts.attach_h_anchor;
|
||||
let box_left = -attach_h_anchor * rect_w;
|
||||
let block_width = if cols.active() {
|
||||
cols.total_width()
|
||||
} else {
|
||||
rect_w
|
||||
};
|
||||
let box_left = -attach_h_anchor * block_width;
|
||||
let rot = opts.rotation;
|
||||
let (cos_r, sin_r) = (rot.cos(), rot.sin());
|
||||
let ins_x = opts.insertion[0];
|
||||
|
|
@ -1953,6 +2060,49 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
emit(denominator, valign_dy);
|
||||
emit(numerator, valign_dy + run_h * STACK_RAISE);
|
||||
|
||||
if opts.want_glyph_boxes {
|
||||
let slots = numerator.chars().count()
|
||||
+ denominator.chars().count()
|
||||
+ usize::from(!denominator.is_empty());
|
||||
if slots > 0 {
|
||||
let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE);
|
||||
for index in 0..slots {
|
||||
let x0 = cursor_x + slot_w * index as f32 / slots as f32;
|
||||
let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32;
|
||||
let corners = [
|
||||
to_world(line_base_x, line_base_y, x0, valign_dy),
|
||||
to_world(line_base_x, line_base_y, x1, valign_dy),
|
||||
to_world(line_base_x, line_base_y, x0, top),
|
||||
to_world(line_base_x, line_base_y, x1, top),
|
||||
];
|
||||
let xmin = corners
|
||||
.iter()
|
||||
.map(|p| p.0)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let xmax = corners
|
||||
.iter()
|
||||
.map(|p| p.0)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let ymin = corners
|
||||
.iter()
|
||||
.map(|p| p.1)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let ymax = corners
|
||||
.iter()
|
||||
.map(|p| p.1)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
glyph_boxes.push(GlyphBox {
|
||||
vis,
|
||||
xmin,
|
||||
xmax,
|
||||
ymin,
|
||||
ymax,
|
||||
});
|
||||
vis += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if *bar && slot_w > 0.0 {
|
||||
// The rule is plain geometry, not a glyph, so it rides a
|
||||
// run-less group — those keep their strokes, while a
|
||||
|
|
@ -1975,6 +2125,11 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
|
|||
run: None,
|
||||
});
|
||||
}
|
||||
local_bounds[0] = local_bounds[0].min(cursor_x);
|
||||
local_bounds[1] = local_bounds[1].min(line_ly + valign_dy);
|
||||
local_bounds[2] = local_bounds[2].max(cursor_x + slot_w);
|
||||
local_bounds[3] = local_bounds[3]
|
||||
.max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE));
|
||||
cursor_x += slot_w;
|
||||
}
|
||||
AtomKind::Space => {
|
||||
|
|
@ -2169,6 +2324,7 @@ mod tests {
|
|||
oblique_angle: 0.0,
|
||||
is_backward: false,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2199,6 +2355,8 @@ mod tests {
|
|||
attach_h_anchor: 0.0,
|
||||
v_anchor: MTextVAnchor::Top,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
@ -2222,6 +2380,8 @@ mod tests {
|
|||
attach_h_anchor: 0.0,
|
||||
v_anchor: MTextVAnchor::Top,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: false,
|
||||
});
|
||||
|
|
@ -2378,6 +2538,7 @@ mod v_anchor_tests {
|
|||
oblique_angle: 0.0,
|
||||
is_backward: false,
|
||||
is_upside_down: false,
|
||||
is_vertical: false,
|
||||
};
|
||||
layout_mtext(&MTextRenderOpts {
|
||||
columns: Default::default(),
|
||||
|
|
@ -2390,6 +2551,8 @@ mod v_anchor_tests {
|
|||
attach_h_anchor: 0.0,
|
||||
v_anchor: MTextVAnchor::Top,
|
||||
line_spacing_factor: 1.0,
|
||||
exact_line_spacing: false,
|
||||
rectangle_height: 0.0,
|
||||
vertical_text: false,
|
||||
want_glyph_boxes: true,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
use crate::command::{CadCommand, CmdResult};
|
||||
use acadrust::entities::mtext::AttachmentPoint;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::MText;
|
||||
use glam::DVec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane};
|
||||
use crate::modules::{IconKind, ModuleEvent, ToolDef};
|
||||
use crate::scene::model::wire_model::WireModel;
|
||||
use glam::DVec3;
|
||||
use crate::t;
|
||||
|
||||
pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg"));
|
||||
|
|
@ -15,20 +19,180 @@ pub fn tool() -> ToolDef {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
InsertPoint,
|
||||
FirstCorner,
|
||||
OppositeCorner,
|
||||
Height,
|
||||
Justify,
|
||||
LineSpacing,
|
||||
Rotation,
|
||||
Style,
|
||||
Width,
|
||||
ColumnMode,
|
||||
ColumnCount,
|
||||
ColumnWidth,
|
||||
ColumnGutter,
|
||||
}
|
||||
|
||||
pub struct MTextCommand {
|
||||
step: Step,
|
||||
first: Option<DVec3>,
|
||||
plane: WorkingPlane,
|
||||
height: f64,
|
||||
style: String,
|
||||
attachment: AttachmentPoint,
|
||||
line_spacing: f64,
|
||||
rotation: f64,
|
||||
width: Option<f64>,
|
||||
column_type: i16,
|
||||
column_count: i32,
|
||||
column_width: f64,
|
||||
column_gutter: f64,
|
||||
}
|
||||
|
||||
impl MTextCommand {
|
||||
pub fn with_height(height: f64) -> Self {
|
||||
pub fn with_defaults(height: f64, style: String) -> Self {
|
||||
Self {
|
||||
step: Step::InsertPoint,
|
||||
height,
|
||||
step: Step::FirstCorner,
|
||||
first: None,
|
||||
plane: WorkingPlane::default(),
|
||||
height: height.max(1e-6),
|
||||
style,
|
||||
attachment: AttachmentPoint::TopLeft,
|
||||
line_spacing: 1.0,
|
||||
rotation: 0.0,
|
||||
width: None,
|
||||
column_type: 0,
|
||||
column_count: 2,
|
||||
column_width: 0.0,
|
||||
column_gutter: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn resume_point_step(&mut self) {
|
||||
self.step = if self.first.is_some() {
|
||||
Step::OppositeCorner
|
||||
} else {
|
||||
Step::FirstCorner
|
||||
};
|
||||
}
|
||||
|
||||
fn point_options() -> Vec<CmdOption> {
|
||||
vec![
|
||||
CmdOption::new("Height", "HEIGHT"),
|
||||
CmdOption::new("Justify", "JUSTIFY"),
|
||||
CmdOption::new("Line spacing", "LINESPACING"),
|
||||
CmdOption::new("Rotation", "ROTATION"),
|
||||
CmdOption::new("Style", "STYLE"),
|
||||
CmdOption::new("Width", "WIDTH"),
|
||||
CmdOption::new("Columns", "COLUMNS"),
|
||||
]
|
||||
}
|
||||
|
||||
fn begin_option(&mut self, keyword: &str) -> bool {
|
||||
self.step = match keyword {
|
||||
"H" | "HEIGHT" => Step::Height,
|
||||
"J" | "JUSTIFY" => Step::Justify,
|
||||
"L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing,
|
||||
"R" | "ROTATION" => Step::Rotation,
|
||||
"S" | "STYLE" => Step::Style,
|
||||
"W" | "WIDTH" => Step::Width,
|
||||
"C" | "COLUMNS" => Step::ColumnMode,
|
||||
_ => return false,
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
fn boundary_geometry(&self, opposite: DVec3) -> (DVec3, f64, f64) {
|
||||
let first = self.first.unwrap_or(opposite);
|
||||
let local = self.plane.vector_to_local(opposite - first);
|
||||
let (sin, cos) = self.rotation.sin_cos();
|
||||
let picked_horizontal = local.x * cos + local.y * sin;
|
||||
let vertical = -local.x * sin + local.y * cos;
|
||||
let width = self.width.unwrap_or(picked_horizontal.abs());
|
||||
let horizontal = if self.width.is_some() {
|
||||
width.copysign(if picked_horizontal.abs() > 1e-12 {
|
||||
picked_horizontal
|
||||
} else {
|
||||
1.0
|
||||
})
|
||||
} else {
|
||||
picked_horizontal
|
||||
};
|
||||
let (xmin, xmax) = (horizontal.min(0.0), horizontal.max(0.0));
|
||||
let (ymin, ymax) = (vertical.min(0.0), vertical.max(0.0));
|
||||
let horizontal_anchor = match self.attachment {
|
||||
AttachmentPoint::TopLeft
|
||||
| AttachmentPoint::MiddleLeft
|
||||
| AttachmentPoint::BottomLeft => 0.0,
|
||||
AttachmentPoint::TopCenter
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::BottomCenter => 0.5,
|
||||
AttachmentPoint::TopRight
|
||||
| AttachmentPoint::MiddleRight
|
||||
| AttachmentPoint::BottomRight => 1.0,
|
||||
};
|
||||
let vertical_anchor = match self.attachment {
|
||||
AttachmentPoint::TopLeft
|
||||
| AttachmentPoint::TopCenter
|
||||
| AttachmentPoint::TopRight => 1.0,
|
||||
AttachmentPoint::MiddleLeft
|
||||
| AttachmentPoint::MiddleCenter
|
||||
| AttachmentPoint::MiddleRight => 0.5,
|
||||
AttachmentPoint::BottomLeft
|
||||
| AttachmentPoint::BottomCenter
|
||||
| AttachmentPoint::BottomRight => 0.0,
|
||||
};
|
||||
let anchor_x = xmin + (xmax - xmin) * horizontal_anchor;
|
||||
let anchor_y = ymin + (ymax - ymin) * vertical_anchor;
|
||||
let offset = DVec3::new(
|
||||
anchor_x * cos - anchor_y * sin,
|
||||
anchor_x * sin + anchor_y * cos,
|
||||
0.0,
|
||||
);
|
||||
(
|
||||
first + self.plane.vector_to_world(offset),
|
||||
width,
|
||||
vertical.abs(),
|
||||
)
|
||||
}
|
||||
|
||||
fn open_editor(&self, opposite: DVec3) -> CmdResult {
|
||||
let (insertion, width, boundary_height) = self.boundary_geometry(opposite);
|
||||
let mut template = MText::default();
|
||||
template.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.z);
|
||||
template.height = self.height;
|
||||
template.rectangle_width = width.max(0.0);
|
||||
template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height);
|
||||
template.rotation = self.rotation;
|
||||
template.style = self.style.clone();
|
||||
template.attachment_point = self.attachment;
|
||||
template.line_spacing_factor = self.line_spacing;
|
||||
template.column_data.column_type = self.column_type;
|
||||
if self.column_type != 0 {
|
||||
template.column_data.column_count =
|
||||
crate::entities::text_support::clamp_mtext_column_count(self.column_count);
|
||||
template.column_data.auto_height = self.column_type == 2;
|
||||
template.column_data.width = if self.column_width > 0.0 {
|
||||
self.column_width
|
||||
} else if width > 0.0 {
|
||||
width
|
||||
} else {
|
||||
self.height * 10.0
|
||||
};
|
||||
template.column_data.gutter = if self.column_gutter > 0.0 {
|
||||
self.column_gutter
|
||||
} else {
|
||||
self.height
|
||||
};
|
||||
}
|
||||
CmdResult::OpenMTextEditor {
|
||||
pos: insertion,
|
||||
handle: None,
|
||||
initial: String::new(),
|
||||
height: self.height,
|
||||
template: Some(Box::new(template)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,34 +202,208 @@ impl CadCommand for MTextCommand {
|
|||
"MTEXT"
|
||||
}
|
||||
|
||||
fn set_working_plane(&mut self, plane: WorkingPlane) {
|
||||
self.plane = plane;
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(),
|
||||
match self.step {
|
||||
Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(),
|
||||
Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(),
|
||||
Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(),
|
||||
Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] <TL>:").into_owned(),
|
||||
Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(),
|
||||
Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(),
|
||||
Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(),
|
||||
Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(),
|
||||
Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] <None>:").into_owned(),
|
||||
Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(),
|
||||
Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(),
|
||||
Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: DVec3) -> CmdResult {
|
||||
// Hand off to the in-place editor (toolbar + text area + live preview).
|
||||
CmdResult::OpenMTextEditor {
|
||||
pos: pt,
|
||||
handle: None,
|
||||
initial: String::new(),
|
||||
height: self.height,
|
||||
fn options(&self) -> Vec<CmdOption> {
|
||||
match self.step {
|
||||
Step::FirstCorner | Step::OppositeCorner => Self::point_options(),
|
||||
Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"]
|
||||
.into_iter()
|
||||
.map(|value| CmdOption::new(value, value))
|
||||
.collect(),
|
||||
Step::ColumnMode => vec![
|
||||
CmdOption::new("None", "NONE"),
|
||||
CmdOption::new("Static", "STATIC"),
|
||||
CmdOption::new("Dynamic", "DYNAMIC"),
|
||||
],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn point_step_accepts_keywords(&self) -> bool {
|
||||
matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn wants_text_with_spaces(&self) -> bool {
|
||||
matches!(self.step, Step::Style)
|
||||
}
|
||||
|
||||
fn dyn_field(&self) -> DynField {
|
||||
match self.step {
|
||||
Step::Rotation => DynField::Angle,
|
||||
Step::Height
|
||||
| Step::LineSpacing
|
||||
| Step::Width
|
||||
| Step::ColumnCount
|
||||
| Step::ColumnWidth
|
||||
| Step::ColumnGutter => DynField::Scalar,
|
||||
_ => DynField::Point,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let trimmed = text.trim();
|
||||
let upper = trimmed.to_ascii_uppercase();
|
||||
if matches!(self.step, Step::FirstCorner | Step::OppositeCorner)
|
||||
&& self.begin_option(&upper)
|
||||
{
|
||||
return Some(CmdResult::NeedPoint);
|
||||
}
|
||||
|
||||
match self.step {
|
||||
Step::Height => {
|
||||
let value = crate::entities::common::parse_typed_length(trimmed)?;
|
||||
if value <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.height = value;
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::Justify => {
|
||||
self.attachment = match upper.as_str() {
|
||||
"TL" => AttachmentPoint::TopLeft,
|
||||
"TC" => AttachmentPoint::TopCenter,
|
||||
"TR" => AttachmentPoint::TopRight,
|
||||
"ML" => AttachmentPoint::MiddleLeft,
|
||||
"MC" => AttachmentPoint::MiddleCenter,
|
||||
"MR" => AttachmentPoint::MiddleRight,
|
||||
"BL" => AttachmentPoint::BottomLeft,
|
||||
"BC" => AttachmentPoint::BottomCenter,
|
||||
"BR" => AttachmentPoint::BottomRight,
|
||||
_ => return None,
|
||||
};
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::LineSpacing => {
|
||||
let value = trimmed.replace(',', ".").parse::<f64>().ok()?;
|
||||
if !(0.25..=4.0).contains(&value) {
|
||||
return None;
|
||||
}
|
||||
self.line_spacing = value;
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::Rotation => {
|
||||
self.rotation = crate::entities::common::parse_typed_direction(trimmed)?;
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::Style => {
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.style = trimmed.to_string();
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::Width => {
|
||||
let value = crate::entities::common::parse_typed_length(trimmed)?;
|
||||
if value < 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.width = Some(value);
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::ColumnMode => {
|
||||
self.column_type = match upper.as_str() {
|
||||
"N" | "NONE" => 0,
|
||||
"S" | "STATIC" => 1,
|
||||
"D" | "DYNAMIC" => 2,
|
||||
_ => return None,
|
||||
};
|
||||
if self.column_type == 0 {
|
||||
self.resume_point_step();
|
||||
} else {
|
||||
self.step = Step::ColumnCount;
|
||||
}
|
||||
}
|
||||
Step::ColumnCount => {
|
||||
let value = trimmed.parse::<i32>().ok()?;
|
||||
if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) {
|
||||
return None;
|
||||
}
|
||||
self.column_count = value;
|
||||
self.step = Step::ColumnWidth;
|
||||
}
|
||||
Step::ColumnWidth => {
|
||||
let value = crate::entities::common::parse_typed_length(trimmed)?;
|
||||
if value <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.column_width = value;
|
||||
self.step = Step::ColumnGutter;
|
||||
}
|
||||
Step::ColumnGutter => {
|
||||
let value = crate::entities::common::parse_typed_length(trimmed)?;
|
||||
if value < 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.column_gutter = value;
|
||||
self.resume_point_step();
|
||||
}
|
||||
Step::FirstCorner | Step::OppositeCorner => return None,
|
||||
}
|
||||
Some(CmdResult::NeedPoint)
|
||||
}
|
||||
|
||||
fn on_point(&mut self, point: DVec3) -> CmdResult {
|
||||
match self.step {
|
||||
Step::FirstCorner => {
|
||||
self.first = Some(point);
|
||||
self.step = Step::OppositeCorner;
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Step::OppositeCorner => self.open_editor(point),
|
||||
_ => CmdResult::NeedPoint,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
match self.step {
|
||||
Step::Height
|
||||
| Step::Justify
|
||||
| Step::LineSpacing
|
||||
| Step::Rotation
|
||||
| Step::Style
|
||||
| Step::Width
|
||||
| Step::ColumnMode
|
||||
| Step::ColumnCount
|
||||
| Step::ColumnWidth
|
||||
| Step::ColumnGutter => {
|
||||
self.resume_point_step();
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
_ => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, _pt: DVec3) -> Option<WireModel> {
|
||||
fn on_mouse_move(&mut self, _point: DVec3) -> Option<WireModel> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand
|
||||
inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] });
|
||||
|
|
|
|||
1
src/scene/cache/properties.rs
vendored
1
src/scene/cache/properties.rs
vendored
|
|
@ -96,6 +96,7 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
|||
|
||||
if matches!(entity, EntityType::Text(_))
|
||||
|| matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline))
|
||||
|| matches!(entity, EntityType::MText(_))
|
||||
{
|
||||
section.props.retain(|prop| prop.field != "handle");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,81 @@ fn oriented_text_corners(
|
|||
]
|
||||
}
|
||||
|
||||
fn oriented_mtext_corner_groups(
|
||||
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
|
||||
text: &acadrust::MText,
|
||||
rotation: f64,
|
||||
pad: f64,
|
||||
annotation_scale: f64,
|
||||
) -> Vec<[[f64; 2]; 4]> {
|
||||
let columns = &text.column_data;
|
||||
let count = crate::entities::text_support::clamp_mtext_column_count(columns.column_count)
|
||||
as usize;
|
||||
if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 {
|
||||
return vec![oriented_text_corners(
|
||||
verts,
|
||||
[text.insertion_point.x, text.insertion_point.y],
|
||||
rotation,
|
||||
pad,
|
||||
)];
|
||||
}
|
||||
|
||||
let width = columns.width * annotation_scale;
|
||||
let gutter = columns.gutter.max(0.0) * annotation_scale;
|
||||
let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64;
|
||||
let anchor = match text.attachment_point {
|
||||
acadrust::entities::mtext::AttachmentPoint::TopCenter
|
||||
| acadrust::entities::mtext::AttachmentPoint::MiddleCenter
|
||||
| acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5,
|
||||
acadrust::entities::mtext::AttachmentPoint::TopRight
|
||||
| acadrust::entities::mtext::AttachmentPoint::MiddleRight
|
||||
| acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let block_left = -anchor * total_width;
|
||||
let origin = [text.insertion_point.x, text.insertion_point.y];
|
||||
let (sin_r, cos_r) = rotation.sin_cos();
|
||||
let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count];
|
||||
for vertex in verts {
|
||||
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0];
|
||||
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1];
|
||||
let local_x = x * cos_r + y * sin_r;
|
||||
let local_y = -x * sin_r + y * cos_r;
|
||||
let stride = width + gutter;
|
||||
let physical = ((local_x - block_left) / stride)
|
||||
.floor()
|
||||
.clamp(0.0, count.saturating_sub(1) as f64) as usize;
|
||||
bounds[physical][0] = bounds[physical][0].min(local_x);
|
||||
bounds[physical][1] = bounds[physical][1].min(local_y);
|
||||
bounds[physical][2] = bounds[physical][2].max(local_x);
|
||||
bounds[physical][3] = bounds[physical][3].max(local_y);
|
||||
}
|
||||
let to_world = |x: f64, y: f64| {
|
||||
[
|
||||
origin[0] + x * cos_r - y * sin_r,
|
||||
origin[1] + x * sin_r + y * cos_r,
|
||||
]
|
||||
};
|
||||
bounds
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3])
|
||||
.map(|bounds| {
|
||||
let [left, bottom, right, top] = [
|
||||
bounds[0] - pad,
|
||||
bounds[1] - pad,
|
||||
bounds[2] + pad,
|
||||
bounds[3] + pad,
|
||||
];
|
||||
[
|
||||
to_world(left, bottom),
|
||||
to_world(right, bottom),
|
||||
to_world(right, top),
|
||||
to_world(left, top),
|
||||
]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> {
|
||||
let EntityType::MText(text) = entity else {
|
||||
return None;
|
||||
|
|
@ -856,11 +931,12 @@ pub fn tessellate(
|
|||
.unwrap_or(m.rotation);
|
||||
let text_height = m.height * anno;
|
||||
let pad = (m.background_scale - 1.0).max(0.0) * text_height;
|
||||
let corners = oriented_text_corners(
|
||||
let corner_groups = oriented_mtext_corner_groups(
|
||||
&sdf_verts,
|
||||
[m.insertion_point.x, m.insertion_point.y],
|
||||
m,
|
||||
text_rotation,
|
||||
pad,
|
||||
anno,
|
||||
);
|
||||
// Fill / mask — two triangles behind the glyphs.
|
||||
if has_fill {
|
||||
|
|
@ -869,13 +945,18 @@ pub fn tessellate(
|
|||
} else {
|
||||
color_or_inherit(&m.background_color, bg_color)
|
||||
};
|
||||
let mut ft = Vec::with_capacity(6);
|
||||
let mut ftl = Vec::with_capacity(6);
|
||||
for &k in &[0usize, 1, 2, 0, 2, 3] {
|
||||
let (h, lo) =
|
||||
split_ds_xyz(corners[k][0], corners[k][1], elev_v);
|
||||
ft.push(h);
|
||||
ftl.push(lo);
|
||||
let mut ft = Vec::with_capacity(6 * corner_groups.len());
|
||||
let mut ftl = Vec::with_capacity(6 * corner_groups.len());
|
||||
for corners in &corner_groups {
|
||||
for &k in &[0usize, 1, 2, 0, 2, 3] {
|
||||
let (h, lo) = split_ds_xyz(
|
||||
corners[k][0],
|
||||
corners[k][1],
|
||||
elev_v,
|
||||
);
|
||||
ft.push(h);
|
||||
ftl.push(lo);
|
||||
}
|
||||
}
|
||||
wires.push(WireModel {
|
||||
point_marker: None,
|
||||
|
|
@ -914,19 +995,24 @@ pub fn tessellate(
|
|||
// Text frame — a closed rectangle in the text
|
||||
// colour around the same box.
|
||||
if has_frame {
|
||||
let loop_xy = [
|
||||
corners[0],
|
||||
corners[1],
|
||||
corners[2],
|
||||
corners[3],
|
||||
corners[0],
|
||||
];
|
||||
let mut fp = Vec::with_capacity(5);
|
||||
let mut fpl = Vec::with_capacity(5);
|
||||
for &[x, y] in &loop_xy {
|
||||
let (h, lo) = split_ds_xyz(x, y, elev_v);
|
||||
fp.push(h);
|
||||
fpl.push(lo);
|
||||
let mut fp = Vec::with_capacity(6 * corner_groups.len());
|
||||
let mut fpl = Vec::with_capacity(6 * corner_groups.len());
|
||||
for (group_index, corners) in corner_groups.iter().enumerate() {
|
||||
if group_index > 0 {
|
||||
fp.push([f32::NAN; 3]);
|
||||
fpl.push([0.0; 3]);
|
||||
}
|
||||
for &[x, y] in &[
|
||||
corners[0],
|
||||
corners[1],
|
||||
corners[2],
|
||||
corners[3],
|
||||
corners[0],
|
||||
] {
|
||||
let (h, lo) = split_ds_xyz(x, y, elev_v);
|
||||
fp.push(h);
|
||||
fpl.push(lo);
|
||||
}
|
||||
}
|
||||
wires.push(WireModel {
|
||||
point_marker: None,
|
||||
|
|
|
|||
Loading…
Reference in a new issue