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:
parent
3bafe45ab3
commit
57d3e620ea
5 changed files with 141 additions and 17 deletions
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue