fix(command): keyword/radius input for ribbon-started modify commands (#90)

Starting FILLET/CHAMFER from the ribbon left the command-line field
unfocused, so its Enter arrived as CommandFinalize and discarded a
typed option keyword ("R"/"D"), cancelling the command. Several
related dynamic-input issues surfaced from the same flow:

- CommandFinalize now forwards a non-empty command-line buffer through
  CommandSubmit, so a typed keyword is handled the same whether the
  command was started from the ribbon or the command line.
- sync_dyn_fields shows no coordinate box during an object-pick step
  (needs_entity_pick / needs_structure_point_pick) — there is no point
  to enter there, so typed keywords reach the command line cleanly.
- apply_cmd_result rebuilds the dynamic-input fields on NeedPoint, so a
  step change (object pick -> radius entry) shows the matching box
  immediately and typed digits land in it instead of the command line.
- Object-pick steps now draw a prompt-only hint pill at the cursor
  (no input box).
- FILLET/CHAMFER remember the first pick across a mid-selection radius
  or distance entry, resuming the second pick instead of restarting
  selection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-13 23:58:56 +03:00
commit 57d3e620ea
5 changed files with 141 additions and 17 deletions

View file

@ -42,6 +42,12 @@ impl OpenCADStudio {
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
// The command may have advanced to a step with a different
// dynamic-input shape (e.g. FILLET object-pick → radius entry).
// Rebuild the fields now so the matching box appears immediately
// and typed digits land in it rather than the command line,
// instead of waiting for the next cursor move to resync.
self.sync_dyn_fields();
}
CmdResult::Preview(wire) => {
self.tabs[i].scene.set_preview_wires(vec![wire]);

View file

@ -1382,6 +1382,15 @@ impl OpenCADStudio {
let idx = self.grip_popup.as_ref().map(|p| p.selected).unwrap_or(0);
return Task::done(Message::GripMenuPick(idx));
}
// Pending typed command-line text (e.g. an option keyword like
// "R") must be submitted rather than finalising the command.
// The focused-input Enter routes through CommandSubmit, but a
// command started from the ribbon never focuses the field, so
// its Enter arrives here — forward to the same submit path.
let i = self.active_tab;
if self.tabs[i].active_cmd.is_some() && !self.command_line.input.trim().is_empty() {
return self.update(Message::CommandSubmit);
}
// A typed dynamic-input value commits as a point pick
// before the plain-Enter (on_enter) path runs.
if let Some(task) = self.try_dyn_commit() {
@ -7327,7 +7336,7 @@ impl OpenCADStudio {
/// command-state changes. The field set only changes shape when the
/// command's `dyn_field()` or the presence of a base point changes;
/// existing typed buffers survive an unchanged shape.
fn sync_dyn_fields(&mut self) {
pub(super) fn sync_dyn_fields(&mut self) {
use super::document::{DynComponent, DynFieldEntry};
let i = self.active_tab;
if !self.dyn_input || self.tabs[i].active_cmd.is_none() {
@ -7351,6 +7360,15 @@ impl OpenCADStudio {
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
// A step that hit-tests for an object (entity / structure pick) has no
// coordinate to enter — clicks select, they don't place a point. Show
// no coordinate box so the cursor stays clean and typed option keywords
// (e.g. FILLET's "R") reach the command line instead of an X/Y field.
let picks_object = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
.unwrap_or(false);
let has_base = self.last_point.is_some();
// While aligned to an OTRACK ray, the point step reads a single
// distance along the ray (issue #69) — show one Distance box.
@ -7366,7 +7384,7 @@ impl OpenCADStudio {
// a name / a keyword (which may itself contain digits) from the
// command line — show no scalar box, so digits reach the command
// line instead of being captured into a dyn buffer.
crate::command::DynField::Point if wants_text => vec![],
crate::command::DynField::Point if wants_text || picks_object => vec![],
crate::command::DynField::Point if has_base => {
vec![DynComponent::Distance, DynComponent::Angle]
}
@ -7388,7 +7406,7 @@ impl OpenCADStudio {
// the command's default so e.g. clicking the first point of LINE
// flips a stale `[X, Y]` (from before there was a base) over to
// the polar `[Distance, Angle]` the prompt actually wants.
let current_is_acceptable = if self.dyn_user_reshaped && !wants_text {
let current_is_acceptable = if self.dyn_user_reshaped && !wants_text && !picks_object {
match field {
crate::command::DynField::Distance => {
matches!(current.as_slice(), [DynComponent::Distance])

View file

@ -691,8 +691,18 @@ impl OpenCADStudio {
// between boxes; typing locks a box to a fixed value while the
// rest keep tracking the cursor. The field set is maintained in
// `tab.dyn_fields` by `sync_dyn_fields`.
// A pick step (object selection) has no input box, but still shows
// its prompt ("Select first object …") near the cursor as a hint.
let dyn_picks_object = tab
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick() || c.needs_structure_point_pick())
.unwrap_or(false);
let dyn_input_overlay: Option<Element<'_, Message>> =
if self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty() {
if self.dyn_input
&& tab.active_cmd.is_some()
&& (!tab.dyn_fields.is_empty() || dyn_picks_object)
{
let w = tab.last_cursor_world;
let base = self.last_point;
// A command may drive a typed scalar by mouse (e.g. a

View file

@ -1196,6 +1196,10 @@ pub struct FilletCommand {
radius: f64,
step: FilletStep,
all_entities: Vec<EntityType>,
/// First-object pick to restore after a radius entry made mid-selection
/// (i.e. "R" pressed after the first object was already picked), so the
/// command resumes at the second pick instead of restarting selection.
resume_second: Option<(Handle, FilletEntity, [f64; 2])>,
}
impl FilletCommand {
@ -1204,8 +1208,29 @@ impl FilletCommand {
radius: radius as f64,
step: FilletStep::First,
all_entities,
resume_second: None,
}
}
/// Switch to the radius sub-step, remembering the first pick (if any) so
/// it can be restored afterwards.
fn enter_radius_substep(&mut self) {
self.resume_second = if let FilletStep::Second { h1, e1, click1 } = &self.step {
Some((*h1, e1.clone(), *click1))
} else {
None
};
self.step = FilletStep::WaitingForRadius;
}
/// Leave the radius sub-step, resuming the second pick when a first object
/// was already chosen, otherwise restarting at the first pick.
fn resume_after_radius(&mut self) {
self.step = match self.resume_second.take() {
Some((h1, e1, click1)) => FilletStep::Second { h1, e1, click1 },
None => FilletStep::First,
};
}
}
impl CadCommand for FilletCommand {
@ -1248,8 +1273,8 @@ impl CadCommand for FilletCommand {
FilletStep::WaitingForRadius => {
let t = text.trim();
if t.is_empty() {
// Keep current radius, return to First
self.step = FilletStep::First;
// Keep current radius, resume where the radius was requested.
self.resume_after_radius();
return Some(CmdResult::NeedPoint);
}
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
@ -1257,7 +1282,7 @@ impl CadCommand for FilletCommand {
self.radius = v;
defaults::set_fillet_radius(v as f32);
}
self.step = FilletStep::First;
self.resume_after_radius();
return Some(CmdResult::NeedPoint);
}
// Invalid — stay and re-prompt
@ -1268,7 +1293,7 @@ impl CadCommand for FilletCommand {
let upper = t.to_uppercase();
// "R" alone → enter sub-step to collect radius
if upper == "R" {
self.step = FilletStep::WaitingForRadius;
self.enter_radius_substep();
return Some(CmdResult::NeedPoint);
}
// "R 5.0" inline shorthand
@ -1279,10 +1304,11 @@ impl CadCommand for FilletCommand {
self.radius = v;
defaults::set_fillet_radius(v as f32);
}
// Stay in the current step (keeps any first pick).
return Some(CmdResult::NeedPoint);
}
// "R" + invalid body → enter sub-step
self.step = FilletStep::WaitingForRadius;
self.enter_radius_substep();
return Some(CmdResult::NeedPoint);
}
None
@ -1533,6 +1559,10 @@ pub struct ChamferCommand {
dist2: f64,
step: ChamferStep,
all_entities: Vec<EntityType>,
/// First-object pick (line or polyline segment) to restore after a
/// distance entry made mid-selection, so the command resumes at the
/// second pick instead of restarting selection.
resume_pick: Option<ChamferStep>,
}
impl ChamferCommand {
@ -1542,8 +1572,33 @@ impl ChamferCommand {
dist2: defaults::get_chamfer_dist2() as f64,
step: ChamferStep::First,
all_entities,
resume_pick: None,
}
}
/// Switch to the distance sub-step, remembering the first pick (if any).
fn enter_dist_substep(&mut self) {
self.resume_pick = match &self.step {
ChamferStep::Second { h1, l1, click1 } => Some(ChamferStep::Second {
h1: *h1,
l1: l1.clone(),
click1: *click1,
}),
ChamferStep::SecondPoly { h1, poly, click1 } => Some(ChamferStep::SecondPoly {
h1: *h1,
poly: poly.clone(),
click1: *click1,
}),
_ => None,
};
self.step = ChamferStep::WaitingForDist1;
}
/// Leave the distance sub-step, resuming the second pick when a first
/// object was already chosen, otherwise restarting at the first pick.
fn resume_after_dist(&mut self) {
self.step = self.resume_pick.take().unwrap_or(ChamferStep::First);
}
}
impl CadCommand for ChamferCommand {
@ -1614,14 +1669,14 @@ impl CadCommand for ChamferCommand {
ChamferStep::WaitingForDist2 => {
let t = text.trim();
if t.is_empty() {
// Keep current dist2, return to First
self.step = ChamferStep::First;
// Keep current dist2, resume where the distance was requested.
self.resume_after_dist();
return Some(CmdResult::NeedPoint);
}
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
self.dist2 = v.max(0.0);
defaults::set_chamfer_dist2(self.dist2 as f32);
self.step = ChamferStep::First;
self.resume_after_dist();
return Some(CmdResult::NeedPoint);
}
// Invalid — stay and re-prompt
@ -1634,7 +1689,7 @@ impl CadCommand for ChamferCommand {
let upper = t.to_uppercase();
// "D" alone → enter sub-step to collect distances
if upper == "D" {
self.step = ChamferStep::WaitingForDist1;
self.enter_dist_substep();
return Some(CmdResult::NeedPoint);
}
// "D 5.0" or "D 5.0 3.0" inline shorthand
@ -1659,7 +1714,7 @@ impl CadCommand for ChamferCommand {
return Some(CmdResult::NeedPoint);
}
// "D" + invalid body → enter sub-step
self.step = ChamferStep::WaitingForDist1;
self.enter_dist_substep();
return Some(CmdResult::NeedPoint);
}
None

View file

@ -1251,9 +1251,6 @@ impl canvas::Program<Message> for DynInputCanvas {
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
if self.boxes.is_empty() {
return vec![frame.into_geometry()];
}
// Offset the row 14 px right and 20 px below the cursor.
const OFFSET_X: f32 = 14.0;
@ -1264,6 +1261,44 @@ impl canvas::Program<Message> for DynInputCanvas {
const CHAR_W: f32 = FONT_SIZE * 0.62; // monospace-ish width estimate
const BOX_H: f32 = FONT_SIZE + PAD * 2.0;
// No input box (a pick step) — draw just the prompt pill at the cursor,
// so object-selection steps still get their hint without a field.
if self.boxes.is_empty() {
if !self.prompt.is_empty() {
let pw = (self.prompt.len() as f32 * CHAR_W) + PAD * 2.0;
let mut px = self.cursor_screen.x + OFFSET_X;
let mut py = self.cursor_screen.y + OFFSET_Y;
if px + pw > bounds.width {
px = (self.cursor_screen.x - pw - 4.0).max(0.0);
}
if py + BOX_H > bounds.height {
py = (self.cursor_screen.y - BOX_H - 4.0).max(0.0);
}
let prect = canvas::Path::rectangle(
Point { x: px, y: py },
Size {
width: pw,
height: BOX_H,
},
);
frame.fill(&prect, Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 });
frame.stroke(
&prect,
canvas::Stroke::default()
.with_color(Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 })
.with_width(1.0),
);
frame.fill_text(canvas::Text {
content: self.prompt.clone(),
position: Point { x: px + PAD, y: py + PAD },
color: Color { r: 0.70, g: 0.85, b: 0.70, a: 1.0 },
size: iced::Pixels(FONT_SIZE),
..Default::default()
});
}
return vec![frame.into_geometry()];
}
// Each box is "<label>:<value>"; width tracks the text length.
let texts: Vec<String> = self
.boxes