diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index c046c2907..5e14e994a 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -1699,6 +1699,41 @@ impl OpenCADStudio { self.ribbon.deactivate_tool(); return self.begin_text_edit(handle); } + CmdResult::SuspendForTextEdit { handle } => { + let is_editable = crate::app::text_inline::can_edit_text(handle, &self.tabs[i].scene.document); + if !is_editable { + self.command_line.push_error("TEXTEDIT: selected entity is not text."); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + return Task::none(); + } + let cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].suspended_cmd = cmd; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + return self.begin_text_edit(handle); + } + CmdResult::UndoDocument => { + let active = self.tabs[i].active_cmd.take(); + self.undo_active_tab(); + self.tabs[i].active_cmd = active; + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + } + CmdResult::SetTexteditMode(val) => { + self.texteditmode = val; + let display_val = if val { 1 } else { 0 }; + self.command_line.push_output(&format!("TEXTEDITMODE set to {display_val}")); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + } CmdResult::DdeditEntity { handle, new_text } => { let mut updated = false; if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { diff --git a/src/app/commands.rs b/src/app/commands.rs index 669447a0e..358dcb109 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1444,6 +1444,22 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "TEXTEDIT" | "TEDIT" => { + use crate::modules::annotate::textedit::TexteditCommand; + let mode_str = if self.texteditmode { "Single" } else { "Multiple" }; + self.command_line.push_output(&format!("Current settings: Edit mode = {}", mode_str)); + let new_cmd = TexteditCommand::new(self.texteditmode); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + + "TEXTEDITMODE" => { + use crate::modules::annotate::textedit::TexteditmodeCommand; + let cmd = TexteditmodeCommand::new(self.texteditmode); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + } + "DIMALIGNED" | "DAL" => { use crate::modules::annotate::aligned_dim::AlignedDimensionCommand; let cmd = AlignedDimensionCommand::new(); @@ -4942,6 +4958,23 @@ impl OpenCADStudio { ); } } + cmd if cmd.starts_with("TEXTEDITMODE ") => { + let val_str = cmd.trim_start_matches("TEXTEDITMODE").trim().to_lowercase(); + if val_str.is_empty() { + let v = if self.texteditmode { 1 } else { 0 }; + self.command_line.push_output(&format!("TEXTEDITMODE = {v}")); + } else if let Some(v) = + crate::modules::annotate::textedit::parse_texteditmode(&val_str) + { + self.texteditmode = v; + let n = if v { 1 } else { 0 }; + self.command_line + .push_output(&format!("TEXTEDITMODE set to {n}")); + } else { + self.command_line + .push_error("Requires 0 OR 1 OR MULTIPLE OR SINGLE"); + } + } cmd if cmd == "PDSIZE" || cmd.starts_with("PDSIZE ") => { let val_str = cmd.trim_start_matches("PDSIZE").trim(); if val_str.is_empty() { diff --git a/src/app/document.rs b/src/app/document.rs index c1895536a..9196c4cc2 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -160,6 +160,7 @@ pub(super) struct DocumentTab { pub(super) is_start: bool, /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state). pub(super) plugin_state: HashMap<&'static str, Box>, + pub(super) suspended_cmd: Option>, } impl DocumentTab { @@ -213,6 +214,7 @@ impl DocumentTab { last_synced_camera_gen: 0, is_start: false, plugin_state: HashMap::new(), + suspended_cmd: None, } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 3c85e2f31..5c195be22 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -242,6 +242,8 @@ pub(super) struct OpenCADStudio { show_grid: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, + /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). + pub texteditmode: bool, /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, @@ -1686,6 +1688,7 @@ impl OpenCADStudio { polar_increment_deg: 45.0, show_grid: false, dyn_input: true, + texteditmode: false, awaiting_vports: false, tile_drag: None, dyn_user_reshaped: false, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index e2422391e..aff2713d3 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -628,15 +628,15 @@ impl super::OpenCADStudio { } /// Commit the editor — create a new MText or update the edited one. - pub(super) fn mtext_commit(&mut self) { + pub(super) fn mtext_commit(&mut self) -> bool { let i = self.active_tab; - let Some(ed) = self.mtext_editor.take() else { return }; + let Some(ed) = self.mtext_editor.take() else { return false }; let body_empty = ed.content.text().trim().is_empty(); let mt = ed.build_mtext(); if body_empty { // Empty content: drop a new entity; leave an edited one untouched. self.refresh_properties(); - return; + return false; } if let Some(h) = ed.editing { self.push_undo_snapshot(i, "MTEXT"); @@ -666,6 +666,7 @@ impl super::OpenCADStudio { self.tabs[i].dirty = true; } self.refresh_properties(); + true } /// Discard the editor without changing the drawing. diff --git a/src/app/settings.rs b/src/app/settings.rs index b4bdcafe4..5dca802f1 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -82,6 +82,8 @@ pub struct UserSettings { /// Linked plugin source repositories (`owner/repo`) the marketplace installs /// from. pub plugin_repos: Vec, + /// Controls whether the TEXTEDIT command repeats automatically (0 = Multiple, 1 = Single). + pub texteditmode: bool, } impl Default for UserSettings { @@ -105,6 +107,7 @@ impl Default for UserSettings { default_assoc_prompted: false, disabled_plugins: Vec::new(), plugin_repos: Vec::new(), + texteditmode: false, } } } @@ -138,6 +141,13 @@ impl UserSettings { "osnap" => s.snap_enabled = val == "1", "otrack" => s.otrack = val == "1", "default_assoc_prompted" => s.default_assoc_prompted = val == "1", + "texteditmode" => { + if let Some(v) = + crate::modules::annotate::textedit::parse_texteditmode(val) + { + s.texteditmode = v; + } + } "disabled_plugins" => { s.disabled_plugins = val .split(',') @@ -179,7 +189,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -190,6 +200,7 @@ impl UserSettings { modes, self.disabled_plugins.join(","), self.plugin_repos.join(","), + self.texteditmode, ); let _ = std::fs::write(path, body); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index 949a0df2c..e66470631 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -98,6 +98,25 @@ pub struct TextInlineState { /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } +pub(super) fn can_edit_text(mut handle: Handle, document: &acadrust::CadDocument) -> bool { + for _ in 0..8 { + match document.get_entity(handle) { + Some(acadrust::EntityType::Leader(l)) => { + let ann = l.annotation_handle; + if ann.is_null() || ann == handle { + return false; + } + handle = ann; + } + _ => break, + } + } + if let Some(entity) = document.get_entity(handle) { + read_text_field(entity).is_some() + } else { + false + } +} impl super::OpenCADStudio { /// Open the right in-place editor for `handle`: the plain box for single- @@ -175,12 +194,12 @@ impl super::OpenCADStudio { /// Commit the editor: create a new TEXT entity or update the edited slot. /// Empty content drops a new entity and leaves an edited one untouched. - pub(super) fn text_inline_commit(&mut self) { + pub(super) fn text_inline_commit(&mut self) -> bool { let i = self.active_tab; - let Some(ed) = self.text_inline.take() else { return }; + let Some(ed) = self.text_inline.take() else { return false }; if ed.value.trim().is_empty() && ed.editing.is_none() { self.refresh_properties(); - return; + return false; } if let Some(h) = ed.editing { self.push_undo_snapshot(i, "TEXT"); @@ -211,6 +230,7 @@ impl super::OpenCADStudio { self.tabs[i].dirty = true; } self.refresh_properties(); + true } /// Discard the editor without changing the drawing. diff --git a/src/app/update.rs b/src/app/update.rs index 3af0d4d00..247550008 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -195,6 +195,7 @@ impl OpenCADStudio { v }, plugin_repos: self.plugin_repos.clone(), + texteditmode: self.texteditmode, } } @@ -210,9 +211,23 @@ impl OpenCADStudio { self.default_assoc_prompted = s.default_assoc_prompted; self.disabled_plugins = s.disabled_plugins.iter().cloned().collect(); self.plugin_repos = s.plugin_repos.clone(); + self.texteditmode = s.texteditmode; self.rebuild_ribbon_modules(); } + /// Check if a suspended command exists on the active tab and resume it + /// with the outcome of the text editor. + pub(super) fn post_editor_closed(&mut self, committed: bool) -> Task { + let i = self.active_tab; + if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { + let res = cmd.on_editor_closed(committed); + self.tabs[i].active_cmd = Some(cmd); + self.apply_cmd_result(res) + } else { + Task::none() + } + } + /// Rebuild the ribbon's tab list from the registry, dropping the tabs of any /// disabled plugins. Call after `disabled_plugins` changes. pub(super) fn rebuild_ribbon_modules(&mut self) { @@ -1806,12 +1821,12 @@ impl OpenCADStudio { // Open MText editor swallows Escape (cancel without committing). if self.mtext_editor.is_some() { self.mtext_cancel(); - return Task::none(); + return self.post_editor_closed(false); } // The in-place TEXT editor likewise cancels on Escape. if self.text_inline.is_some() { self.text_inline_cancel(); - return Task::none(); + return self.post_editor_closed(false); } // Grip popup intercepts Escape — dismisses the menu // without doing anything else. @@ -4905,12 +4920,12 @@ impl OpenCADStudio { Task::none() } Message::MTextOk => { - self.mtext_commit(); - Task::none() + let committed = self.mtext_commit(); + self.post_editor_closed(committed) } Message::MTextCancel => { self.mtext_cancel(); - Task::none() + self.post_editor_closed(false) } Message::TextInlineInput(s) => { @@ -4971,8 +4986,8 @@ impl OpenCADStudio { Task::none() } Message::TextInlineOk => { - self.text_inline_commit(); - Task::none() + let committed = self.text_inline_commit(); + self.post_editor_closed(committed) } Message::DrawOrderSubmenuToggle => { diff --git a/src/command/mod.rs b/src/command/mod.rs index 0552f8810..ee4215a4f 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -240,6 +240,12 @@ pub enum CmdResult { entity: EntityType, finish: bool, }, + /// Suspends command execution, moves it to suspended_cmd, and opens the text editor for the given handle. + SuspendForTextEdit { handle: Handle }, + /// Requests a standard document-level undo while keeping the command active. + UndoDocument, + /// Sets the TEXTEDITMODE system variable and ends the command. + SetTexteditMode(bool), } /// What kind of value the active command is currently asking for. Drives @@ -427,6 +433,11 @@ pub trait CadCommand: Send { false } + /// Called when the text editor closes, either because the user committed or cancelled the edit. + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::Cancel + } + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult { diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index e303ff3de..da00d7f31 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -23,6 +23,7 @@ pub mod qdim; pub mod radius_dim; pub mod table_cmd; pub mod text; +pub mod textedit; pub mod tolerance_cmd; use crate::modules::{CadModule, RibbonGroup, RibbonItem, StyleKey}; diff --git a/src/modules/annotate/textedit.rs b/src/modules/annotate/textedit.rs new file mode 100644 index 000000000..5828cb6bf --- /dev/null +++ b/src/modules/annotate/textedit.rs @@ -0,0 +1,253 @@ +// TEXTEDIT — edit multiline text, single-line text, or dimension text in-place. +// +// Workflow: +// 1. Enters a loop prompting to select an annotation object. +// 2. Accepts keyword options: Undo (to revert the last edit) and Mode (to switch Single/Multiple). +// 3. In Multiple mode (default), editing an object suspends the command, opens the editor, +// and when closed, resumes the command loop. +// 4. In Single mode, editing an object exits the command immediately. + +use acadrust::Handle; +use glam::Vec3; + +use crate::command::{CadCommand, CmdResult}; + +/// Parse a TEXTEDITMODE value. Accepts `0`/`m`/`multiple`/`false` (Multiple → false) +/// and `1`/`s`/`single`/`true` (Single → true), case-insensitive. Returns `None` +/// for anything else so callers can re-prompt or report the error. +pub fn parse_texteditmode(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "0" | "m" | "multiple" | "false" => Some(false), + "1" | "s" | "single" | "true" => Some(true), + _ => None, + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Step { + PickObject, + EnterMode, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TextEditMode { + Single, + Multiple, +} + +pub struct TexteditCommand { + mode: TextEditMode, + edit_count: usize, + step: Step, +} + +impl TexteditCommand { + pub fn new(texteditmode: bool) -> Self { + let mode = if texteditmode { + TextEditMode::Single + } else { + TextEditMode::Multiple + }; + Self { + mode, + edit_count: 0, + step: Step::PickObject, + } + } +} + +impl CadCommand for TexteditCommand { + fn name(&self) -> &'static str { + "TEXTEDIT" + } + + fn prompt(&self) -> String { + match self.step { + Step::PickObject => { + if self.edit_count == 0 { + "TEXTEDIT Select an annotation object or [Undo Mode]:".to_string() + } else { + "TEXTEDIT Select an annotation object or [Undo Mode] :".to_string() + } + } + Step::EnterMode => { + format!( + "TEXTEDIT Enter text edit mode [Single/Multiple] <{}>:", + match self.mode { + TextEditMode::Single => "Single", + TextEditMode::Multiple => "Multiple", + } + ) + } + } + } + + fn needs_entity_pick(&self) -> bool { + self.step == Step::PickObject + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + CmdResult::SuspendForTextEdit { handle } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + match self.step { + Step::PickObject => { + if text.is_empty() { + if self.edit_count > 0 { + return Some(CmdResult::Cancel); + } else { + return Some(CmdResult::NeedPoint); + } + } + + let lower = text.to_lowercase(); + if lower == "u" || lower == "undo" { + if self.edit_count > 0 { + self.edit_count -= 1; + return Some(CmdResult::UndoDocument); + } else { + return Some(CmdResult::NeedPoint); + } + } else if lower == "m" || lower == "mode" { + self.step = Step::EnterMode; + return Some(CmdResult::NeedPoint); + } + + Some(CmdResult::NeedPoint) + } + Step::EnterMode => { + if text.is_empty() { + self.step = Step::PickObject; + return Some(CmdResult::NeedPoint); + } + + let lower = text.to_lowercase(); + if lower == "s" || lower == "single" || lower == "1" { + self.mode = TextEditMode::Single; + self.step = Step::PickObject; + } else if lower == "m" || lower == "multiple" || lower == "0" { + self.mode = TextEditMode::Multiple; + self.step = Step::PickObject; + } + + Some(CmdResult::NeedPoint) + } + } + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::PickObject => { + if self.edit_count > 0 { + CmdResult::Cancel + } else { + CmdResult::NeedPoint + } + } + Step::EnterMode => { + self.step = Step::PickObject; + CmdResult::NeedPoint + } + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.edit_count += 1; + } + + match self.mode { + TextEditMode::Single => CmdResult::Cancel, + TextEditMode::Multiple => CmdResult::NeedPoint, + } + } +} + +pub struct TexteditmodeCommand { + current: bool, + /// Set when the last value entry was unrecognized, so the next prompt + /// leads with the error instead of silently re-asking. + invalid: bool, +} + +impl TexteditmodeCommand { + pub fn new(current: bool) -> Self { + Self { + current, + invalid: false, + } + } +} + +impl CadCommand for TexteditmodeCommand { + fn name(&self) -> &'static str { + "TEXTEDITMODE" + } + + fn prompt(&self) -> String { + let v = if self.current { 1 } else { 0 }; + let prefix = if self.invalid { + "Requires 0 OR 1 OR MULTIPLE OR SINGLE. " + } else { + "" + }; + format!("{prefix}TEXTEDITMODE Enter new value for TEXTEDITMODE <{v}>:") + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if text.is_empty() { + return Some(CmdResult::Cancel); + } + + match parse_texteditmode(text) { + Some(v) => Some(CmdResult::SetTexteditMode(v)), + // Unrecognized value: stay in the command and re-prompt with the + // error, rather than aborting via the Measurement arm. + None => { + self.invalid = true; + Some(CmdResult::NeedPoint) + } + } + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn dyn_field(&self) -> crate::command::DynField { + crate::command::DynField::Scalar + } +} + +// ── Autocomplete registry ───────────────────────────────── +inventory::submit!(crate::command::CommandRegistration { names: &["TEXTEDIT", "TEDIT", "TEXTEDITMODE"] });