From fbedb3736e6d78d90a5809bed8dd0e2aa6d804e0 Mon Sep 17 00:00:00 2001 From: yztink Date: Sun, 20 Sep 2026 21:03:12 -0700 Subject: [PATCH 1/4] feat(plugin): AutoCAD-compatible PAUSE, ENTER keyword, and command streaming --- crates/ocs_plugin_api/src/host.rs | 6 + crates/ocs_plugin_api/src/ipc/client.rs | 14 ++ crates/ocs_plugin_api/src/ipc/protocol.rs | 2 + crates/ocs_plugin_api/src/ipc/server.rs | 1 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 14 ++ src/app/command_driver.rs | 64 ++++++++- src/app/document.rs | 3 + src/app/plugin_host.rs | 149 +++++++++++++++++++++ 8 files changed, 250 insertions(+), 3 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index c64ff4e91..0434994a2 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -568,6 +568,12 @@ pub trait HostApi { let _ = config; false } + + /// Run a command on the active document tab's command line (AutoLISP style). + fn execute_command(&mut self, cmd: &str) -> bool { + let _ = cmd; + false + } } /// Configuration properties for creating or modifying a layer. diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index 0a7baa779..d9713873b 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -444,6 +444,20 @@ impl HostApi for PluginHostApi { } } } + + fn execute_command(&mut self, cmd: &str) -> bool { + match self.client.request(PluginRequest::ExecuteCommand(cmd.to_string())) { + Ok(PluginResponse::Bool(b)) => b, + Ok(other) => { + eprintln!("[plugin] unexpected ExecuteCommand response: {other:?}"); + false + } + Err(e) => { + eprintln!("[plugin] ExecuteCommand request failed: {e}"); + false + } + } + } } /// Sentinel reader used when the shared-memory view could not be initialized. diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 81a1ea3cf..abba64439 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -142,6 +142,8 @@ pub enum PluginRequest { AddLayer(crate::host::LayerConfig), /// Modify specified properties of an existing layer in the active document. ModifyLayer(crate::host::LayerConfig), + /// Run a command on the active document tab's command line (AutoLISP style). + ExecuteCommand(String), } /// Responses the host sends back for `PluginRequest`. diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 05d03998f..8bea17852 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -79,5 +79,6 @@ pub fn handle_plugin_request( ), AddLayer(config) => PluginResponse::OptHandle(host.add_layer(config)), ModifyLayer(config) => PluginResponse::Bool(host.modify_layer(config)), + ExecuteCommand(cmd) => PluginResponse::Bool(host.execute_command(&cmd)), } } diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 1e5149c53..bcd201a61 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -758,6 +758,20 @@ impl HostApi for V4PluginHostApi { } } } + + fn execute_command(&mut self, cmd: &str) -> bool { + match self.request(PluginRequest::ExecuteCommand(cmd.to_string())) { + Ok(PluginResponse::Bool(b)) => b, + Ok(other) => { + eprintln!("[plugin] unexpected ExecuteCommand response: {other:?}"); + false + } + Err(e) => { + eprintln!("[plugin] ExecuteCommand failed: {e}"); + false + } + } + } } #[cfg(test)] diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0e21a8e6e..0b93ed8f5 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -530,6 +530,7 @@ impl OpenCADStudio { } else { None }; + let is_escape = matches!(&input, StepInput::Escape); let result: Option = { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { return Task::none(); @@ -552,12 +553,58 @@ impl OpenCADStudio { self.record_dimension_entity_points(i, None, handle, Vec::new()); } self.sync_dimension_snaps(i); - match result { + if is_escape { + self.tabs[i].pending_pause_tokens = None; + } + let task = match result { Some(r) => self.apply_cmd_result(r), None => Task::none(), + }; + if !is_escape && self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { + let drain_task = self.drain_pending_pause_tokens(i); + Task::batch([task, drain_task]) + } else { + task } } + /// Process queued tokens buffered after a PAUSE (or `\`) until the queue is + /// empty or another PAUSE / cancellation is encountered. + pub(super) fn drain_pending_pause_tokens(&mut self, tab_idx: usize) -> Task { + let Some(mut tokens) = self.tabs[tab_idx].pending_pause_tokens.take() else { + return Task::none(); + }; + let mut tasks = Vec::new(); + while !tokens.is_empty() { + if self.tabs[tab_idx].active_cmd.is_none() { + break; + } + let token = tokens.remove(0); + let trimmed = token.trim(); + if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" { + self.tabs[tab_idx].pending_pause_tokens = Some(tokens); + break; + } + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("ENTER") + || trimmed.eq_ignore_ascii_case("RETURN") + || trimmed == "\n" + { + tasks.push(self.feed_command(StepInput::Enter)); + } else if trimmed.eq_ignore_ascii_case("ESC") + || trimmed.eq_ignore_ascii_case("ESCAPE") + || trimmed.eq_ignore_ascii_case("CANCEL") + { + self.tabs[tab_idx].pending_pause_tokens = None; + tasks.push(self.feed_command(StepInput::Escape)); + break; + } else { + tasks.push(self.feed_active_cmd(&token)); + } + } + Task::batch(tasks) + } + /// Run one whole command-line string. A single word or an inline-argument /// command (`PDMODE 3`, `LAYER Walls`, `UCS Z 90` pasted as one line) /// dispatches as-is; for a multi-token line whose first word starts an @@ -624,10 +671,19 @@ impl OpenCADStudio { let mut tasks = Vec::new(); // First token is the command verb itself; prompts start consuming after it. let mut consumed = 1; - for tok in &tokens[1..] { + let mut paused = false; + for (idx, tok) in tokens[1..].iter().enumerate() { if self.tabs[i].active_cmd.is_none() { break; } + let trimmed = tok.trim(); + if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" { + let remainder: Vec = tokens[1 + idx + 1..].to_vec(); + self.tabs[i].pending_pause_tokens = Some(remainder); + consumed = tokens.len(); + paused = true; + break; + } tasks.push(self.feed_active_cmd(tok)); consumed += 1; } @@ -658,7 +714,9 @@ impl OpenCADStudio { if consumed < tokens.len() { self.command_line.unconsumed = tokens[consumed..].to_vec(); } - tasks.push(self.feed_command(StepInput::Enter)); + if !paused { + tasks.push(self.feed_command(StepInput::Enter)); + } Task::batch(tasks) } diff --git a/src/app/document.rs b/src/app/document.rs index e7f9041ed..be6004a79 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -151,6 +151,8 @@ pub(super) struct DocumentTab { pub(super) properties: PropertiesPanel, pub(super) layers: LayerPanel, pub(super) active_cmd: Option>, + /// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction. + pub(super) pending_pause_tokens: Option>, /// The selection set the most recent command worked on, captured when a /// finishing command drops the live selection — re-selectable with the /// "Previous" keyword at any Select objects prompt (#426). @@ -611,6 +613,7 @@ impl DocumentTab { properties: PropertiesPanel::empty(), layers: LayerPanel::default(), active_cmd: None, + pending_pause_tokens: None, last_cmd: None, last_draw_anchor: None, snap_result: None, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 0e08b5ae7..09c5cdd26 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -370,6 +370,102 @@ impl<'a> HostSession<'a> { self.publish_document_view(); true } + + /// Run a command string on the active tab's command line (AutoLISP style). + /// Supports AutoCAD-style `PAUSE` (and `\`) input queuing, explicit `ENTER` / + /// `RETURN` tokens, and `\n` trailing newline execution. + pub fn execute_command(&mut self, cmd: &str) -> bool { + let has_newline = cmd.ends_with('\n') || cmd.ends_with('\r'); + let trimmed = cmd.trim(); + + // 1. ESC / CANCEL handling + if trimmed.eq_ignore_ascii_case("ESC") + || trimmed.eq_ignore_ascii_case("ESCAPE") + || trimmed.eq_ignore_ascii_case("CANCEL") + { + self.app.tabs[self.tab].pending_pause_tokens = None; + let _ = self.app.feed_command(crate::command::StepInput::Escape); + self.publish_document_view(); + return true; + } + + // 2. If a PAUSE is already pending for this tab: + if let Some(ref mut queue) = self.app.tabs[self.tab].pending_pause_tokens { + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("ENTER") + || trimmed.eq_ignore_ascii_case("RETURN") + { + queue.push("ENTER".to_string()); + } else { + for part in trimmed.split_whitespace() { + queue.push(part.to_string()); + } + if has_newline { + queue.push("ENTER".to_string()); + } + } + return true; + } + + // 3. ENTER / RETURN / empty string handling when no pause is pending + if trimmed.is_empty() + || trimmed.eq_ignore_ascii_case("ENTER") + || trimmed.eq_ignore_ascii_case("RETURN") + { + if self.app.tabs[self.tab].active_cmd.is_some() { + let _ = self.app.feed_command(crate::command::StepInput::Enter); + self.publish_document_view(); + return true; + } + return false; + } + + // 4. Command execution + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.is_empty() { + return false; + } + + let tab = self.tab; + let is_new_cmd = self.app.tabs[tab].active_cmd.is_none(); + if is_new_cmd { + let cmd_name = parts[0]; + let resolved = self.app.resolve_alias(cmd_name); + let effective = resolved.as_deref().unwrap_or(cmd_name); + let _ = self.app.dispatch_command(effective); + } + + let mut paused = false; + let start_idx = if is_new_cmd { 1 } else { 0 }; + for (idx, &part) in parts.iter().skip(start_idx).enumerate() { + if self.app.tabs[tab].active_cmd.is_none() { + break; + } + if part.eq_ignore_ascii_case("PAUSE") || part == "\\" { + let mut remainder: Vec = + parts[start_idx + idx + 1..].iter().map(|s| s.to_string()).collect(); + if has_newline { + remainder.push("ENTER".to_string()); + } + self.app.tabs[tab].pending_pause_tokens = Some(remainder); + paused = true; + break; + } + if part.eq_ignore_ascii_case("ENTER") || part.eq_ignore_ascii_case("RETURN") { + let _ = self.app.feed_command(crate::command::StepInput::Enter); + } else { + let _ = self.app.feed_active_cmd(part); + } + } + + if !paused && has_newline && self.app.tabs[tab].active_cmd.is_some() { + let _ = self.app.feed_command(crate::command::StepInput::Enter); + } + + self.app.refresh_layer_panel(); + self.publish_document_view(); + true + } } /// The stored spelling of `name` in the drawing's linetype table, loading the @@ -490,6 +586,9 @@ impl HostApi for HostSession<'_> { fn modify_layer(&mut self, config: ocs_plugin_api::host::LayerConfig) -> bool { self.modify_layer(config) } + fn execute_command(&mut self, cmd: &str) -> bool { + self.execute_command(cmd) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -1049,4 +1148,54 @@ mod tests { }; assert!(!host.modify_layer(non_existent)); } + + #[test] + fn test_plugin_execute_command() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + + // 1. Single-line command with newline finishes command + assert!(host.execute_command("LINE 0,0 10,10\n")); + assert!(host.app.tabs[0].active_cmd.is_none()); + assert_eq!(host.document().entities().count(), 1); + + // 2. Streamed command without newline leaves tool active + assert!(host.execute_command("LINE 10,10")); + assert!(host.app.tabs[0].active_cmd.is_some()); + // Feed next point without newline + assert!(host.execute_command("20,20")); + assert!(host.app.tabs[0].active_cmd.is_some()); + // Send explicit ENTER + assert!(host.execute_command("ENTER")); + assert!(host.app.tabs[0].active_cmd.is_none()); + assert_eq!(host.document().entities().count(), 2); + + // 3. Command with PAUSE buffers remaining tokens until point is provided + assert!(host.execute_command("LINE 20,20 PAUSE ENTER")); + assert!(host.app.tabs[0].active_cmd.is_some()); + assert_eq!( + host.app.tabs[0].pending_pause_tokens, + Some(vec!["ENTER".to_string()]) + ); + // User clicks second point in viewport + let _ = host + .app + .feed_command(crate::command::StepInput::Point(glam::DVec3::new(30.0, 30.0, 0.0))); + // Draining should have executed ENTER and completed LINE + assert!(host.app.tabs[0].active_cmd.is_none()); + assert_eq!(host.document().entities().count(), 3); + + // 4. Command with PAUSE and trailing \n + assert!(host.execute_command("LINE 30,30 PAUSE\n")); + assert!(host.app.tabs[0].active_cmd.is_some()); + assert_eq!( + host.app.tabs[0].pending_pause_tokens, + Some(vec!["ENTER".to_string()]) + ); + // Escape cancels paused command cleanly + assert!(host.execute_command("ESC")); + assert!(host.app.tabs[0].active_cmd.is_none()); + assert_eq!(host.app.tabs[0].pending_pause_tokens, None); + } } From c96de124b65f4e6771df156ca6e992359308c26c Mon Sep 17 00:00:00 2001 From: yztink Date: Sun, 20 Sep 2026 21:31:16 -0700 Subject: [PATCH 2/4] fix(plugin): prevent re-entrant plugin dispatch deadlock in execute_command --- src/app/commands/mod.rs | 15 ++++++++++----- src/app/plugin_host.rs | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index cc75d7fe1..f553fecc0 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -43,7 +43,12 @@ impl OpenCADStudio { } pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, false) + self.dispatch_command_inner(cmd, false, false) + } + + /// Dispatch a command skipping external plugin dispatch to prevent reentrant deadlocks. + pub(super) fn dispatch_command_without_plugins(&mut self, cmd: &str) -> Task { + self.dispatch_command_inner(cmd, false, true) } /// Dispatch a verb typed at the interactive command line, falling back to @@ -55,10 +60,10 @@ impl OpenCADStudio { /// callers (ribbon, plugins, headless automation) use `dispatch_command` /// and never get silent substitution. pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, true) + self.dispatch_command_inner(cmd, true, false) } - fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task { + fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool, skip_plugins: bool) -> Task { let i = self.active_tab; // Expand a command alias ("L" → "LINE") on the leading token before any // routing, so every path below (Start-tab gate, plugins, all dispatch @@ -186,7 +191,7 @@ impl OpenCADStudio { return Task::none(); } - if crate::plugin::try_dispatch(self, i, cmd) { + if !skip_plugins && crate::plugin::try_dispatch(self, i, cmd) { // try_dispatch returns true for both finished commands and interactive // commands that it just installed. If no command is now active, the // tool was a one-shot and we must turn the ribbon highlight off here — @@ -225,7 +230,7 @@ impl OpenCADStudio { .next() { if !top.eq_ignore_ascii_case(cmd) { - return self.dispatch_command_inner(&top, false); + return self.dispatch_command_inner(&top, false, skip_plugins); } } } diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 09c5cdd26..f50981609 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -432,7 +432,7 @@ impl<'a> HostSession<'a> { let cmd_name = parts[0]; let resolved = self.app.resolve_alias(cmd_name); let effective = resolved.as_deref().unwrap_or(cmd_name); - let _ = self.app.dispatch_command(effective); + let _ = self.app.dispatch_command_without_plugins(effective); } let mut paused = false; From bc6ad1a5fed61e32944540b5f888995fae0b169b Mon Sep 17 00:00:00 2001 From: yztink Date: Sun, 20 Sep 2026 21:51:25 -0700 Subject: [PATCH 3/4] fix(command): drain pending pause tokens on viewport click and coordinate input --- src/app/command_driver.rs | 16 ++++++++-------- src/app/plugin_host.rs | 13 ++++++++----- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0b93ed8f5..c921e4615 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -556,15 +556,9 @@ impl OpenCADStudio { if is_escape { self.tabs[i].pending_pause_tokens = None; } - let task = match result { + match result { Some(r) => self.apply_cmd_result(r), None => Task::none(), - }; - if !is_escape && self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { - let drain_task = self.drain_pending_pause_tokens(i); - Task::batch([task, drain_task]) - } else { - task } } @@ -1075,8 +1069,14 @@ impl OpenCADStudio { self.tabs[i].scene.set_hover_highlight(None); self.command_line.set_step_options(Vec::new()); self.restore_add_selected_defaults(); + self.tabs[i].pending_pause_tokens = None; + } + if self.tabs[i].active_cmd.is_some() && self.tabs[i].pending_pause_tokens.is_some() { + let drain_task = self.drain_pending_pause_tokens(i); + Task::batch([task, drain_task]) + } else { + task } - task } /// Resolve the cell under `click` on table `handle` (or any table in the drawing diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index f50981609..c38f339d7 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1178,11 +1178,14 @@ mod tests { host.app.tabs[0].pending_pause_tokens, Some(vec!["ENTER".to_string()]) ); - // User clicks second point in viewport - let _ = host - .app - .feed_command(crate::command::StepInput::Point(glam::DVec3::new(30.0, 30.0, 0.0))); - // Draining should have executed ENTER and completed LINE + // User clicks second point in viewport (which calls on_point + apply_cmd_result) + let r = host.app.tabs[0] + .active_cmd + .as_mut() + .unwrap() + .on_point(glam::DVec3::new(30.0, 30.0, 0.0)); + let _ = host.app.apply_cmd_result(r); + // Draining in apply_cmd_result should have executed ENTER and completed LINE assert!(host.app.tabs[0].active_cmd.is_none()); assert_eq!(host.document().entities().count(), 3); From 270b6102dec2bc89c82a626940ce080773c30825 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 21 Sep 2026 16:29:01 -0700 Subject: [PATCH 4/4] fix(plugin): drive headless tasks, invalidate layer cache, and handle cancel event --- crates/ocs_plugin_api/README.md | 11 +++ crates/ocs_plugin_api/src/host.rs | 16 +++- crates/ocs_plugin_api/src/ipc/client.rs | 14 ++- crates/ocs_plugin_api/src/ipc/protocol.rs | 5 ++ crates/ocs_plugin_api/src/ipc/v4/client.rs | 14 ++- crates/ocs_plugin_api/src/runner.rs | 54 ++++++++++-- src/app/command_driver.rs | 2 +- src/app/commands/mod.rs | 2 +- src/app/commands/styleprops.rs | 11 +++ src/app/plugin_host.rs | 99 ++++++++++++++++++++-- 10 files changed, 208 insertions(+), 20 deletions(-) diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index e1d29878d..2ad586863 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -425,6 +425,17 @@ std::thread::spawn(move || { // Pass `port` to the child process via environment variable. ``` +### Command execution and layer management + +Plugins can execute native CAD commands and manage drawing tables through `HostApi`: + +- **Command Execution (`HostApi::execute_command`)**: + Executes AutoCAD-compatible commands, scripts, and tokens on the active document tab's command driver (e.g. `LINE 0,0 10,10`, `SETVAR PDMODE 3`, `VSCURRENT FLATSHADED`, `GRID ON`). The host automatically executes any resulting asynchronous headless tasks (such as render mode updates and grid state changes) through the application message loop without freezing the UI. +- **Layer Creation (`HostApi::add_layer`)**: + Creates a new layer in the document table with optional color, visibility, and line weight settings. Returns `Some(Handle)` on success, or `None` if the layer name already exists or is invalid. +- **Layer Modification (`HostApi::modify_layer`)**: + Updates properties of an existing layer in-place. Properties left as `None` remain unchanged. Returns `true` on success, or `false` if the layer does not exist. + ### Further reading - Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 0434994a2..951900621 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -556,6 +556,8 @@ pub trait HostApi { /// Add a layer to the active document with full initial properties. /// If an optional property in `config` is `None`, standard CAD defaults are applied. /// Returns `None` if the layer already exists or `config.name` is invalid. + /// + /// To modify properties of an already existing layer, use [`modify_layer`](Self::modify_layer). fn add_layer(&mut self, config: LayerConfig) -> Option { let _ = config; None @@ -564,12 +566,24 @@ pub trait HostApi { /// Modify specified properties of an existing layer in the active document. /// Properties that are `None` in `config` are left untouched as-is. /// Returns `false` if the layer does not exist or `config.name` is invalid. + /// + /// To create a new layer, use [`add_layer`](Self::add_layer). fn modify_layer(&mut self, config: LayerConfig) -> bool { let _ = config; false } - /// Run a command on the active document tab's command line (AutoLISP style). + /// Run a command on the active document tab's command line (AutoLISP / script style). + /// + /// The string is passed to the host command driver as if entered into the command line, + /// supporting command names, space/newline-delimited arguments, AutoCAD-compatible + /// aliases, system variable setters (e.g. `SETVAR PDMODE 3`), and display commands + /// (e.g. `VSCURRENT`, `GRID`, `SNAP`). + /// + /// Headless async tasks spawned by commands are automatically pumped through the host + /// application lifecycle. + /// + /// Returns `true` if the command was recognized and initiated successfully, `false` otherwise. fn execute_command(&mut self, cmd: &str) -> bool { let _ = cmd; false diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index d9713873b..cf86f02a5 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -419,7 +419,12 @@ impl HostApi for PluginHostApi { fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option { match self.client.request(PluginRequest::AddLayer(config)) { - Ok(PluginResponse::OptHandle(h)) => h, + Ok(PluginResponse::OptHandle(h)) => { + if h.is_some() { + self.document_cache = OnceCell::new(); + } + h + } Ok(other) => { eprintln!("[plugin] unexpected AddLayer response: {other:?}"); None @@ -433,7 +438,12 @@ impl HostApi for PluginHostApi { fn modify_layer(&mut self, config: crate::host::LayerConfig) -> bool { match self.client.request(PluginRequest::ModifyLayer(config)) { - Ok(PluginResponse::Bool(b)) => b, + Ok(PluginResponse::Bool(b)) => { + if b { + self.document_cache = OnceCell::new(); + } + b + } Ok(other) => { eprintln!("[plugin] unexpected ModifyLayer response: {other:?}"); false diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index abba64439..710731cfc 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -27,9 +27,14 @@ pub use acadrust::{CadDocument, EntityType, Handle}; /// Events the host forwards to an active plugin `InteractiveCommand`. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum InteractiveEvent { + /// User clicked or specified a point coordinate. Point([f64; 3]), + /// User pressed Enter or Return to complete input. Enter, + /// User selected an existing entity in the drawing. ObjectPick { handle: Handle, pt: [f64; 3] }, + /// User cancelled the prompt (e.g. pressed ESC), resetting interactive collection. + Cancel, } /// Initial handshake sent by the plugin runner immediately after connecting. diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index bcd201a61..20e3d235b 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -733,7 +733,12 @@ impl HostApi for V4PluginHostApi { fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option { match self.request(PluginRequest::AddLayer(config)) { - Ok(PluginResponse::OptHandle(h)) => h, + Ok(PluginResponse::OptHandle(h)) => { + if h.is_some() { + self.document_cache = OnceCell::new(); + } + h + } Ok(other) => { eprintln!("[plugin] unexpected AddLayer response: {other:?}"); None @@ -747,7 +752,12 @@ impl HostApi for V4PluginHostApi { fn modify_layer(&mut self, config: crate::host::LayerConfig) -> bool { match self.request(PluginRequest::ModifyLayer(config)) { - Ok(PluginResponse::Bool(b)) => b, + Ok(PluginResponse::Bool(b)) => { + if b { + self.document_cache = OnceCell::new(); + } + b + } Ok(other) => { eprintln!("[plugin] unexpected ModifyLayer response: {other:?}"); false diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index b1d89ab0c..296f70e7d 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -22,7 +22,7 @@ use std::collections::HashMap; use std::path::Path; use std::rc::Rc; -use crate::host::{BuiltinPlugin, InteractiveCommand}; +use crate::host::{BuiltinPlugin, CommandStep, InteractiveCommand}; use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi}; use crate::ipc::protocol::{ HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV, @@ -170,7 +170,11 @@ fn handle_host_request( } } HostRequest::InteractiveEvent { command_id, event } => { - let step = { + if matches!(event, InteractiveEvent::Cancel) { + interactive.borrow_mut().remove(&command_id); + return HostResponse::CommandStep(Box::new(CommandStep::Cancel)); + } + let (step, done) = { let mut registry = interactive.borrow_mut(); let Some(cmd) = registry.get_mut(&command_id) else { return HostResponse::Error(format!( @@ -178,14 +182,30 @@ fn handle_host_request( )); }; let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut(); - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { InteractiveEvent::Point(pt) => cmd_ref.on_point(pt), InteractiveEvent::Enter => cmd_ref.on_enter(), InteractiveEvent::ObjectPick { handle, pt } => { cmd_ref.on_object_pick(handle, pt) } - })) + InteractiveEvent::Cancel => CommandStep::Cancel, + })); + match res { + Ok(s) => { + let is_done = matches!( + s, + CommandStep::Done + | CommandStep::Cancel + | CommandStep::CommitAndEnd(_) + ); + (Ok(s), is_done) + } + Err(_) => (Err(()), true), + } }; + if done { + interactive.borrow_mut().remove(&command_id); + } match step { Ok(s) => HostResponse::CommandStep(Box::new(s)), Err(_) => HostResponse::Error("interactive command panicked".to_string()), @@ -269,7 +289,11 @@ fn handle_host_request_v4( } } HostRequest::InteractiveEvent { command_id, event } => { - let step = { + if matches!(event, InteractiveEvent::Cancel) { + interactive.borrow_mut().remove(&command_id); + return Some(HostResponse::CommandStep(Box::new(CommandStep::Cancel))); + } + let (step, done) = { let mut registry = interactive.borrow_mut(); let Some(cmd) = registry.get_mut(&command_id) else { return Some(HostResponse::Error(format!( @@ -277,14 +301,30 @@ fn handle_host_request_v4( ))); }; let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut(); - std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { InteractiveEvent::Point(pt) => cmd_ref.on_point(pt), InteractiveEvent::Enter => cmd_ref.on_enter(), InteractiveEvent::ObjectPick { handle, pt } => { cmd_ref.on_object_pick(handle, pt) } - })) + InteractiveEvent::Cancel => CommandStep::Cancel, + })); + match res { + Ok(s) => { + let is_done = matches!( + s, + CommandStep::Done + | CommandStep::Cancel + | CommandStep::CommitAndEnd(_) + ); + (Ok(s), is_done) + } + Err(_) => (Err(()), true), + } }; + if done { + interactive.borrow_mut().remove(&command_id); + } match step { Ok(s) => Some(HostResponse::CommandStep(Box::new(s))), Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())), diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index c921e4615..7c5e5c2b3 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -4872,7 +4872,7 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); - dispatched = self.dispatch_command(&cmd); + dispatched = self.dispatch_command_without_plugins(&cmd); } CmdResult::EditTableCell { handle, point } => { // TABLEDIT's pick: end the pick phase and hand (table, point) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index f553fecc0..97cd4ed3d 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -244,7 +244,7 @@ impl OpenCADStudio { /// `Some(task)` for an arm it owns (early-returning or falling through to /// `finish_dispatch`), or `None` to defer to the next — equivalent to one /// sequential `match` over all arms. - fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option> { + pub(crate) fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option> { if let Some(t) = self.dispatch_fileops(cmd, i) { return Some(t); } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 0b5bc9b14..f2393883f 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2710,8 +2710,19 @@ impl OpenCADStudio { .push_output(crate::tf!("CLAYER = \"{cur}\"").as_ref()); } else { if self.tabs[i].scene.document.layers.contains(name_arg) { + let handle = self.tabs[i] + .scene + .document + .layers + .get(name_arg) + .map(|l| l.handle) + .unwrap_or(acadrust::types::Handle::NULL); self.tabs[i].scene.document.header.current_layer_name = name_arg.to_string(); + self.tabs[i].scene.document.header.current_layer_handle = handle; + self.tabs[i].active_layer = name_arg.to_string(); + self.tabs[i].layers.current_layer = name_arg.to_string(); + self.ribbon.active_layer = name_arg.to_string(); self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("CLAYER set to \"{name_arg}\"").as_ref()); diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index c38f339d7..1d0705f7a 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -429,10 +429,23 @@ impl<'a> HostSession<'a> { let tab = self.tab; let is_new_cmd = self.app.tabs[tab].active_cmd.is_none(); if is_new_cmd { + // Check if the complete command string is handled directly by command families + // (e.g. "CLAYER TEST", "ZOOM EXTENTS", "COLOR RED", "VSCURRENT FLATSHADED", etc.) without needing token-by-token feeding + let full_resolved = self.app.resolve_alias(trimmed); + let full_effective = full_resolved.as_deref().unwrap_or(trimmed); + if let Some(task) = self.app.dispatch_families(full_effective, tab) { + let _ = self.app.drive_headless_task(task); + self.app.command_line.record_recent(full_effective); + self.app.refresh_layer_panel(); + self.publish_document_view(); + return true; + } + let cmd_name = parts[0]; let resolved = self.app.resolve_alias(cmd_name); let effective = resolved.as_deref().unwrap_or(cmd_name); - let _ = self.app.dispatch_command_without_plugins(effective); + let task = self.app.dispatch_command_without_plugins(effective); + let _ = self.app.drive_headless_task(task); } let mut paused = false; @@ -451,15 +464,17 @@ impl<'a> HostSession<'a> { paused = true; break; } - if part.eq_ignore_ascii_case("ENTER") || part.eq_ignore_ascii_case("RETURN") { - let _ = self.app.feed_command(crate::command::StepInput::Enter); + let task = if part.eq_ignore_ascii_case("ENTER") || part.eq_ignore_ascii_case("RETURN") { + self.app.feed_command(crate::command::StepInput::Enter) } else { - let _ = self.app.feed_active_cmd(part); - } + self.app.feed_active_cmd(part) + }; + let _ = self.app.drive_headless_task(task); } if !paused && has_newline && self.app.tabs[tab].active_cmd.is_some() { - let _ = self.app.feed_command(crate::command::StepInput::Enter); + let task = self.app.feed_command(crate::command::StepInput::Enter); + let _ = self.app.drive_headless_task(task); } self.app.refresh_layer_panel(); @@ -646,6 +661,7 @@ pub(crate) struct PluginProcessInteractiveAdapter { pub command_id: u64, prompt: Option, needs_entity_pick: Option, + is_done: bool, } impl PluginProcessInteractiveAdapter { @@ -660,6 +676,7 @@ impl PluginProcessInteractiveAdapter { command_id, prompt, needs_entity_pick, + is_done: false, } } @@ -667,6 +684,20 @@ impl PluginProcessInteractiveAdapter { self.prompt = self.process.get_prompt(self.command_id).ok(); self.needs_entity_pick = self.process.needs_entity_pick(self.command_id).ok(); } + + fn cancel(&mut self) { + if !self.is_done { + self.is_done = true; + use ocs_plugin_api::ipc::protocol::InteractiveEvent; + let _ = self.process.interactive_event(self.command_id, InteractiveEvent::Cancel); + } + } +} + +impl Drop for PluginProcessInteractiveAdapter { + fn drop(&mut self) { + self.cancel(); + } } impl crate::command::CadCommand for PluginProcessInteractiveAdapter { @@ -686,6 +717,9 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { ) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); + if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + self.is_done = true; + } self.refresh(); result } @@ -696,9 +730,16 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { .interactive_event(self.command_id, InteractiveEvent::Enter) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); + if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + self.is_done = true; + } self.refresh(); result } + fn on_escape(&mut self) -> crate::command::CmdResult { + self.cancel(); + crate::command::CmdResult::Cancel + } fn needs_entity_pick(&self) -> bool { self.needs_entity_pick.unwrap_or(false) } @@ -715,6 +756,9 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter { ) .map(plugin_step_to_result) .unwrap_or(crate::command::CmdResult::Cancel); + if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) { + self.is_done = true; + } self.refresh(); result } @@ -1200,5 +1244,48 @@ mod tests { assert!(host.execute_command("ESC")); assert!(host.app.tabs[0].active_cmd.is_none()); assert_eq!(host.app.tabs[0].pending_pause_tokens, None); + + // 5. CLAYER command + host.add_layer(ocs_plugin_api::host::LayerConfig { + name: "TEST".to_string(), + ..Default::default() + }); + assert!(host.execute_command("CLAYER TEST\n")); + assert_eq!(host.document().header.current_layer_name, "TEST"); + + // 6. Non-typical command: POINT + assert!(host.execute_command("POINT 50,50\n")); + assert!(host.app.tabs[0].active_cmd.is_none()); + assert_eq!(host.document().entities().count(), 4); + + // 7. Non-typical command: DONUT + assert!(host.execute_command("DONUT 10 30 100,100 \n")); + assert!(host.app.tabs[0].active_cmd.is_none()); + assert!(host.document().entities().count() >= 5); + + // 8. Non-typical command: ELLIPSE + assert!(host.execute_command("ELLIPSE 0,0 80,0 30\n")); + assert!(host.app.tabs[0].active_cmd.is_none()); + + // 9. Non-typical inline commands: UCS + assert!(host.execute_command("UCS ORIGIN 50,50,0\n")); + assert!(host.app.tabs[0].active_ucs.is_some()); + assert!(host.execute_command("UCS W\n")); + assert!(host.app.tabs[0].active_ucs.is_none()); + + // 10. Non-typical inline commands: SETVAR + assert!(host.execute_command("SETVAR PDMODE 35\n")); + + // 11. Viewport visual style via drive_headless_task + assert!(host.execute_command("VSCURRENT FLATSHADED\n")); + assert_eq!( + host.app.tabs[0].render_mode, + ocs_plugin_api::host::acadrust::entities::ViewportRenderMode::FlatShaded + ); + + // 12. Drafting aids toggle via drive_headless_task + let initial_grid = host.app.show_grid; + assert!(host.execute_command("GRID\n")); + assert_eq!(host.app.show_grid, !initial_grid); } }