fix(tolerance): complete workflow integration

Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview.
This commit is contained in:
Hakan Seven authored and GitHub committed 2026-08-27 00:14:08 +03:00
1 parent 66bf4b1d5f
commit 81e7add1dd
65 files changed
+3708 -934

No files matched your search

+13
View File
@@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value {
impl OpenCADStudio {
/// Handle one JSON request line and return the JSON response.
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
let res = self.automation_op_inner(line);
// Most automation ops mutate `Scene::selected` directly rather than
// going through `update()` (`select` calls `deselect_all` /
// `select_entity`, and `run` can erase the selected entities), so the
// selection check has to run on this path too.
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_selection_changed();
res
}
fn automation_op_inner(&mut self, line: &str) -> Value {
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => return err(format!("invalid JSON: {e}")),
@@ -201,6 +212,7 @@ impl OpenCADStudio {
"new" => {
let i = self.active_tab;
self.tabs[i].scene.document = acadrust::CadDocument::new();
self.tabs[i].scene.deselect_all();
self.tabs[i].current_path = None;
// The headless session starts on the welcome (Start) tab, which
// blocks drawing commands; turn it into a real drawing.
@@ -224,6 +236,7 @@ impl OpenCADStudio {
Ok(doc) => {
let i = self.active_tab;
self.tabs[i].scene.document = doc;
self.tabs[i].scene.deselect_all();
crate::app::style_ops::ensure_standard_styles(
&mut self.tabs[i].scene.document,
);
+19 -2
View File
@@ -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,
@@ -3748,6 +3749,22 @@ impl OpenCADStudio {
&initial,
height,
super::text_inline::TextEntityField::Text,
None,
);
}
CmdResult::SuspendForTextInput { pos, entity } => {
self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take();
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
let height = entity.height;
self.open_text_inline(
pos,
None,
"",
height,
super::text_inline::TextEntityField::Text,
Some(entity),
);
}
CmdResult::EditTextEntity { handle } => {
+17 -6
View File
@@ -1268,11 +1268,16 @@ impl OpenCADStudio {
// ── Annotate commands ──────────────────────────────────────────
"TEXT" => {
use crate::modules::annotate::text::TextCommand;
let height = crate::scene::creation_style::current_text_defaults(
&self.tabs[i].scene.document,
)
.height;
let new_cmd = TextCommand::with_height(height);
let (defaults, styles, annotation_multiplier) = {
let scene = &self.tabs[i].scene;
let annotation_multiplier = scene.creation_annotation_multiplier();
let defaults =
crate::scene::creation_style::current_text_defaults(&scene.document);
let styles = scene.document.text_styles.iter().cloned().collect();
(defaults, styles, annotation_multiplier)
};
let new_cmd =
TextCommand::with_defaults(defaults, styles, annotation_multiplier);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
@@ -1307,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));
}
+6 -1
View File
@@ -943,7 +943,12 @@ impl OpenCADStudio {
"ALIGN" => {
use crate::modules::draw::modify::align::AlignCommand;
let cmd = AlignCommand::new();
let selected: Vec<acadrust::Handle> =
self.tabs[i].scene.selected.iter().copied().collect();
let cmd = AlignCommand::with_selection(selected);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
+3 -32
View File
@@ -827,30 +827,8 @@ impl OpenCADStudio {
}
}
// A viewport frames the model, so its framing is measured in model
// units and has to follow them. Left alone, every layout would suddenly
// look at a region a thousand times too large. The paper side of the
// ratio is untouched — the sheet is still the same sheet — so the ratio
// itself moves the other way.
//
// `transform_viewport` covers the rectangle and the target but not the
// framing, so the framing is done here for every viewport, and the
// target only for the ones the scale above did not already reach.
// Not every viewport frames the model, though. Each layout's sheet
// viewport frames the sheet — its view is the paper, measured in paper
// units — so scaling it by the model's factor would leave the drawing
// frame the same size as before while the view of it changed by a
// thousand, which is the boundary appearing to break.
let sheets: std::collections::HashSet<acadrust::Handle> = self.tabs[i]
.scene
.document
.objects
.values()
.filter_map(|object| match object {
acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport),
_ => None,
})
.collect();
// Model framing follows model units; sheet framing stays in paper units.
let sheets = self.tabs[i].scene.sheet_viewport_handles();
let scaled: std::collections::HashSet<acadrust::Handle> = handles.iter().copied().collect();
let mut reframed = 0usize;
for entity in self.tabs[i].scene.document.entities_mut() {
@@ -858,14 +836,7 @@ impl OpenCADStudio {
let acadrust::entities::EntityType::Viewport(vp) = entity else {
continue;
};
// Only a paper-space viewport can be a sheet — the scale above
// reached everything in model space, and what it reached frames the
// model by definition. Among the rest, the layout names its sheet
// outright, and a file that arrives without that link still gives
// itself away by sitting at the paper origin, where only the sheet
// sits.
let is_sheet = !scaled.contains(&handle)
&& (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp));
let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle);
if is_sheet {
continue;
}
+31
View File
@@ -694,6 +694,10 @@ pub(super) struct OpenCADStudio {
/// keep their manifest listed but drop their ribbon tab and command
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
disabled_plugins: rustc_hash::FxHashSet<String>,
/// `(tab id, selection fingerprint)` last broadcast to V4 plugins, so
/// `SelectionChangedV4` fires once per real change rather than per message.
#[cfg(not(target_arch = "wasm32"))]
last_plugin_selection: Option<(u64, u64)>,
/// External add-on packages found in the plugins folder, refreshed when the
/// Plugin Manager opens.
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
@@ -2667,6 +2671,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),
@@ -3259,6 +3288,8 @@ impl OpenCADStudio {
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute,
attr_editor_selected: 0,
disabled_plugins: rustc_hash::FxHashSet::default(),
#[cfg(not(target_arch = "wasm32"))]
last_plugin_selection: None,
external_plugins: Vec::new(),
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
plugin_load_errors: rustc_hash::FxHashMap::default(),
+489 -19
View File
@@ -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(&para0, &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(&para0, &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(&para0, &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(&para0, &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(&para0, &cells, start);
let replacement_cells = str_to_cells(&replacement, &props, &paragraph);
cells.splice(start..start + needle.len(), replacement_cells);
}
ed.content = text_editor::Content::with_text(
&cells_to_doc(&para0, &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();
+63
View File
@@ -1884,6 +1884,69 @@ impl OpenCADStudio {
}
}
// Single-line text height rows depend on both the
// justification and the active annotation scale. Aligned
// text derives its paper height from the two endpoints,
// while annotative text exposes that paper height and a
// separate calculated model height.
if let acadrust::EntityType::Text(text) = entity {
let aligned = matches!(
text.horizontal_alignment,
acadrust::entities::TextHorizontalAlignment::Aligned
);
let annotative = crate::scene::annotative::is_annotative(doc, entity);
let model_factor = if annotative {
annotation_scale_handle
.and_then(|handle| match doc.objects.get(&handle) {
Some(acadrust::objects::ObjectType::Scale(scale)) => Some(
scale.inverse_factor()
/ self.tabs[i].scene.annotation_scale_unit_factor(),
),
_ => None,
})
.unwrap_or(self.tabs[i].scene.annotation_scale as f64)
} else {
1.0
};
let paper_height = if aligned {
crate::entities::text::text_run_placement_at_scale(
text,
doc,
model_factor as f32,
)
.height as f64
} else {
text.height
};
for section in sections.iter_mut() {
if let Some(row) =
section.props.iter_mut().find(|row| row.field == "height")
{
if annotative {
row.label = t!("Paper text height").into_owned();
}
if aligned {
row.value = crate::scene::model::object::PropValue::ReadOnly(
crate::entities::common::format_length(paper_height),
);
}
}
}
if annotative {
insert_row_after(
&mut sections,
"height",
crate::entities::common::ro_prop(
t!("Model text height").as_ref(),
"model_text_height",
crate::entities::common::format_length(
paper_height * model_factor,
),
),
);
}
}
if !group_names.is_empty() {
let label = group_names.join(", ");
if let Some(general) = sections.first_mut() {
+59 -24
View File
@@ -95,6 +95,9 @@ pub struct TextInlineState {
pub editing: Option<Handle>,
/// Which entity slot this session writes to on commit.
pub field: TextEntityField,
/// Fully prepared entity supplied by the interactive TEXT command. Editing
/// existing text and legacy direct-open paths leave this empty.
pub creation: Option<Text>,
/// Canvas-space anchor where the field is drawn (the insertion-point click).
pub screen_anchor: iced::Point,
}
@@ -173,10 +176,10 @@ 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);
self.open_text_inline(pos, Some(target), &value, height, field, None);
iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID))
}
}
@@ -190,6 +193,7 @@ impl super::OpenCADStudio {
initial: &str,
height: f64,
field: TextEntityField,
creation: Option<Text>,
) {
if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) {
return;
@@ -200,6 +204,7 @@ impl super::OpenCADStudio {
height: if height > 0.0 { height } else { 0.25 },
editing: handle,
field,
creation,
screen_anchor: iced::Point::new(60.0, 90.0),
};
if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos {
@@ -236,12 +241,18 @@ impl super::OpenCADStudio {
} else {
crate::command::WorkingPlane::default()
};
let position = plane.to_local(ed.pos);
let mut t = Text::with_value(
&ed.value,
Vector3::new(position.x, position.y, position.z),
)
.with_height(ed.height);
let command_creation = ed.creation.is_some();
let mut t = if let Some(mut prepared) = ed.creation {
prepared.value = ed.value.clone();
prepared
} else {
let position = plane.to_local(ed.pos);
Text::with_value(
&ed.value,
Vector3::new(position.x, position.y, position.z),
)
.with_height(ed.height)
};
// New text inherits the document's current text style (STYLE), not
// the entity default. See #92.
let cur_style = self.tabs[i]
@@ -251,24 +262,48 @@ impl super::OpenCADStudio {
.current_text_style_name
.clone();
if !cur_style.is_empty() {
t.style = cur_style;
}
let annotative = crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&t.style,
);
self.push_undo_snapshot(i, "TEXT");
let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
if annotative {
let scale = self.tabs[i].scene.current_annotation_scale_handle();
if let (Some(handle), Some(scale)) = (handle, scale) {
crate::scene::annotative::create_annotation_context(
&mut self.tabs[i].scene.document,
handle,
scale,
);
if t.style.trim().is_empty() {
t.style = cur_style;
}
}
if command_creation {
let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative(
&self.tabs[i].scene.document,
&t.style,
) {
self.tabs[i].scene.creation_annotation_multiplier()
} else {
1.0
};
let display_height = crate::entities::text::text_run_placement_at_scale(
&t,
&self.tabs[i].scene.document,
annotation_multiplier as f32,
)
.height as f64
* annotation_multiplier;
if let Some(command) = self.tabs[i].suspended_cmd.as_mut() {
command.on_editor_display_height(display_height);
}
}
self.push_undo_snapshot(i, "TEXT");
self.tabs[i].scene.document.header.current_text_style_name = t.style.clone();
let variable_height = self.tabs[i]
.scene
.document
.text_styles
.iter()
.find(|style| style.name.eq_ignore_ascii_case(&t.style))
.is_none_or(|style| style.height <= 1.0e-9);
if variable_height
&& !matches!(
t.horizontal_alignment,
acadrust::entities::TextHorizontalAlignment::Aligned
)
{
self.tabs[i].scene.document.header.text_height = t.height;
}
let _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
self.tabs[i].dirty = true;
}
self.refresh_properties();
+22 -2
View File
@@ -71,8 +71,28 @@ impl super::OpenCADStudio {
return false;
}
let i = self.active_tab;
let mut command =
crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text());
let text = state.to_text();
let mut preview_entity = EntityType::Tolerance(
acadrust::entities::Tolerance::with_text(
acadrust::types::Vector3::ZERO,
text.clone(),
),
);
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut preview_entity,
);
let preview_strokes = match &preview_entity {
EntityType::Tolerance(tolerance) => crate::entities::tolerance::preview_strokes(
tolerance,
&self.tabs[i].scene.document,
),
_ => Vec::new(),
};
let mut command = crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(
text,
preview_strokes,
);
use crate::command::CadCommand;
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
+6 -1
View File
@@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if matches!(
item.action,
GripMenuAction::Stretch
| GripMenuAction::MoveWithText
| GripMenuAction::MoveWithDimLine
| GripMenuAction::MoveWithLeader
| GripMenuAction::MoveIndependent
@@ -1212,7 +1213,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
{
(crate::entities::multileader::MOVE_ALL_GRIP, true)
} else {
(popup.grip_id, if is_dimension { false } else { g.is_midpoint })
(
popup.grip_id,
matches!(item.action, GripMenuAction::MoveWithText)
|| (!is_dimension && g.is_midpoint),
)
};
self.tabs[i].active_grip = Some(GripEdit::single(
popup.handle,
+8 -1
View File
@@ -204,7 +204,14 @@ fn plot_scene_content(
|| !crate::scene::Scene::handle_from_wire_name(&wire.name)
.and_then(|handle| scene.document.get_entity(handle))
.is_some_and(|entity| {
matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport))
matches!(
entity,
acadrust::EntityType::Viewport(viewport)
if !crate::scene::Scene::is_sheet_viewport(
&scene.document,
viewport,
)
)
}))
});
model_wires.retain(|wire| wire.plot_visible);
+134 -11
View File
@@ -235,6 +235,25 @@ impl OpenCADStudio {
}
}
/// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection
/// set actually changed since the last broadcast.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn notify_plugins_selection_changed(&mut self) {
if self.active_tab >= self.tabs.len() {
return;
}
let i = self.active_tab;
let tab_id = self.tabs[i].id;
let fingerprint = self.tabs[i].scene.selection_fingerprint();
let key = (tab_id, fingerprint);
if self.last_plugin_selection == Some(key) {
return;
}
self.last_plugin_selection = Some(key);
let handles = self.tabs[i].scene.selected_handles_in_order();
crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles);
}
pub fn update(&mut self, msg: Message) -> Task<Message> {
let perf_started = crate::perf::enabled().then(Instant::now);
let perf_label = perf_message_label(&msg);
@@ -279,6 +298,11 @@ impl OpenCADStudio {
// The block panel watches the drawing's block list and rebuilds its
// thumbnails whenever the names change (BLOCK define, file open, …).
self.refresh_block_palette_if_stale();
// Let V4 plugins observe selection changes that happened while handling
// this message (picking, window select, QSELECT, SELECTALL, grip edits,
// and plugin request draining).
#[cfg(not(target_arch = "wasm32"))]
self.notify_plugins_selection_changed();
// OTRACK acquires tracking points only while a command or grip drag is
// running; drop them once neither is active so the temporary tracking
// points / vectors disappear when the command ends (issue #64).
@@ -3915,10 +3939,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) => {
@@ -3950,26 +3971,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;
+4 -1
View File
@@ -4620,7 +4620,10 @@ impl OpenCADStudio {
if let Some(AcadEntityType::Viewport(vp)) =
self.tabs[i].scene.document.get_entity(h)
{
if Scene::is_content_viewport(vp) {
if !Scene::is_sheet_viewport(
&self.tabs[i].scene.document,
vp,
) {
Some(h)
} else {
None
+159 -6
View File
@@ -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),