fix: review fixes for the merged command-line, plugin and font changes

Direct distance entry (#1429) turned every bare number at a point prompt
into a distance, so ROTATE's angle and the values automation types were
read as coordinates. The step reads the token first now — `feed_command`
reports whether it claimed it — and only an unclaimed one becomes a
distance. That also puts the token back on the unified step driver, which
owns the P / L selection keywords and the dimension-snap bookkeeping, and
stops `on_text_input` running twice for the same token. A tracking ray
carries its own base, so it no longer demands an anchor that the first
point of a command does not have yet.

Plugin command execution (#1425) re-implemented the command line inside
the plugin host: it dispatched families directly, skipping the teardown
that starts a command (grip edits, selection gestures, `last_point`, the
accepted snaps), and made every `CmdResult::Dispatch` in the application
skip plugin dispatch to avoid its own re-entrancy. A scripted line now
runs through `run_command_line`, with the trailing Enter left off while
the plugin streams the command, and the re-entrancy is handled by a flag
set for the length of the plugin's call. A pause parks the line's Enter
with the rest of its tokens instead of dropping it.

A font reference (#1432) tried the family lookup before the face-file
index, and that lookup ends in a prefix match a longer file name loses to
(`ISOCPEUI.TTF` would take `ISOCP`). The exact file answers first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5 committed 2026-09-22 13:35:25 +03:00
1 parent 0cbaf18551
commit fcbab57627
6 files changed
+94 -100

No files matched your search

+46 -23
View File
@@ -429,12 +429,20 @@ impl OpenCADStudio {
/// through, so the routing from input → `on_*` method → `apply_cmd_result` /// through, so the routing from input → `on_*` method → `apply_cmd_result`
/// lives in exactly one place. No-op when no command is active. /// lives in exactly one place. No-op when no command is active.
pub(super) fn feed_command(&mut self, input: StepInput) -> Task<Message> { pub(super) fn feed_command(&mut self, input: StepInput) -> Task<Message> {
self.feed_command_consumed(input).0
}
/// `feed_command`, also reporting whether the step actually took the input.
/// Only a `Text` token can come back unclaimed (`on_text_input` returning
/// `None`), which is what lets the caller read it as something else — a
/// typed distance, say — instead of guessing beforehand.
pub(super) fn feed_command_consumed(&mut self, input: StepInput) -> (Task<Message>, bool) {
// Selection keywords (P / PREVIOUS, L / LAST) consume the token // Selection keywords (P / PREVIOUS, L / LAST) consume the token
// before it reaches the command (#426). // before it reaches the command (#426).
if let StepInput::Text(s) = &input { if let StepInput::Text(s) = &input {
let kw_input = s.clone(); let kw_input = s.clone();
if let Some(task) = self.try_selection_keyword(&kw_input) { if let Some(task) = self.try_selection_keyword(&kw_input) {
return task; return (task, true);
} }
} }
let i = self.active_tab; let i = self.active_tab;
@@ -450,14 +458,14 @@ impl OpenCADStudio {
}; };
if let StepInput::Point(point) = &input { if let StepInput::Point(point) = &input {
if !self.command_point_allowed(i, *point) { if !self.command_point_allowed(i, *point) {
return Task::none(); return (Task::none(), true);
} }
// Typed / dynamic-input / headless points carry no snap, but the // Typed / dynamic-input / headless points carry no snap, but the
// accepted-snap list must stay index-parallel with the points the // accepted-snap list must stay index-parallel with the points the
// command collects. Interactive picks record themselves in // command collects. Interactive picks record themselves in
// the click handler and never reach here. // the click handler and never reach here.
if !self.record_accepted_snap(i, None, None, *point) { if !self.record_accepted_snap(i, None, None, *point) {
return Task::none(); return (Task::none(), true);
} }
} }
if default_start { if default_start {
@@ -473,7 +481,7 @@ impl OpenCADStudio {
} }
if let StepInput::EntityPick(handle, point) = &input { if let StepInput::EntityPick(handle, point) = &input {
if !self.dimension_acquisition_allowed(i, None) { if !self.dimension_acquisition_allowed(i, None) {
return Task::none(); return (Task::none(), true);
} }
let solid_pick = matches!( let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle), self.tabs[i].scene.document.get_entity(*handle),
@@ -555,7 +563,7 @@ impl OpenCADStudio {
let is_escape = matches!(&input, StepInput::Escape); let is_escape = matches!(&input, StepInput::Escape);
let result: Option<CmdResult> = { let result: Option<CmdResult> = {
let Some(cmd) = self.tabs[i].active_cmd.as_mut() else { let Some(cmd) = self.tabs[i].active_cmd.as_mut() else {
return Task::none(); return (Task::none(), false);
}; };
cmd.set_ctrl(ctrl); cmd.set_ctrl(ctrl);
cmd.set_shift(shift); cmd.set_shift(shift);
@@ -579,8 +587,8 @@ impl OpenCADStudio {
self.tabs[i].pending_pause_tokens = None; self.tabs[i].pending_pause_tokens = None;
} }
match result { match result {
Some(r) => self.apply_cmd_result(r), Some(r) => (self.apply_cmd_result(r), true),
None => Task::none(), None => (Task::none(), false),
} }
} }
@@ -629,6 +637,14 @@ impl OpenCADStudio {
/// terminated as if Enter were pressed. Shared by the GUI command line and /// terminated as if Enter were pressed. Shared by the GUI command line and
/// the headless automation feeder so both behave identically. /// the headless automation feeder so both behave identically.
pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> { pub(super) fn run_command_line(&mut self, cmd: &str) -> Task<Message> {
self.run_command_line_streaming(cmd, true)
}
/// `run_command_line`, with the trailing Enter left off when `finish` is
/// false: the line is one instalment and the tool stays at its next prompt
/// for the following one. Only a plugin streaming a command feeds it that
/// way; the command line and the automation feeder always finish.
pub(super) fn run_command_line_streaming(&mut self, cmd: &str, finish: bool) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
self.command_line.unconsumed.clear(); self.command_line.unconsumed.clear();
let tokens: Vec<&str> = cmd.split_whitespace().collect(); let tokens: Vec<&str> = cmd.split_whitespace().collect();
@@ -640,9 +656,9 @@ impl OpenCADStudio {
// dispatch first. A built-in interactive tool matches only its bare name // 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 // (`LINE`), so the full line is not a plugin command and falls through to
// the first-word + fed-tokens path below. (#162) // the first-word + fed-tokens path below. (#162)
if crate::plugin::try_dispatch(self, i, cmd) { if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect(); let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
return self.finish_active_command(&toks); return self.finish_active_command(&toks, finish);
} }
if tokens[0].eq_ignore_ascii_case("BACKGROUND") if tokens[0].eq_ignore_ascii_case("BACKGROUND")
|| tokens[0].eq_ignore_ascii_case("COLORSCHEME") || tokens[0].eq_ignore_ascii_case("COLORSCHEME")
@@ -655,7 +671,7 @@ impl OpenCADStudio {
return self.dispatch_command(cmd); return self.dispatch_command(cmd);
} }
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect(); let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
let finish_task = self.finish_active_command(&toks); let finish_task = self.finish_active_command(&toks, finish);
Task::batch([start_task, finish_task]) Task::batch([start_task, finish_task])
} }
@@ -674,7 +690,7 @@ impl OpenCADStudio {
/// silently dropped and `TEXT 0,0 5 0 hi` created nothing at all. /// silently dropped and `TEXT 0,0 5 0 hi` created nothing at all.
/// * Anything still left over was never claimed by any prompt; it is /// * Anything still left over was never claimed by any prompt; it is
/// recorded in [`CommandLine::unconsumed`] instead of vanishing. /// recorded in [`CommandLine::unconsumed`] instead of vanishing.
pub(super) fn finish_active_command(&mut self, tokens: &[String]) -> Task<Message> { pub(super) fn finish_active_command(&mut self, tokens: &[String], finish: bool) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
if self.tabs[i].active_cmd.is_none() { if self.tabs[i].active_cmd.is_none() {
return Task::none(); return Task::none();
@@ -694,7 +710,12 @@ impl OpenCADStudio {
} }
let trimmed = tok.trim(); let trimmed = tok.trim();
if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" { if trimmed.eq_ignore_ascii_case("PAUSE") || trimmed == "\\" {
let remainder: Vec<String> = tokens[1 + idx + 1..].to_vec(); let mut remainder: Vec<String> = tokens[1 + idx + 1..].to_vec();
// The line still ends with an Enter; it waits behind the pause
// instead of being dropped with it.
if finish {
remainder.push("ENTER".to_string());
}
self.tabs[i].pending_pause_tokens = Some(remainder); self.tabs[i].pending_pause_tokens = Some(remainder);
consumed = tokens.len(); consumed = tokens.len();
paused = true; paused = true;
@@ -730,7 +751,7 @@ impl OpenCADStudio {
if consumed < tokens.len() { if consumed < tokens.len() {
self.command_line.unconsumed = tokens[consumed..].to_vec(); self.command_line.unconsumed = tokens[consumed..].to_vec();
} }
if !paused { if !paused && finish {
tasks.push(self.feed_command(StepInput::Enter)); tasks.push(self.feed_command(StepInput::Enter));
} }
Task::batch(tasks) Task::batch(tasks)
@@ -981,17 +1002,19 @@ impl OpenCADStudio {
self.push_ucs_to_cmd(i); self.push_ucs_to_cmd(i);
return self.feed_command(StepInput::Point(wcs)); return self.feed_command(StepInput::Point(wcs));
} else { } else {
let consumed = self.tabs[i] // The step reads the token first: at a great many point prompts a
.active_cmd // bare number is the step's own value, not a distance — ROTATE's
.as_mut() // angle, SCALE's factor, CIRCLE's radius. Only a token no step
.and_then(|c| c.on_text_input(token)); // claims becomes direct distance entry, measured along the cursor
if let Some(r) = consumed { // direction from the command's anchor.
return self.apply_cmd_result(r); let (task, consumed) = self.feed_command_consumed(StepInput::Text(token.to_string()));
} if consumed {
if let Some(task) = self.try_direct_distance_entry(token) {
return task; return task;
} }
return self.feed_command(StepInput::Text(token.to_string())); if let Some(distance) = self.try_direct_distance_entry(token) {
return distance;
}
return task;
} }
} }
@@ -5958,7 +5981,7 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None; self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire(); self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent(); self.restore_pre_cmd_tangent();
dispatched = self.dispatch_command_without_plugins(&cmd); dispatched = self.dispatch_command(&cmd);
} }
CmdResult::EditTableCell { handle, point } => { CmdResult::EditTableCell { handle, point } => {
// TABLEDIT's pick: end the pick phase and hand (table, point) // TABLEDIT's pick: end the pick phase and hand (table, point)
+6 -11
View File
@@ -43,12 +43,7 @@ impl OpenCADStudio {
} }
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> { pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, false, false) self.dispatch_command_inner(cmd, 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 /// Dispatch a verb typed at the interactive command line, falling back to
@@ -60,10 +55,10 @@ impl OpenCADStudio {
/// callers (ribbon, plugins, headless automation) use `dispatch_command` /// callers (ribbon, plugins, headless automation) use `dispatch_command`
/// and never get silent substitution. /// and never get silent substitution.
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> { pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
self.dispatch_command_inner(cmd, true, false) self.dispatch_command_inner(cmd, true)
} }
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool, skip_plugins: bool) -> Task<Message> { fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
let i = self.active_tab; let i = self.active_tab;
// Expand a command alias ("L" → "LINE") on the leading token before any // Expand a command alias ("L" → "LINE") on the leading token before any
// routing, so every path below (Start-tab gate, plugins, all dispatch // routing, so every path below (Start-tab gate, plugins, all dispatch
@@ -191,7 +186,7 @@ impl OpenCADStudio {
return Task::none(); return Task::none();
} }
if !skip_plugins && crate::plugin::try_dispatch(self, i, cmd) { if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
// try_dispatch returns true for both finished commands and interactive // try_dispatch returns true for both finished commands and interactive
// commands that it just installed. If no command is now active, the // 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 — // tool was a one-shot and we must turn the ribbon highlight off here —
@@ -230,7 +225,7 @@ impl OpenCADStudio {
.next() .next()
{ {
if !top.eq_ignore_ascii_case(cmd) { if !top.eq_ignore_ascii_case(cmd) {
return self.dispatch_command_inner(&top, false, skip_plugins); return self.dispatch_command_inner(&top, false);
} }
} }
} }
@@ -244,7 +239,7 @@ impl OpenCADStudio {
/// `Some(task)` for an arm it owns (early-returning or falling through to /// `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 /// `finish_dispatch`), or `None` to defer to the next — equivalent to one
/// sequential `match` over all arms. /// sequential `match` over all arms.
pub(crate) fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> { fn dispatch_families(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
if let Some(t) = self.dispatch_fileops(cmd, i) { if let Some(t) = self.dispatch_fileops(cmd, i) {
return Some(t); return Some(t);
} }
+6
View File
@@ -623,6 +623,11 @@ pub(super) struct OpenCADStudio {
pub constraint_form_annotational: bool, pub constraint_form_annotational: bool,
/// The dimensional constraint DIMCONSTRAINT offers by default: the last one used. /// The dimensional constraint DIMCONSTRAINT offers by default: the last one used.
pub dim_constraint_last: &'static str, pub dim_constraint_last: &'static str,
/// Set while a plugin drives the command line (`HostApi::execute_command`).
/// Dispatching a plugin command from there would call back into the plugin
/// that is still blocked waiting for this request, so plugin dispatch is
/// skipped for the length of the call.
pub(crate) suppress_plugin_dispatch: bool,
pub constraint_bar_mode: i16, pub constraint_bar_mode: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave. /// 0 disables autosave.
@@ -3964,6 +3969,7 @@ impl OpenCADStudio {
constraint_bar_display: 3, constraint_bar_display: 3,
constraint_form_annotational: false, constraint_form_annotational: false,
dim_constraint_last: "Aligned", dim_constraint_last: "Aligned",
suppress_plugin_dispatch: false,
constraint_bar_mode: 4095, constraint_bar_mode: 4095,
savetime_min: 10, savetime_min: 10,
default_bg_color: None, default_bg_color: None,
+20 -54
View File
@@ -420,62 +420,28 @@ impl<'a> HostSession<'a> {
return false; return false;
} }
// 4. Command execution // 4. Command execution. The command line's own driver owns this: it
let parts: Vec<&str> = trimmed.split_whitespace().collect(); // resolves the alias, tears down whatever was running, dispatches an
if parts.is_empty() { // inline-argument line as a whole (`CLAYER TEST`, `UCS ORIGIN 0,0`),
return false; // and otherwise starts the tool and feeds it the rest of the tokens,
} // PAUSE included. Leaving that here rather than repeating it keeps a
// scripted line and a typed one on the same path.
let tab = self.tab; let tab = self.tab;
let is_new_cmd = self.app.tabs[tab].active_cmd.is_none(); self.app.suppress_plugin_dispatch = true;
if is_new_cmd { let task = if self.app.tabs[tab].active_cmd.is_some() {
// Check if the complete command string is handled directly by command families // A running command owns these tokens: they answer its prompts.
// (e.g. "CLAYER TEST", "ZOOM EXTENTS", "COLOR RED", "VSCURRENT FLATSHADED", etc.) without needing token-by-token feeding let mut queue: Vec<String> =
let full_resolved = self.app.resolve_alias(trimmed); trimmed.split_whitespace().map(str::to_string).collect();
let full_effective = full_resolved.as_deref().unwrap_or(trimmed); if has_newline {
if let Some(task) = self.app.dispatch_families(full_effective, tab) { queue.push("ENTER".to_string());
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;
} }
self.app.tabs[tab].pending_pause_tokens = Some(queue);
let cmd_name = parts[0]; self.app.drain_pending_pause_tokens(tab)
let resolved = self.app.resolve_alias(cmd_name); } else {
let effective = resolved.as_deref().unwrap_or(cmd_name); self.app.run_command_line_streaming(trimmed, has_newline)
let task = self.app.dispatch_command_without_plugins(effective); };
let _ = self.app.drive_headless_task(task); let _ = self.app.drive_headless_task(task);
} self.app.suppress_plugin_dispatch = false;
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.app.refresh_layer_panel();
self.publish_document_view(); self.publish_document_view();
+12 -11
View File
@@ -876,28 +876,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
/// direction in the active UCS plane. /// direction in the active UCS plane.
pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option<Task<Message>> { pub(in crate::app) fn try_direct_distance_entry(&mut self, text: &str) -> Option<Task<Message>> {
let i = self.active_tab; let i = self.active_tab;
let not_entity_pick = !self.tabs[i] if self.tabs[i]
.active_cmd .active_cmd
.as_ref() .as_ref()
.map(|c| c.needs_entity_pick()) .is_some_and(|c| c.needs_entity_pick())
.unwrap_or(false); {
if !not_entity_pick {
return None; return None;
} }
let anchor = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.resolved_anchor())
.or(self.tabs[i].dyn_anchor)
.or(self.last_point)?;
let dist = crate::entities::common::parse_length(text.trim()) let dist = crate::entities::common::parse_length(text.trim())
.or_else(|| crate::app::expr_eval::eval_number(text.trim()))?; .or_else(|| crate::app::expr_eval::eval_number(text.trim()))?;
// A tracking ray carries its own base, so it needs no anchor: that is
// how OTRACK and Extension accepted a bare distance for the *first*
// point of a command, before any anchor exists.
let pt = if let Some((base, dir)) = self.active_distance_ray(i) { let pt = if let Some((base, dir)) = self.active_distance_ray(i) {
base + dir * dist base + dir * dist
} else { } else {
let anchor = self.tabs[i]
.active_cmd
.as_ref()
.and_then(|c| c.resolved_anchor())
.or(self.tabs[i].dyn_anchor)
.or(self.last_point)?;
let w = self.tabs[i].last_cursor_world; let w = self.tabs[i].last_cursor_world;
let xf = self.tabs[i].ucs_xform(); let xf = self.tabs[i].ucs_xform();
let d_ucs = xf.vec_to_ucs(w - anchor); let d_ucs = xf.vec_to_ucs(w - anchor);
+4 -1
View File
@@ -179,7 +179,10 @@ pub fn family_for_reference(reference: &str) -> Option<String> {
if reference.is_empty() { if reference.is_empty() {
return None; return None;
} }
canonical_family_name(reference).or_else(|| family_for_file(reference)) // The file name is exact; the family lookup ends in a prefix/subset match
// that a longer file name loses to (`ISOCPEUI.TTF` would take `ISOCP`), so
// an installed face file answers first.
family_for_file(reference).or_else(|| canonical_family_name(reference))
} }
/// Whether `family` matches an installed system font (case-insensitive via /// Whether `family` matches an installed system font (case-insensitive via