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 {
|
if let Some(p) = prompt {
|
||||||
self.command_line.push_info(&p);
|
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) => {
|
CmdResult::Preview(wire) => {
|
||||||
self.tabs[i].scene.set_preview_wires(vec![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);
|
let idx = self.grip_popup.as_ref().map(|p| p.selected).unwrap_or(0);
|
||||||
return Task::done(Message::GripMenuPick(idx));
|
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
|
// A typed dynamic-input value commits as a point pick
|
||||||
// before the plain-Enter (on_enter) path runs.
|
// before the plain-Enter (on_enter) path runs.
|
||||||
if let Some(task) = self.try_dyn_commit() {
|
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-state changes. The field set only changes shape when the
|
||||||
/// command's `dyn_field()` or the presence of a base point changes;
|
/// command's `dyn_field()` or the presence of a base point changes;
|
||||||
/// existing typed buffers survive an unchanged shape.
|
/// 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};
|
use super::document::{DynComponent, DynFieldEntry};
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
if !self.dyn_input || self.tabs[i].active_cmd.is_none() {
|
if !self.dyn_input || self.tabs[i].active_cmd.is_none() {
|
||||||
|
|
@ -7351,6 +7360,15 @@ impl OpenCADStudio {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.wants_text_input())
|
.map(|c| c.wants_text_input())
|
||||||
.unwrap_or(false);
|
.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();
|
let has_base = self.last_point.is_some();
|
||||||
// While aligned to an OTRACK ray, the point step reads a single
|
// While aligned to an OTRACK ray, the point step reads a single
|
||||||
// distance along the ray (issue #69) — show one Distance box.
|
// 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
|
// a name / a keyword (which may itself contain digits) from the
|
||||||
// command line — show no scalar box, so digits reach the command
|
// command line — show no scalar box, so digits reach the command
|
||||||
// line instead of being captured into a dyn buffer.
|
// 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 => {
|
crate::command::DynField::Point if has_base => {
|
||||||
vec![DynComponent::Distance, DynComponent::Angle]
|
vec![DynComponent::Distance, DynComponent::Angle]
|
||||||
}
|
}
|
||||||
|
|
@ -7388,7 +7406,7 @@ impl OpenCADStudio {
|
||||||
// the command's default so e.g. clicking the first point of LINE
|
// 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
|
// flips a stale `[X, Y]` (from before there was a base) over to
|
||||||
// the polar `[Distance, Angle]` the prompt actually wants.
|
// 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 {
|
match field {
|
||||||
crate::command::DynField::Distance => {
|
crate::command::DynField::Distance => {
|
||||||
matches!(current.as_slice(), [DynComponent::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
|
// between boxes; typing locks a box to a fixed value while the
|
||||||
// rest keep tracking the cursor. The field set is maintained in
|
// rest keep tracking the cursor. The field set is maintained in
|
||||||
// `tab.dyn_fields` by `sync_dyn_fields`.
|
// `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>> =
|
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 w = tab.last_cursor_world;
|
||||||
let base = self.last_point;
|
let base = self.last_point;
|
||||||
// A command may drive a typed scalar by mouse (e.g. a
|
// A command may drive a typed scalar by mouse (e.g. a
|
||||||
|
|
|
||||||
|
|
@ -1196,6 +1196,10 @@ pub struct FilletCommand {
|
||||||
radius: f64,
|
radius: f64,
|
||||||
step: FilletStep,
|
step: FilletStep,
|
||||||
all_entities: Vec<EntityType>,
|
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 {
|
impl FilletCommand {
|
||||||
|
|
@ -1204,8 +1208,29 @@ impl FilletCommand {
|
||||||
radius: radius as f64,
|
radius: radius as f64,
|
||||||
step: FilletStep::First,
|
step: FilletStep::First,
|
||||||
all_entities,
|
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 {
|
impl CadCommand for FilletCommand {
|
||||||
|
|
@ -1248,8 +1273,8 @@ impl CadCommand for FilletCommand {
|
||||||
FilletStep::WaitingForRadius => {
|
FilletStep::WaitingForRadius => {
|
||||||
let t = text.trim();
|
let t = text.trim();
|
||||||
if t.is_empty() {
|
if t.is_empty() {
|
||||||
// Keep current radius, return to First
|
// Keep current radius, resume where the radius was requested.
|
||||||
self.step = FilletStep::First;
|
self.resume_after_radius();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
|
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
|
||||||
|
|
@ -1257,7 +1282,7 @@ impl CadCommand for FilletCommand {
|
||||||
self.radius = v;
|
self.radius = v;
|
||||||
defaults::set_fillet_radius(v as f32);
|
defaults::set_fillet_radius(v as f32);
|
||||||
}
|
}
|
||||||
self.step = FilletStep::First;
|
self.resume_after_radius();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// Invalid — stay and re-prompt
|
// Invalid — stay and re-prompt
|
||||||
|
|
@ -1268,7 +1293,7 @@ impl CadCommand for FilletCommand {
|
||||||
let upper = t.to_uppercase();
|
let upper = t.to_uppercase();
|
||||||
// "R" alone → enter sub-step to collect radius
|
// "R" alone → enter sub-step to collect radius
|
||||||
if upper == "R" {
|
if upper == "R" {
|
||||||
self.step = FilletStep::WaitingForRadius;
|
self.enter_radius_substep();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// "R 5.0" inline shorthand
|
// "R 5.0" inline shorthand
|
||||||
|
|
@ -1279,10 +1304,11 @@ impl CadCommand for FilletCommand {
|
||||||
self.radius = v;
|
self.radius = v;
|
||||||
defaults::set_fillet_radius(v as f32);
|
defaults::set_fillet_radius(v as f32);
|
||||||
}
|
}
|
||||||
|
// Stay in the current step (keeps any first pick).
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// "R" + invalid body → enter sub-step
|
// "R" + invalid body → enter sub-step
|
||||||
self.step = FilletStep::WaitingForRadius;
|
self.enter_radius_substep();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
@ -1533,6 +1559,10 @@ pub struct ChamferCommand {
|
||||||
dist2: f64,
|
dist2: f64,
|
||||||
step: ChamferStep,
|
step: ChamferStep,
|
||||||
all_entities: Vec<EntityType>,
|
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 {
|
impl ChamferCommand {
|
||||||
|
|
@ -1542,8 +1572,33 @@ impl ChamferCommand {
|
||||||
dist2: defaults::get_chamfer_dist2() as f64,
|
dist2: defaults::get_chamfer_dist2() as f64,
|
||||||
step: ChamferStep::First,
|
step: ChamferStep::First,
|
||||||
all_entities,
|
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 {
|
impl CadCommand for ChamferCommand {
|
||||||
|
|
@ -1614,14 +1669,14 @@ impl CadCommand for ChamferCommand {
|
||||||
ChamferStep::WaitingForDist2 => {
|
ChamferStep::WaitingForDist2 => {
|
||||||
let t = text.trim();
|
let t = text.trim();
|
||||||
if t.is_empty() {
|
if t.is_empty() {
|
||||||
// Keep current dist2, return to First
|
// Keep current dist2, resume where the distance was requested.
|
||||||
self.step = ChamferStep::First;
|
self.resume_after_dist();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
|
if let Ok(v) = t.replace(',', ".").parse::<f64>() {
|
||||||
self.dist2 = v.max(0.0);
|
self.dist2 = v.max(0.0);
|
||||||
defaults::set_chamfer_dist2(self.dist2 as f32);
|
defaults::set_chamfer_dist2(self.dist2 as f32);
|
||||||
self.step = ChamferStep::First;
|
self.resume_after_dist();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// Invalid — stay and re-prompt
|
// Invalid — stay and re-prompt
|
||||||
|
|
@ -1634,7 +1689,7 @@ impl CadCommand for ChamferCommand {
|
||||||
let upper = t.to_uppercase();
|
let upper = t.to_uppercase();
|
||||||
// "D" alone → enter sub-step to collect distances
|
// "D" alone → enter sub-step to collect distances
|
||||||
if upper == "D" {
|
if upper == "D" {
|
||||||
self.step = ChamferStep::WaitingForDist1;
|
self.enter_dist_substep();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// "D 5.0" or "D 5.0 3.0" inline shorthand
|
// "D 5.0" or "D 5.0 3.0" inline shorthand
|
||||||
|
|
@ -1659,7 +1714,7 @@ impl CadCommand for ChamferCommand {
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
// "D" + invalid body → enter sub-step
|
// "D" + invalid body → enter sub-step
|
||||||
self.step = ChamferStep::WaitingForDist1;
|
self.enter_dist_substep();
|
||||||
return Some(CmdResult::NeedPoint);
|
return Some(CmdResult::NeedPoint);
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -1251,9 +1251,6 @@ impl canvas::Program<Message> for DynInputCanvas {
|
||||||
_cursor: mouse::Cursor,
|
_cursor: mouse::Cursor,
|
||||||
) -> Vec<canvas::Geometry> {
|
) -> Vec<canvas::Geometry> {
|
||||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
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.
|
// Offset the row 14 px right and 20 px below the cursor.
|
||||||
const OFFSET_X: f32 = 14.0;
|
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 CHAR_W: f32 = FONT_SIZE * 0.62; // monospace-ish width estimate
|
||||||
const BOX_H: f32 = FONT_SIZE + PAD * 2.0;
|
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.
|
// Each box is "<label>:<value>"; width tracks the text length.
|
||||||
let texts: Vec<String> = self
|
let texts: Vec<String> = self
|
||||||
.boxes
|
.boxes
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue