feat: implement TEXTEDIT command and TEXTEDITMODE system variable

- Adds TEXTEDIT command to seamlessly edit annotation objects in-place.
- Matches industry-standard CAD tools' prompt behavior and "Current settings" banner logic.
- Implements the TEXTEDITMODE system variable (0/1) for Multiple vs Single editing modes.
- Enhances command lifecycle with CmdResult::SuspendForTextEdit and post_editor_closed hooks, allowing commands to cleanly pause and resume after external UI editors close.
This commit is contained in:
Karim Jerbi committed 2026-06-18 15:56:27 +01:00
1 parent 4d8ea95974
commit 9a258f29ce
9 files changed
+351 -6

No files matched your search

+40
View File
@@ -1617,6 +1617,46 @@ impl OpenCADStudio {
self.ribbon.deactivate_tool();
return self.begin_text_edit(handle);
}
CmdResult::SuspendForTextEdit { handle } => {
let is_editable = self.tabs[i]
.scene
.document
.get_entity(handle)
.map(|e| crate::app::text_inline::read_text_field(e).is_some())
.unwrap_or(false);
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) {
+31
View File
@@ -1429,6 +1429,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();
@@ -4907,6 +4923,21 @@ 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 val_str == "0" || val_str == "m" || val_str == "multiple" {
self.texteditmode = false;
self.command_line.push_output("TEXTEDITMODE set to 0");
} else if val_str == "1" || val_str == "s" || val_str == "single" {
self.texteditmode = true;
self.command_line.push_output("TEXTEDITMODE set to 1");
} 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() {
+2
View File
@@ -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<dyn Any + Send + Sync>>,
pub(super) suspended_cmd: Option<Box<dyn CadCommand>>,
}
impl DocumentTab {
@@ -213,6 +214,7 @@ impl DocumentTab {
last_synced_camera_gen: 0,
is_start: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
}
}
+3
View File
@@ -225,6 +225,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,
@@ -1557,6 +1559,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,
+13 -1
View File
@@ -81,6 +81,8 @@ pub struct UserSettings {
/// Linked plugin source repositories (`owner/repo`) the marketplace installs
/// from.
pub plugin_repos: Vec<String>,
/// 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,
}
}
}
@@ -139,6 +142,14 @@ impl UserSettings {
"osnap" => s.snap_enabled = val == "1",
"otrack" => s.otrack = val == "1",
"default_assoc_prompted" => s.default_assoc_prompted = val == "1",
"texteditmode" => {
let lower = val.to_lowercase();
if lower == "0" || lower == "m" || lower == "multiple" || lower == "false" {
s.texteditmode = false;
} else if lower == "1" || lower == "s" || lower == "single" || lower == "true" {
s.texteditmode = true;
}
}
"disabled_plugins" => {
s.disabled_plugins = val
.split(',')
@@ -180,7 +191,7 @@ impl UserSettings {
.collect::<Vec<_>>()
.join(",");
let body = format!(
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\n",
"dyn={}\northo={}\npolar={}\npolar_increment_deg={}\ngrid={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\n",
b(self.dyn_input),
b(self.ortho),
b(self.polar),
@@ -192,6 +203,7 @@ impl UserSettings {
modes,
self.disabled_plugins.join(","),
self.plugin_repos.join(","),
self.texteditmode,
);
let _ = std::fs::write(path, body);
}
+20 -5
View File
@@ -196,6 +196,7 @@ impl OpenCADStudio {
v
},
plugin_repos: self.plugin_repos.clone(),
texteditmode: self.texteditmode,
}
}
@@ -212,9 +213,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<Message> {
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) {
@@ -1704,12 +1719,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.
@@ -4679,11 +4694,11 @@ impl OpenCADStudio {
}
Message::MTextOk => {
self.mtext_commit();
Task::none()
self.post_editor_closed(true)
}
Message::MTextCancel => {
self.mtext_cancel();
Task::none()
self.post_editor_closed(false)
}
Message::TextInlineInput(s) => {
@@ -4694,7 +4709,7 @@ impl OpenCADStudio {
}
Message::TextInlineOk => {
self.text_inline_commit();
Task::none()
self.post_editor_closed(true)
}
Message::DrawOrderSubmenuToggle => {
+11
View File
@@ -225,6 +225,12 @@ pub enum CmdResult {
/// The host should look up the attdefs for `block_name` from the document
/// and call `attreq_set_attdefs()` on the command, then loop on text input.
AttreqNeeded { block_name: String },
/// 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
@@ -411,6 +417,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 {
+1
View File
@@ -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};
+230
View File
@@ -0,0 +1,230 @@
// 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};
#[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] <exit>:".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<CmdResult> {
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,
}
impl TexteditmodeCommand {
pub fn new(current: bool) -> Self {
Self { current }
}
}
impl CadCommand for TexteditmodeCommand {
fn name(&self) -> &'static str {
"TEXTEDITMODE"
}
fn prompt(&self) -> String {
let v = if self.current { 1 } else { 0 };
format!("TEXTEDITMODE Enter new value for TEXTEDITMODE <{}>:", v)
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let text = text.trim();
if text.is_empty() {
return Some(CmdResult::Cancel);
}
let lower = text.to_lowercase();
if lower == "0" || lower == "m" || lower == "multiple" {
return Some(CmdResult::SetTexteditMode(false));
} else if lower == "1" || lower == "s" || lower == "single" {
return Some(CmdResult::SetTexteditMode(true));
}
Some(CmdResult::Measurement("Requires 0 OR 1 OR MULTIPLE OR SINGLE".to_string()))
}
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"] });