From 31ac5372d6ff582d1b504c72dab238b2d536c19c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 8 Jul 2026 00:16:59 +0300 Subject: [PATCH] feat(command): clickable option buttons in the command line (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/mod.rs | 3 ++ src/app/update/mod.rs | 20 +++++++++++ src/command.rs | 39 +++++++++++++++++++++ src/modules/draw/draw/arc.rs | 38 ++++++++++++++++++++ src/modules/draw/draw/circle.rs | 30 ++++++++++++++++ src/modules/draw/draw/ellipse.rs | 24 +++++++++++++ src/modules/draw/draw/hatch.rs | 44 +++++++++++++++++------ src/modules/draw/draw/point.rs | 7 +++- src/modules/draw/draw/polyline.rs | 18 +++++++++- src/modules/draw/draw/ray.rs | 22 ++++++++++-- src/modules/draw/draw/shapes.rs | 58 +++++++++++++++++++++++++++++++ src/modules/draw/draw/solid2d.rs | 12 ++++++- src/modules/draw/draw/spline.rs | 17 ++++++--- src/modules/draw/modify/fillet.rs | 26 ++++++++++++-- src/modules/draw/modify/pedit.rs | 50 ++++++++++++++++++++++++-- src/modules/draw/modify/scale.rs | 11 +++++- src/modules/draw/modify/trim.rs | 12 +++++-- src/ui/command_line.rs | 54 +++++++++++++++++++++++++++- 18 files changed, 457 insertions(+), 28 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index ad912dd81..1b7358b24 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1300,6 +1300,9 @@ pub enum Message { /// User clicked an autocomplete suggestion — fill the input with /// the chosen command name and dispatch it. CommandSuggestionPick(String), + /// User clicked a command-line option button — feed its keyword to the + /// active command as if typed (empty keyword = Enter). (#304) + CommandOptionPick(String), ToggleLayers, LayerToggleVisible(usize), LayerToggleLock(usize), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 542fbdb51..6bab75c96 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -147,6 +147,13 @@ impl OpenCADStudio { .as_ref() .map(|c| c.prompt()); self.command_line.set_step_prompt(prompt); + // Mirror the step's clickable options so they render as buttons (#304). + let opts = self.tabs[self.active_tab] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); // Persist UI preferences whenever a toggle changes them (issue #68). self.persist_settings_if_changed(); // OTRACK acquires tracking points only while a command or grip drag is @@ -829,6 +836,19 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } + Message::CommandOptionPick(kw) => { + // Clicking an option button feeds its keyword to the active + // command through the same path as typed text; an empty keyword + // finishes the step like Enter. (#304) + self.command_line.input.clear(); + self.command_line.close_history(); + if kw.is_empty() { + return self.feed_command(crate::command::StepInput::Enter); + } + self.feed_active_cmd(&kw); + Task::none() + } + Message::CommandSubmit => self.on_command_submit(), Message::CommandSpace => { diff --git a/src/command.rs b/src/command.rs index 3587d7e41..b2c6cd001 100644 --- a/src/command.rs +++ b/src/command.rs @@ -499,6 +499,37 @@ pub struct DynSpec { // ── Trait ───────────────────────────────────────────────────────────────── /// An interactive CAD command that collects user input step-by-step. +/// One clickable option a command step offers. Rendered as a button next to +/// the command-line prompt; clicking it feeds `keyword` to the running command +/// exactly as if the user had typed it (routed through `on_text_input`). An +/// empty `keyword` submits the step like pressing Enter (the finish / default +/// action). Lets every bracketed `[A=arc L=line …]` prompt become buttons so +/// the option need not be typed. (#304) +#[derive(Clone, Debug)] +pub struct CmdOption { + /// Text shown on the button, e.g. `"3P"` or `"Close"`. + pub label: String, + /// Token fed to the command when clicked, e.g. `"3P"`. Empty = Enter. + pub keyword: String, +} + +impl CmdOption { + /// Button whose keyword is typed on click, e.g. `("Ttr", "TTR")`. + pub fn new(label: &str, keyword: &str) -> Self { + Self { + label: label.to_string(), + keyword: keyword.to_string(), + } + } + /// A "finish" button that submits the step like Enter. + pub fn enter(label: &str) -> Self { + Self { + label: label.to_string(), + keyword: String::new(), + } + } +} + pub trait CadCommand: Send { /// Short name shown in the command line prompt, e.g. `"LINE"`. #[allow(dead_code)] @@ -506,6 +537,14 @@ pub trait CadCommand: Send { /// Current prompt string to display in the command line. fn prompt(&self) -> String; + /// Clickable keyword options for the current step, rendered as buttons in + /// the command line next to the prompt. Default: none. A command returns + /// different options per step; clicking a button feeds its keyword through + /// the same path as typed command-line text (`on_text_input`). (#304) + fn options(&self) -> Vec { + Vec::new() + } + /// Push the active UCS into the command as a UCS→render(wire)-space affine /// (identity = plain WCS). Commands that build axis-aligned geometry (RECT, /// rectangular ARRAY, …) override this to store it and rotate their implicit diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 0190410e7..d4fa47795 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -254,6 +254,28 @@ impl CadCommand for ArcCommand { ), } } + + fn options(&self) -> Vec { + 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 { + // 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 { match self.step { 1 => Some(line_wire(self.c, pt)), diff --git a/src/modules/draw/draw/circle.rs b/src/modules/draw/draw/circle.rs index a44765121..47d598ad3 100644 --- a/src/modules/draw/draw/circle.rs +++ b/src/modules/draw/draw/circle.rs @@ -142,6 +142,24 @@ impl CadCommand for CircleCommand { ), } } + + fn options(&self) -> Vec { + 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 { + // 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 { diff --git a/src/modules/draw/draw/ellipse.rs b/src/modules/draw/draw/ellipse.rs index a974da38f..f782b2289 100644 --- a/src/modules/draw/draw/ellipse.rs +++ b/src/modules/draw/draw/ellipse.rs @@ -135,6 +135,21 @@ impl CadCommand for EllipseCommand { } } + fn options(&self) -> Vec { + 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 { + // 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 { diff --git a/src/modules/draw/draw/hatch.rs b/src/modules/draw/draw/hatch.rs index 0212c6d45..dce2af062 100644 --- a/src/modules/draw/draw/hatch.rs +++ b/src/modules/draw/draw/hatch.rs @@ -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 { + 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 { + 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![] } } } diff --git a/src/modules/draw/draw/point.rs b/src/modules/draw/draw/point.rs index 8b7de36cf..56fb4978f 100644 --- a/src/modules/draw/draw/point.rs +++ b/src/modules/draw/draw/point.rs @@ -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 { + use crate::command::CmdOption; + vec![CmdOption::enter("Done")] } fn on_point(&mut self, pt: DVec3) -> CmdResult { diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 5d8ade5bc..009b119e4 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -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 { + 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(); diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 91d0c0474..3e62cc65d 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -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 { + 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 { + use crate::command::CmdOption; + if self.base.is_some() { + vec![CmdOption::enter("Done")] + } else { + vec![] } } diff --git a/src/modules/draw/draw/shapes.rs b/src/modules/draw/draw/shapes.rs index 24e5520a1..733710d36 100644 --- a/src/modules/draw/draw/shapes.rs +++ b/src/modules/draw/draw/shapes.rs @@ -170,6 +170,37 @@ impl CadCommand for RectCommand { "RECT Specify opposite corner:".into() } } + + fn options(&self) -> Vec { + 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 { + // 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 { + 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 { + // 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::() { if (3..=1024).contains(&n) { self.sides = n; diff --git a/src/modules/draw/draw/solid2d.rs b/src/modules/draw/draw/solid2d.rs index 709e8e021..d896a9ef9 100644 --- a/src/modules/draw/draw/solid2d.rs +++ b/src/modules/draw/draw/solid2d.rs @@ -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 { + 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() } } diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 2a8b86cd3..4fd3b1a77 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -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 { + 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 diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 7d09129c4..116c8ae81 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -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 { + 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 { + 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, diff --git a/src/modules/draw/modify/pedit.rs b/src/modules/draw/modify/pedit.rs index 3431cc182..17c8ce356 100644 --- a/src/modules/draw/modify/pedit.rs +++ b/src/modules/draw/modify/pedit.rs @@ -16,11 +16,17 @@ use crate::command::{CadCommand, CmdResult}; pub struct PeditCommand { target: Option, + /// 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 { + 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 `. + if let Some(rest) = up.strip_prefix("W ") { let w: f64 = rest .trim() .replace(',', ".") diff --git a/src/modules/draw/modify/scale.rs b/src/modules/draw/modify/scale.rs index 15f735625..b212d14a4 100644 --- a/src/modules/draw/modify/scale.rs +++ b/src/modules/draw/modify/scale.rs @@ -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 { + 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 => { diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index 7005cd70e..aa56a45ec 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -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 { + 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 { + vec![crate::command::CmdOption::enter("Done")] } fn needs_entity_pick(&self) -> bool { diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 87af7f54d..2bf88d5cc 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -3,6 +3,7 @@ use iced::time::Instant; use crate::app::Message; +use crate::command::CmdOption; use iced::widget::{ button, column, container, opaque, row, text, text_editor, text_input, Space, }; @@ -52,6 +53,10 @@ pub struct CommandLine { /// The active command step's prompt, mirrored here so a step change /// can be detected and the pinned (non-fading) history line updated. step_prompt: Option, + /// The active command step's clickable options, mirrored here so they + /// render as buttons above the input row. Empty when no command is + /// active or the current step offers no options. (#304) + step_options: Vec, } #[derive(Clone, Debug)] @@ -213,6 +218,12 @@ impl CommandLine { self.step_prompt = prompt; } + /// Mirror the active command step's clickable options so `view` can render + /// them as buttons. Called each frame alongside [`Self::set_step_prompt`]. + pub fn set_step_options(&mut self, opts: Vec) { + self.step_options = opts; + } + /// `true` while at least one history entry is still within the /// visible window — the host app uses this to drive a low-frequency /// tick subscription so the overlay re-renders and fades the entry @@ -318,7 +329,48 @@ impl CommandLine { EntryKind::Error => ERR_COLOR, EntryKind::Info => INFO_COLOR, }; - col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8])) + // The current step's prompt is the single pinned line. When the + // step offers options, render them as clickable buttons inline + // beside the prompt text — so options need never be typed and + // don't clutter the prompt string itself. (#304) + if entry.pinned && !self.step_options.is_empty() { + let mut r = row![text(&entry.text).size(11).color(color)] + .spacing(6) + .align_y(iced::Center); + for opt in &self.step_options { + let btn = button( + text(opt.label.to_uppercase()).size(11).color(CMD_COLOR), + ) + .on_press(Message::CommandOptionPick(opt.keyword.clone())) + .padding([1, 6]) + .style(|_: &Theme, status| { + let bg = if matches!(status, button::Status::Hovered) { + Color { + r: 0.28, + g: 0.40, + b: 0.56, + a: 1.0, + } + } else { + INPUT_ROW_BG + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::WHITE, + border: Border { + color: BORDER_COLOR, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } + }); + r = r.push(btn); + } + col.push(container(r).padding([1, 8])) + } else { + col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8])) + } }); let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]); // While dynamic input is capturing keystrokes, the command-line