From 52f6aed2f2baaab9367a09401fe3c8cd4565796e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 25 Jun 2026 13:16:28 +0300 Subject: [PATCH] feat(cmdline): type whole command lines with spaces; unify with headless (F1, #169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/automation.rs | 99 ++------------------------------------ src/app/cmd_result.rs | 108 ++++++++++++++++++++++++++++++++++++++++++ src/app/update.rs | 52 +++++++++++++------- 3 files changed, 149 insertions(+), 110 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index eee846b2b..2fddea27c 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -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 diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 2315f2107..94cee99e8 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -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 { + 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 = 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 = 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 { let i = self.active_tab; match result { diff --git a/src/app/update.rs b/src/app/update.rs index 4b644d058..8ba5ea195 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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 = 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);