forked from 1b-agent/opencadstudio-upstream
Merge pull request #1425 from yztink:feat/plugin-run-command
This commit is contained in:
commit
0cbaf18551
12 files changed
+464
-21
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,10 +566,28 @@ 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 / 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration properties for creating or modifying a layer.
|
||||
|
||||
@@ -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
|
||||
@@ -444,6 +454,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.
|
||||
|
||||
@@ -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.
|
||||
@@ -142,6 +147,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`.
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -758,6 +768,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)]
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -552,6 +552,7 @@ impl OpenCADStudio {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is_escape = matches!(&input, StepInput::Escape);
|
||||
let result: Option<CmdResult> = {
|
||||
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
|
||||
return Task::none();
|
||||
@@ -574,12 +575,52 @@ impl OpenCADStudio {
|
||||
self.record_dimension_entity_points(i, None, handle, Vec::new());
|
||||
}
|
||||
self.sync_dimension_snaps(i);
|
||||
if is_escape {
|
||||
self.tabs[i].pending_pause_tokens = None;
|
||||
}
|
||||
match result {
|
||||
Some(r) => self.apply_cmd_result(r),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Message> {
|
||||
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
|
||||
@@ -646,10 +687,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<String> = 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;
|
||||
}
|
||||
@@ -680,7 +730,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)
|
||||
}
|
||||
|
||||
@@ -1049,8 +1101,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
|
||||
@@ -5900,7 +5958,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)
|
||||
|
||||
+11
-6
@@ -43,7 +43,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool, skip_plugins: bool) -> Task<Message> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,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);
|
||||
}
|
||||
|
||||
@@ -2739,8 +2739,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());
|
||||
|
||||
@@ -151,6 +151,8 @@ pub(super) struct DocumentTab {
|
||||
pub(super) properties: PropertiesPanel,
|
||||
pub(super) layers: LayerPanel,
|
||||
pub(super) active_cmd: Option<Box<dyn CadCommand>>,
|
||||
/// Remaining command tokens queued behind a PAUSE (or `\`) awaiting user interaction.
|
||||
pub(super) pending_pause_tokens: Option<Vec<String>>,
|
||||
/// 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,
|
||||
|
||||
@@ -370,6 +370,117 @@ 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 {
|
||||
// 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 task = self.app.dispatch_command_without_plugins(effective);
|
||||
let _ = self.app.drive_headless_task(task);
|
||||
}
|
||||
|
||||
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<String> =
|
||||
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;
|
||||
}
|
||||
let task = if part.eq_ignore_ascii_case("ENTER") || part.eq_ignore_ascii_case("RETURN") {
|
||||
self.app.feed_command(crate::command::StepInput::Enter)
|
||||
} else {
|
||||
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 task = self.app.feed_command(crate::command::StepInput::Enter);
|
||||
let _ = self.app.drive_headless_task(task);
|
||||
}
|
||||
|
||||
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 +601,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)
|
||||
@@ -547,6 +661,7 @@ pub(crate) struct PluginProcessInteractiveAdapter {
|
||||
pub command_id: u64,
|
||||
prompt: Option<String>,
|
||||
needs_entity_pick: Option<bool>,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
impl PluginProcessInteractiveAdapter {
|
||||
@@ -561,6 +676,7 @@ impl PluginProcessInteractiveAdapter {
|
||||
command_id,
|
||||
prompt,
|
||||
needs_entity_pick,
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -568,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 {
|
||||
@@ -587,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
|
||||
}
|
||||
@@ -597,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)
|
||||
}
|
||||
@@ -616,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
|
||||
}
|
||||
@@ -1049,4 +1192,100 @@ 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 (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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user