feat(cmdline): type whole command lines with spaces; unify with headless (F1, #169)
Space is now a literal character in the command line, so a whole command line — `UCS Z 90`, `LINE 0,0 10,10`, `PDMODE 3` — is typed before Enter instead of the first Space submitting the buffer. Enter tokenises the line and runs it. The command line and the headless automation feeder now share one runner, `run_command_line` (moved to the non-wasm-gated cmd_result.rs): a single or inline-argument command dispatches as-is; a multi-token line whose first word starts an interactive tool feeds the rest as steps; plugin commands get the whole line first (#162). The shared feeder is now UCS-aware — typed coordinates are interpreted in the active UCS in both the GUI and headless. `run_headless` is a thin wrapper over it. A free-form text command (TEXT / MTEXT / a name with spaces) still receives the whole line as one input, via the existing `wants_text_with_spaces` guard. Unfocused Space still repeats the last command. Closes the command-line half of #169 generically for every command, not just UCS. Third phase of the unified step driver: every input source funnels through feed_command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
36d2f33301
commit
52f6aed2f2
3 changed files with 149 additions and 110 deletions
|
|
@ -360,101 +360,12 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
/// Run a command headlessly. Single-word and inline-argument commands
|
||||
/// (`PDMODE 3`, `LAYER Walls`) dispatch as-is. For an interactive tool with
|
||||
/// coordinate arguments (`LINE 0,0 10,10`) the first word starts the tool
|
||||
/// and the remaining tokens are fed as points / option keywords, then the
|
||||
/// command is terminated as if Enter were pressed.
|
||||
/// Run a command line headlessly. Thin wrapper over the shared
|
||||
/// [`OpenCADStudio::run_command_line`] (see `cmd_result.rs`), which the GUI
|
||||
/// command line uses too so both process `UCS Z 90` / `LINE 0,0 10,10` /
|
||||
/// `PDMODE 3` identically.
|
||||
fn run_headless(&mut self, cmd: &str) {
|
||||
let i = self.active_tab;
|
||||
let tokens: Vec<&str> = cmd.split_whitespace().collect();
|
||||
if tokens.len() <= 1 {
|
||||
let _ = self.dispatch_command(cmd);
|
||||
return;
|
||||
}
|
||||
// Plugin commands parse their own inline arguments from the whole line
|
||||
// (e.g. `HC_PIPE 2B 2C 1.25 0.013`), so offer the full command to plugin
|
||||
// dispatch first. A built-in interactive tool matches only its bare name
|
||||
// (`LINE`), so the full line is not a plugin command and falls through to
|
||||
// the first-word + fed-tokens path below. (#162)
|
||||
if crate::plugin::try_dispatch(self, i, cmd) {
|
||||
// The plugin either committed immediately (inline args consumed) or
|
||||
// installed an interactive command — feed any remaining tokens and
|
||||
// finish it on Enter, as for a built-in tool.
|
||||
self.finish_headless_interactive(&tokens);
|
||||
return;
|
||||
}
|
||||
let _ = self.dispatch_command(tokens[0]);
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
// Not an interactive tool — an inline-argument command.
|
||||
let _ = self.dispatch_command(cmd);
|
||||
return;
|
||||
}
|
||||
self.finish_headless_interactive(&tokens);
|
||||
}
|
||||
|
||||
/// Feed `tokens[1..]` to the active interactive command as points / option
|
||||
/// keywords, then terminate it as if Enter were pressed (LINE / PLINE finish
|
||||
/// on Enter). No-op when no command is active.
|
||||
fn finish_headless_interactive(&mut self, tokens: &[&str]) {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
return;
|
||||
}
|
||||
self.last_point = None;
|
||||
for tok in &tokens[1..] {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
break;
|
||||
}
|
||||
self.feed_active_cmd(tok);
|
||||
}
|
||||
// Terminate a still-open command (LINE / PLINE finish on Enter).
|
||||
let _ = self.feed_command(crate::command::StepInput::Enter);
|
||||
}
|
||||
|
||||
/// Classify one headless token into a [`StepInput`] and route it through the
|
||||
/// shared [`OpenCADStudio::feed_command`]. When the command is picking an
|
||||
/// existing entity the token is a hex handle; otherwise it is a coordinate
|
||||
/// point or an option keyword / value.
|
||||
fn feed_active_cmd(&mut self, token: &str) {
|
||||
use crate::command::StepInput;
|
||||
let i = self.active_tab;
|
||||
// Object-pick step: the token is a handle (as returned by `query`).
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.needs_entity_pick())
|
||||
{
|
||||
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
|
||||
let handle = acadrust::Handle::new(v);
|
||||
let pt = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.map(|e| {
|
||||
let bb = e.as_entity().bounding_box();
|
||||
glam::Vec3::new(
|
||||
((bb.min.x + bb.max.x) * 0.5) as f32,
|
||||
((bb.min.y + bb.max.y) * 0.5) as f32,
|
||||
0.0,
|
||||
)
|
||||
})
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((mut pt, kind)) = super::helpers::parse_coord(token) {
|
||||
if matches!(kind, super::helpers::CoordKind::Relative) {
|
||||
if let Some(base) = self.last_point {
|
||||
pt += base;
|
||||
}
|
||||
}
|
||||
self.last_point = Some(pt);
|
||||
let _ = self.feed_command(StepInput::Point(pt.as_dvec3()));
|
||||
} else {
|
||||
let _ = self.feed_command(StepInput::Text(token.to_string()));
|
||||
}
|
||||
let _ = self.run_command_line(cmd);
|
||||
}
|
||||
|
||||
/// List entities (handle, type, layer, basic geometry), optionally filtered
|
||||
|
|
|
|||
|
|
@ -33,6 +33,114 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// interactive tool (`LINE 0,0 10,10`) the first word starts the tool and the
|
||||
/// remaining tokens are fed as points / option keywords, then the command is
|
||||
/// terminated as if Enter were pressed. Shared by the GUI command line and
|
||||
/// the headless automation feeder so both behave identically.
|
||||
pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let tokens: Vec<&str> = cmd.split_whitespace().collect();
|
||||
if tokens.len() <= 1 {
|
||||
return self.dispatch_command(cmd);
|
||||
}
|
||||
// Plugin commands parse their own inline arguments from the whole line
|
||||
// (e.g. `HC_PIPE 2B 2C 1.25 0.013`), so offer the full command to plugin
|
||||
// dispatch first. A built-in interactive tool matches only its bare name
|
||||
// (`LINE`), so the full line is not a plugin command and falls through to
|
||||
// the first-word + fed-tokens path below. (#162)
|
||||
if crate::plugin::try_dispatch(self, i, cmd) {
|
||||
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
|
||||
self.finish_active_command(&toks);
|
||||
return Task::none();
|
||||
}
|
||||
let _ = self.dispatch_command(tokens[0]);
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
// Not an interactive tool — an inline-argument command (`PDMODE 3`).
|
||||
return self.dispatch_command(cmd);
|
||||
}
|
||||
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
|
||||
self.finish_active_command(&toks);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Feed `tokens[1..]` to the active interactive command as points / option
|
||||
/// keywords, then terminate it as if Enter were pressed. No-op when no
|
||||
/// command is active.
|
||||
pub(super) fn finish_active_command(&mut self, tokens: &[String]) {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
return;
|
||||
}
|
||||
self.last_point = None;
|
||||
for tok in &tokens[1..] {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
break;
|
||||
}
|
||||
self.feed_active_cmd(tok);
|
||||
}
|
||||
let _ = self.feed_command(StepInput::Enter);
|
||||
}
|
||||
|
||||
/// Classify one typed token into a [`StepInput`] and route it through the
|
||||
/// shared [`Self::feed_command`]. An object-pick step takes a hex handle; a
|
||||
/// coordinate is parsed (and, like the GUI command line, interpreted in the
|
||||
/// active UCS); anything else is an option keyword / value. Used by both the
|
||||
/// GUI command line and headless automation.
|
||||
pub(super) fn feed_active_cmd(&mut self, token: &str) {
|
||||
let i = self.active_tab;
|
||||
// Object-pick step: the token is a handle (as returned by `query`).
|
||||
if self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.needs_entity_pick())
|
||||
{
|
||||
if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) {
|
||||
let handle = Handle::new(v);
|
||||
let pt = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.map(|e| {
|
||||
let bb = e.as_entity().bounding_box();
|
||||
glam::Vec3::new(
|
||||
((bb.min.x + bb.max.x) * 0.5) as f32,
|
||||
((bb.min.y + bb.max.y) * 0.5) as f32,
|
||||
0.0,
|
||||
)
|
||||
})
|
||||
.unwrap_or(glam::Vec3::ZERO);
|
||||
let _ = self.feed_command(StepInput::EntityPick(handle, pt.as_dvec3()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((coord, kind)) = super::helpers::parse_coord(token) {
|
||||
// Match the GUI command line: typed coordinates are in the active
|
||||
// UCS (relative offsets are rotated by the UCS axes), so a multi-
|
||||
// token `LINE 0,0 10,10` under a rotated UCS lands correctly.
|
||||
let ucs = self.tabs[i].active_ucs.clone();
|
||||
let wcs = match (matches!(kind, super::helpers::CoordKind::Relative), self.last_point) {
|
||||
(true, Some(base)) => {
|
||||
base + match &ucs {
|
||||
Some(u) => super::helpers::ucs_rotate_vec(coord, u),
|
||||
None => coord,
|
||||
}
|
||||
}
|
||||
_ => match &ucs {
|
||||
Some(u) => super::helpers::ucs_to_wcs(coord, u),
|
||||
None => coord,
|
||||
},
|
||||
};
|
||||
self.last_point = Some(wcs);
|
||||
self.push_ucs_to_cmd(i);
|
||||
let _ = self.feed_command(StepInput::Point(wcs.as_dvec3()));
|
||||
} else {
|
||||
let _ = self.feed_command(StepInput::Text(token.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
match result {
|
||||
|
|
|
|||
|
|
@ -1413,21 +1413,11 @@ impl OpenCADStudio {
|
|||
}
|
||||
|
||||
Message::CommandInput(s) => {
|
||||
// Space submits the current input the same way Enter does
|
||||
// (CAD convention) — unless the active command is collecting
|
||||
// free-form text (TEXT / MTEXT / DDEDIT / attribute value
|
||||
// prompts) where Space must reach the buffer as a literal
|
||||
// character. `wants_text_with_spaces()` flags those prompts.
|
||||
let i = self.active_tab;
|
||||
let allow_literal_space = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
|
||||
.unwrap_or(false);
|
||||
if !allow_literal_space && s.ends_with(' ') {
|
||||
self.command_line.input = s.trim_end_matches(' ').to_string();
|
||||
return Task::done(Message::CommandSubmit);
|
||||
}
|
||||
// Space is a literal character so a whole command line — `UCS Z
|
||||
// 90`, `LINE 0,0 10,10`, `PDMODE 3` — can be typed before Enter.
|
||||
// CommandSubmit (Enter) tokenises and runs the line through the
|
||||
// shared runner. (Unfocused Space still repeats the last command
|
||||
// via CommandSpace.)
|
||||
self.command_line.input = s;
|
||||
// Typing invalidates the previous arrow-key cursor —
|
||||
// the matches list has likely changed.
|
||||
|
|
@ -1637,6 +1627,36 @@ impl OpenCADStudio {
|
|||
}
|
||||
}
|
||||
let i = self.active_tab;
|
||||
// A whole multi-token command line (`UCS Z 90`, `LINE 0,0
|
||||
// 10,10`, `PDMODE 3`) — typable now that Space is literal — is
|
||||
// processed as one unit: feed the tokens to a running command,
|
||||
// or start a new one through the shared runner that the headless
|
||||
// automation feeder uses too.
|
||||
{
|
||||
// Skip token-splitting when the active command collects
|
||||
// free-form text with spaces (TEXT / MTEXT / a name) — it
|
||||
// wants the whole line as one input.
|
||||
let wants_spaces = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.wants_text_input() && c.wants_text_with_spaces())
|
||||
.unwrap_or(false);
|
||||
let raw = self.command_line.input.clone();
|
||||
let toks: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
||||
if toks.len() > 1 && !wants_spaces {
|
||||
self.command_line.input.clear();
|
||||
if self.tabs[i].active_cmd.is_some() {
|
||||
for tok in &toks {
|
||||
if self.tabs[i].active_cmd.is_none() {
|
||||
break;
|
||||
}
|
||||
self.feed_active_cmd(tok);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
return self.run_command_line(&raw);
|
||||
}
|
||||
}
|
||||
// With the command line empty, a typed dynamic-input value
|
||||
// commits as a point pick instead of an empty submit.
|
||||
if self.tabs[i].active_cmd.is_some() && self.command_line.input.trim().is_empty() {
|
||||
|
|
@ -8584,7 +8604,7 @@ impl OpenCADStudio {
|
|||
/// Hand the active command the current UCS (as a UCS→wire affine) so
|
||||
/// axis-aligned constructions build square to the user's coordinate system.
|
||||
/// No-op for commands that don't override `set_ucs`.
|
||||
fn push_ucs_to_cmd(&mut self, i: usize) {
|
||||
pub(super) fn push_ucs_to_cmd(&mut self, i: usize) {
|
||||
let ucs = self.tabs[i].ucs_wire_affine();
|
||||
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
|
||||
c.set_ucs(ucs);
|
||||
|
|
|
|||
Loading…
Reference in a new issue