feat(command): clickable option buttons in the command line (#304)

Commands now expose their step options as clickable buttons rendered
inline beside the command-line prompt, so options need not be typed.

Adds a CmdOption/options() API to CadCommand: each command returns the
options for its current step; the command line renders them as
uppercase buttons and a click feeds the keyword to the command exactly
as if typed (an empty keyword submits like Enter).

Construction commands become "central" — CIRCLE offers [3P/2P/TTR/TTT/
DIAMETER], ELLIPSE [ARC/AXIS], ARC [SCE/SCA/SEA/SER/CSA/3P], RECTANGLE
[ROTATION/CENTER], POLYGON [CIRCUMSCRIBED/EDGE] — each keyword hands off
to the existing dedicated variant command via CmdResult::Dispatch,
instead of the variants only being reachable as isolated commands.

Migrates the existing bracketed-option prompts (PLINE, PEDIT, SPLINE,
SCALE, FILLET, CHAMFER, HATCH, GRADIENT, SOLID, POINT, RAY, XLINE, TRIM,
EXTEND) to the same button API and strips the option text from the
prompt strings, keeping value/coordinate hints. PEDIT's Width option
now enters a value sub-step so the button acts on click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-08 00:16:59 +03:00
1 parent efa4bdd5e1
commit 31ac5372d6
18 files changed
+457 -28

No files matched your search

+38
View File
@@ -254,6 +254,28 @@ impl CadCommand for ArcCommand {
),
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
// Alternate construction methods, offered only at the first (center)
// step. Each keyword hands off to the dedicated variant command.
if self.step == 0 {
vec![
CmdOption::new("SCE", "SCE"),
CmdOption::new("SCA", "SCA"),
CmdOption::new("SEA", "SEA"),
CmdOption::new("SER", "SER"),
CmdOption::new("CSA", "CSA"),
CmdOption::new("3P", "3P"),
]
} else {
vec![]
}
}
fn point_step_accepts_keywords(&self) -> bool {
self.step == 0
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.step {
0 => {
@@ -284,6 +306,22 @@ impl CadCommand for ArcCommand {
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// At the center step, keyword options switch construction method by
// handing off to the dedicated variant command.
if self.step == 0 {
return match text.trim().to_uppercase().as_str() {
"SCE" => Some(CmdResult::Dispatch("ARC_SCE".into())),
"SCA" => Some(CmdResult::Dispatch("ARC_SCA".into())),
"SEA" => Some(CmdResult::Dispatch("ARC_SEA".into())),
"SER" => Some(CmdResult::Dispatch("ARC_SER".into())),
"CSA" => Some(CmdResult::Dispatch("ARC_CSA".into())),
"3P" => Some(CmdResult::Dispatch("ARC_3P".into())),
_ => None,
};
}
None
}
fn on_mouse_move(&mut self, pt: DVec3) -> Option<WireModel> {
match self.step {
1 => Some(line_wire(self.c, pt)),
+30
View File
@@ -142,6 +142,24 @@ impl CadCommand for CircleCommand {
),
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
StepCR::Center => vec![
CmdOption::new("3P", "3P"),
CmdOption::new("2P", "2P"),
CmdOption::new("Ttr", "TTR"),
CmdOption::new("Ttt", "TTT"),
CmdOption::new("Diameter", "D"),
],
StepCR::Radius(_) => vec![],
}
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, StepCR::Center)
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match &self.step {
StepCR::Center => {
@@ -174,6 +192,18 @@ impl CadCommand for CircleCommand {
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// At the centre step, keyword options switch construction method by
// handing off to the dedicated variant command. (#304)
if matches!(self.step, StepCR::Center) {
return match text.trim().to_uppercase().as_str() {
"3P" => Some(CmdResult::Dispatch("CIRCLE_3P".into())),
"2P" => Some(CmdResult::Dispatch("CIRCLE_2P".into())),
"T" | "TTR" => Some(CmdResult::Dispatch("CIRCLE_TTR".into())),
"TTT" => Some(CmdResult::Dispatch("CIRCLE_TTT".into())),
"D" | "DIAMETER" => Some(CmdResult::Dispatch("CIRCLE_CD".into())),
_ => None,
};
}
if let StepCR::Radius(c) = &self.step {
let r: f64 = text.trim().replace(',', ".").parse().ok()?;
if r > 0.0 {
+24
View File
@@ -135,6 +135,21 @@ impl CadCommand for EllipseCommand {
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
CtrStep::Center => vec![
CmdOption::new("Arc", "ARC"),
CmdOption::new("Axis", "AXIS"),
],
_ => vec![],
}
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, CtrStep::Center)
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match &self.step {
CtrStep::Center => {
@@ -164,6 +179,15 @@ impl CadCommand for EllipseCommand {
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// At the centre step, keyword options switch construction method by
// handing off to the dedicated variant command.
if matches!(self.step, CtrStep::Center) {
return match text.trim().to_uppercase().as_str() {
"A" | "ARC" => Some(CmdResult::Dispatch("ELLIPSE_ARC".into())),
"AXIS" => Some(CmdResult::Dispatch("ELLIPSE_AXIS".into())),
_ => None,
};
}
if let CtrStep::MinorRatio { center, major } = &self.step {
let r_minor = parse_num(text)?;
if r_minor > 0.0 {
+34 -10
View File
@@ -139,16 +139,28 @@ impl CadCommand for HatchCommand {
} else {
""
};
format!("HATCH Pick internal point [S=Draw manually]:{miss}")
format!("HATCH Pick internal point:{miss}")
}
Mode::Manual => {
if self.manual_pts.is_empty() {
"HATCH Boundary point 1:".into()
} else {
format!(
"HATCH Point {} [Enter=accept, ≥3 points]:",
self.manual_pts.len() + 1
)
format!("HATCH Point {}:", self.manual_pts.len() + 1)
}
}
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match &self.mode {
Mode::PickInside => vec![CmdOption::new("Draw manually", "S")],
Mode::Manual => {
// Enter accepts the boundary once at least 3 points are picked.
if self.manual_pts.len() >= 3 {
vec![CmdOption::enter("Accept")]
} else {
vec![]
}
}
}
@@ -277,16 +289,28 @@ impl CadCommand for GradientCommand {
} else {
""
};
format!("GRADIENT Pick internal point [S=Draw manually]:{miss}")
format!("GRADIENT Pick internal point:{miss}")
}
Mode::Manual => {
if self.manual_pts.is_empty() {
"GRADIENT Boundary point 1:".into()
} else {
format!(
"GRADIENT Point {} [Enter=accept, ≥3 points]:",
self.manual_pts.len() + 1
)
format!("GRADIENT Point {}:", self.manual_pts.len() + 1)
}
}
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match &self.mode {
Mode::PickInside => vec![CmdOption::new("Draw manually", "S")],
Mode::Manual => {
// Enter accepts the boundary once at least 3 points are picked.
if self.manual_pts.len() >= 3 {
vec![CmdOption::enter("Accept")]
} else {
vec![]
}
}
}
+6 -1
View File
@@ -34,7 +34,12 @@ impl CadCommand for PointCommand {
"POINT"
}
fn prompt(&self) -> String {
"POINT Specify point [Enter=done]:".into()
"POINT Specify point:".into()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
vec![CmdOption::enter("Done")]
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
+17 -1
View File
@@ -218,12 +218,28 @@ impl CadCommand for PlineCommand {
"PLINE Specify start point:".into()
} else {
format!(
"PLINE [{mode_tag}] Next pt [{}pts | A=arc L=line C=close Enter=done]:",
"PLINE [{mode_tag}] Next pt [{}pts]:",
self.vertices.len()
)
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
// Options appear once at least one vertex is placed: the segment step
// accepts A / L / C, and Enter finishes.
if self.vertices.is_empty() {
Vec::new()
} else {
vec![
CmdOption::new("Arc", "A"),
CmdOption::new("Line", "L"),
CmdOption::new("Close", "C"),
CmdOption::enter("Done"),
]
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult { let pt = pt.as_vec3();
if !self.vertices.is_empty() {
let last = *self.vertices.last().unwrap();
+20 -2
View File
@@ -35,7 +35,16 @@ impl CadCommand for RayCommand {
if self.base.is_none() {
"RAY Specify start point:".into()
} else {
"RAY Specify through point [Enter/Esc = done]:".into()
"RAY Specify through point:".into()
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
if self.base.is_some() {
vec![CmdOption::enter("Done")]
} else {
vec![]
}
}
@@ -117,7 +126,16 @@ impl CadCommand for XLineCommand {
if self.base.is_none() {
"XLINE Specify a point:".into()
} else {
"XLINE Specify through point [Enter/Esc = done]:".into()
"XLINE Specify through point:".into()
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
if self.base.is_some() {
vec![CmdOption::enter("Done")]
} else {
vec![]
}
}
+58
View File
@@ -170,6 +170,37 @@ impl CadCommand for RectCommand {
"RECT Specify opposite corner:".into()
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
// First corner also offers the alternate rectangle methods; later steps
// are plain point picks. (#304)
if self.a.is_none() {
vec![
CmdOption::new("Rotation", "ROTATION"),
CmdOption::new("Center", "CENTER"),
]
} else {
vec![]
}
}
fn point_step_accepts_keywords(&self) -> bool {
self.a.is_none()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// At the first corner, keyword options hand off to the dedicated
// variant command. (#304)
if self.a.is_none() {
return match text.trim().to_uppercase().as_str() {
"R" | "ROTATION" => Some(CmdResult::Dispatch("RECT_ROT".into())),
"C" | "CENTER" => Some(CmdResult::Dispatch("RECT_CEN".into())),
_ => None,
};
}
None
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match self.a {
None => {
@@ -438,6 +469,24 @@ impl CadCommand for PolyCommand {
self.step == 0
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
// The sides step also offers the alternate polygon methods; later steps
// are plain point picks. (#304)
if self.step == 0 {
vec![
CmdOption::new("Circumscribed", "CIRCUMSCRIBED"),
CmdOption::new("Edge", "EDGE"),
]
} else {
vec![]
}
}
fn point_step_accepts_keywords(&self) -> bool {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
@@ -461,6 +510,15 @@ impl CadCommand for PolyCommand {
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
// At the sides step, keyword options hand off to the dedicated variant
// command; numeric input still sets the side count. (#304)
if self.step == 0 {
match text.trim().to_uppercase().as_str() {
"C" | "CIRCUMSCRIBED" => return Some(CmdResult::Dispatch("POLY_C".into())),
"E" | "EDGE" => return Some(CmdResult::Dispatch("POLY_E".into())),
_ => {}
}
}
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
self.sides = n;
+11 -1
View File
@@ -55,7 +55,17 @@ impl CadCommand for Solid2dCommand {
0 => "SOLID Specify first point:".to_string(),
1 => "SOLID Specify second point:".to_string(),
2 => "SOLID Specify third point:".to_string(),
_ => "SOLID Specify fourth point [Enter = triangle]:".to_string(),
_ => "SOLID Specify fourth point:".to_string(),
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
// After three corners Enter commits a triangle; offer a Done button.
if self.points.len() == 3 {
vec![CmdOption::enter("Done")]
} else {
Vec::new()
}
}
+13 -4
View File
@@ -80,13 +80,22 @@ impl CadCommand for SplineCommand {
if self.pts.is_empty() {
"SPLINE Specify first control point:".into()
} else {
format!(
"SPLINE Specify next point [{} pts | Close/Undo | Enter=done]:",
self.pts.len()
)
format!("SPLINE Specify next point [{} pts]:", self.pts.len())
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
if self.pts.is_empty() {
return Vec::new();
}
let mut opts = vec![CmdOption::new("Close", "C")];
// Undo only makes sense once a control point exists.
opts.push(CmdOption::new("Undo", "U"));
opts.push(CmdOption::enter("Done"));
opts
}
fn on_point(&mut self, pt: DVec3) -> CmdResult { let pt = pt.as_vec3();
self.pts.push(pt);
CmdResult::NeedPoint
+24 -2
View File
@@ -1249,7 +1249,7 @@ impl CadCommand for FilletCommand {
fn prompt(&self) -> String {
match &self.step {
FilletStep::First => format!(
"FILLET Select first object (Line/Arc/LwPolyline) [R={:.4} | type R to change]:",
"FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]:",
self.radius
),
FilletStep::WaitingForRadius => {
@@ -1264,6 +1264,16 @@ impl CadCommand for FilletCommand {
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
// Offer the radius keyword while selecting objects; the "R" token is
// already handled by on_text_input.
FilletStep::First | FilletStep::Second { .. } => vec![CmdOption::new("Radius", "R")],
FilletStep::WaitingForRadius => vec![],
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, FilletStep::WaitingForRadius)
}
@@ -1623,7 +1633,7 @@ impl CadCommand for ChamferCommand {
fn prompt(&self) -> String {
match &self.step {
ChamferStep::First => format!(
"CHAMFER Select first line [D1={:.4} D2={:.4} | type D to change]:",
"CHAMFER Select first line [D1={:.4} D2={:.4}]:",
self.dist1, self.dist2
),
ChamferStep::WaitingForDist1 => {
@@ -1644,6 +1654,18 @@ impl CadCommand for ChamferCommand {
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match self.step {
// Offer the distance keyword while selecting objects; the "D" token
// is already handled by on_text_input.
ChamferStep::First
| ChamferStep::Second { .. }
| ChamferStep::SecondPoly { .. } => vec![CmdOption::new("Distance", "D")],
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![],
}
}
fn wants_text_input(&self) -> bool {
matches!(
self.step,
+47 -3
View File
@@ -16,11 +16,17 @@ use crate::command::{CadCommand, CmdResult};
pub struct PeditCommand {
target: Option<Handle>,
/// Set once the bare `W` keyword (or the Width button) is chosen: the next
/// text input is parsed as the new width instead of an option. (#304)
awaiting_width: bool,
}
impl PeditCommand {
pub fn new() -> Self {
Self { target: None }
Self {
target: None,
awaiting_width: false,
}
}
}
@@ -32,8 +38,24 @@ impl CadCommand for PeditCommand {
fn prompt(&self) -> String {
if self.target.is_none() {
"PEDIT Select polyline:".into()
} else if self.awaiting_width {
"PEDIT Specify new width:".into()
} else {
"PEDIT Enter option [C=Close O=Open W=Width X=Exit]:".into()
"PEDIT Enter option:".into()
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
if self.target.is_none() || self.awaiting_width {
vec![]
} else {
vec![
CmdOption::new("Close", "C"),
CmdOption::new("Open", "O"),
CmdOption::new("Width", "W"),
CmdOption::new("eXit", "X"),
]
}
}
@@ -57,6 +79,21 @@ impl CadCommand for PeditCommand {
let handle = self.target?;
let up = text.trim().to_uppercase();
// Width sub-step: the previous input was a bare `W` (typed or via the
// Width button), so this input is the width value. (#304)
if self.awaiting_width {
let w: f64 = up
.replace(',', ".")
.parse()
.ok()
.filter(|&v: &f64| v >= 0.0)?;
self.awaiting_width = false;
return Some(CmdResult::PeditOp {
handle,
op: PeditOp::SetWidth(w),
});
}
match up.as_str() {
"X" | "EXIT" => return Some(CmdResult::Cancel),
"C" | "CLOSE" => {
@@ -71,10 +108,17 @@ impl CadCommand for PeditCommand {
op: PeditOp::SetClosed(false),
})
}
"W" | "WIDTH" => {
// Bare width keyword (the Width button): prompt for the value
// rather than doing nothing.
self.awaiting_width = true;
return Some(CmdResult::NeedPoint);
}
_ => {}
}
if let Some(rest) = up.strip_prefix("W ").or_else(|| up.strip_prefix("W")) {
// Inline shorthand `W <value>`.
if let Some(rest) = up.strip_prefix("W ") {
let w: f64 = rest
.trim()
.replace(',', ".")
+10 -1
View File
@@ -82,7 +82,7 @@ impl CadCommand for ScaleCommand {
self.handles.len()
),
Step::Factor { .. } => format!(
"SCALE Specify scale factor or [Reference] <{:.4}>:",
"SCALE Specify scale factor <{:.4}>:",
self.default_factor
),
Step::RefLen { .. } => {
@@ -95,6 +95,15 @@ impl CadCommand for ScaleCommand {
}
}
fn options(&self) -> Vec<crate::command::CmdOption> {
use crate::command::CmdOption;
match &self.step {
// The reference-scaling keyword is only offered at the factor step.
Step::Factor { .. } => vec![CmdOption::new("Reference", "R")],
_ => vec![],
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match &self.step {
Step::Base => {
+10 -2
View File
@@ -2027,7 +2027,11 @@ impl CadCommand for TrimCommand {
}
fn prompt(&self) -> String {
"TRIM Click segment to remove [Enter=done]:".into()
"TRIM Click segment to remove:".into()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
vec![crate::command::CmdOption::enter("Done")]
}
fn needs_entity_pick(&self) -> bool {
@@ -2537,7 +2541,11 @@ impl CadCommand for ExtendCommand {
}
fn prompt(&self) -> String {
"EXTEND Click near end of object to extend [Enter=done]:".into()
"EXTEND Click near end of object to extend:".into()
}
fn options(&self) -> Vec<crate::command::CmdOption> {
vec![crate::command::CmdOption::enter("Done")]
}
fn needs_entity_pick(&self) -> bool {