fix(plugin): drive headless tasks, invalidate layer cache, and handle cancel event

This commit is contained in:
yztink committed 2026-09-21 16:29:01 -07:00
1 parent bc6ad1a5fe
commit 270b6102de
10 files changed
+208 -20

No files matched your search

+11
View File
@@ -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)
+15 -1
View File
@@ -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<Handle> {
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
+12 -2
View File
@@ -419,7 +419,12 @@ impl HostApi for PluginHostApi {
fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option<Handle> {
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
@@ -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.
+12 -2
View File
@@ -733,7 +733,12 @@ impl HostApi for V4PluginHostApi {
fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option<Handle> {
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
+47 -7
View File
@@ -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())),
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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<Task<Message>> {
pub(crate) fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
if let Some(t) = self.dispatch_fileops(cmd, i) {
return Some(t);
}
+11
View File
@@ -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());
+93 -6
View File
@@ -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<String>,
needs_entity_pick: Option<bool>,
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);
}
}