feat(plugin): AutoCAD-compatible PAUSE, ENTER keyword, and command streaming

This commit is contained in:
yztink committed 2026-09-20 21:03:12 -07:00
1 parent fd2062bdec
commit fbedb3736e
8 files changed
+250 -3

No files matched your search

+6
View File
@@ -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.
+14
View File
@@ -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.
@@ -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`.
+1
View File
@@ -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)),
}
}
@@ -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)]
+61 -3
View File
@@ -530,6 +530,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();
@@ -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<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
@@ -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<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;
}
@@ -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)
}
+3
View File
@@ -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,
+149
View File
@@ -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<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;
}
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);
}
}