From 4b878b88c4e5ba77ec783405370d160a7d5454cc Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 9 Jun 2026 18:32:03 +0300 Subject: [PATCH] feat(input): scalar entry via dynamic input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text-input steps (OFFSET distance, POLYGON sides, radii, counts) were swallowed by the dynamic-input overlay: typed digits landed in the field buffer and Enter committed a point pick, so the value never reached on_text_input (issue #61). Now each text step shows one scalar box whose value routes to on_text_input on commit; non-numeric prompts (names, keywords) keep going to the command line so mixed text like "Bolt12" isn't split. OFFSET reworked: object first, then through-mode by default — the offset follows the cursor at the perpendicular distance to the object. Typing a number locks the magnitude (cursor then only picks the side); a value typed and clicked without Enter is applied before the point pick. The dynamic-input overlay and the command line now show the active step's prompt and hold it until the step completes. Co-Authored-By: Claude Opus 4.8 --- src/app/commands.rs | 5 +- src/app/document.rs | 4 + src/app/update.rs | 84 ++++++++++++++- src/app/view.rs | 49 +++++++-- src/command/mod.rs | 15 +++ src/modules/home/draw/circle.rs | 8 ++ src/modules/home/draw/donut.rs | 8 ++ src/modules/home/draw/shapes.rs | 24 +++++ src/modules/home/inquiry/divide.rs | 16 +++ src/modules/home/modify/array.rs | 24 +++++ src/modules/home/modify/fillet.rs | 19 ++++ src/modules/home/modify/lengthen.rs | 8 ++ src/modules/home/modify/offset.rs | 162 ++++++++++++++++++---------- src/ui/command_line.rs | 14 ++- src/ui/overlay.rs | 35 +++++- 15 files changed, 405 insertions(+), 70 deletions(-) diff --git a/src/app/commands.rs b/src/app/commands.rs index b6389fa7c..bfca8ec95 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1734,10 +1734,7 @@ impl OpenCADStudio { self.tabs[i].scene.document.get_entity(h).cloned() }) .collect(); - let new_cmd = OffsetCommand::new( - crate::modules::home::defaults::get_offset_dist(), - all_entities, - ); + let new_cmd = OffsetCommand::new(all_entities); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/document.rs b/src/app/document.rs index 0667c9e7c..8dece0e79 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -27,6 +27,10 @@ pub(super) enum DynComponent { Distance, /// Angle from the last point, in degrees. Angle, + /// A scalar the command reads from the command line (a count, a radius, + /// a delta). Typed-only — it has no geometric live value derived from + /// the cursor unless the command supplies one via `dyn_live_value`. + Scalar, } /// A single editable dynamic-input field. `buffer == None` means the box diff --git a/src/app/update.rs b/src/app/update.rs index b6ec588fd..d9a423d0d 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2850,6 +2850,31 @@ impl OpenCADStudio { None } } else { + // A scalar typed into the dynamic-input box but not + // yet confirmed with Enter is applied before the + // point pick — e.g. an OFFSET distance typed and then + // clicked takes effect rather than being discarded. + let wants_text = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.wants_text_input()) + .unwrap_or(false); + if wants_text { + if let Some(text) = self.tabs[i] + .dyn_fields + .iter() + .find_map(|f| f.buffer.clone()) + { + let text = super::expr_eval::eval_to_string(text.trim()); + if let Some(c) = self.tabs[i].active_cmd.as_mut() { + c.on_text_input(&text); + } + for f in self.tabs[i].dyn_fields.iter_mut() { + f.buffer = None; + } + self.tabs[i].dyn_active = 0; + } + } self.last_point = Some(world_pt); self.dyn_user_reshaped = false; self.sync_dyn_fields(); @@ -7106,10 +7131,28 @@ impl OpenCADStudio { .as_ref() .map(|c| c.dyn_field()) .unwrap_or(crate::command::DynField::Point); + // A text-input step reads a single scalar. The overlay shows one box + // the user types into (or, when the command supplies a live value, + // sets by moving the cursor); on commit the host routes it to + // `on_text_input` instead of `on_point`. A distance prompt keeps the + // `Distance` box (so a perpendicular-distance live value reads + // naturally); everything else uses the typed-only `Scalar` box. + let wants_text = self + .tabs[i] + .active_cmd + .as_ref() + .map(|c| c.wants_text_input()) + .unwrap_or(false); let has_base = self.last_point.is_some(); let default: Vec = match field { crate::command::DynField::Distance => vec![DynComponent::Distance], crate::command::DynField::Angle => vec![DynComponent::Angle], + crate::command::DynField::Scalar => vec![DynComponent::Scalar], + // A text prompt with the default `Point` field reads free text / + // 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 has_base => { vec![DynComponent::Distance, DynComponent::Angle] } @@ -7128,7 +7171,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 { + let current_is_acceptable = if self.dyn_user_reshaped && !wants_text { match field { crate::command::DynField::Distance => { matches!(current.as_slice(), [DynComponent::Distance]) @@ -7136,6 +7179,9 @@ impl OpenCADStudio { crate::command::DynField::Angle => { matches!(current.as_slice(), [DynComponent::Angle]) } + crate::command::DynField::Scalar => { + matches!(current.as_slice(), [DynComponent::Scalar]) + } crate::command::DynField::Point => matches!( current.as_slice(), [DynComponent::Distance, DynComponent::Angle] @@ -7447,6 +7493,42 @@ impl OpenCADStudio { { return None; } + // A text-input step reads its single box as a string and commits via + // `on_text_input` (a count, radius, distance) rather than resolving a + // point. Only the typed buffer matters here — a mouse-driven live + // value commits through the viewport click, not Enter. + let wants_text = self + .tabs[i] + .active_cmd + .as_ref() + .map(|c| c.wants_text_input()) + .unwrap_or(false); + if wants_text { + let text = self.tabs[i] + .dyn_fields + .iter() + .find_map(|f| f.buffer.clone()) + .unwrap_or_default(); + let text = super::expr_eval::eval_to_string(text.trim()); + let result = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|c| c.on_text_input(&text)); + for f in self.tabs[i].dyn_fields.iter_mut() { + f.buffer = None; + } + self.tabs[i].dyn_active = 0; + self.sync_dyn_fields(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + self.refresh_active_cmd_preview(i); + return Some(match result { + Some(r) => self.apply_cmd_result(r), + None => self.focus_cmd_input(), + }); + } let pt = self.dyn_resolve_point()?; self.last_point = Some(pt); self.dyn_user_reshaped = false; diff --git a/src/app/view.rs b/src/app/view.rs index eaa358d54..f08fe068c 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -676,18 +676,45 @@ impl OpenCADStudio { if self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty() { let w = tab.last_cursor_world; let base = self.last_point; + // A command may drive a typed scalar by mouse (e.g. a + // perpendicular distance to a picked object); show that live + // value in the box until the user types over it. + let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)); let boxes: Vec = tab .dyn_fields .iter() .enumerate() - .map(|(idx, f)| overlay::DynBox { - label: dyn_component_label(f.component), - value: dyn_component_value(f, w, base), - active: idx == tab.dyn_active, - locked: f.locked(), + .map(|(idx, f)| { + let value = match (&f.buffer, live) { + (Some(b), _) => b.clone(), + (None, Some(lv)) + if matches!( + f.component, + DynComponent::Scalar | DynComponent::Distance + ) => + { + format!("{lv:.4}") + } + _ => dyn_component_value(f, w, base), + }; + overlay::DynBox { + label: dyn_component_label(f.component), + value, + active: idx == tab.dyn_active, + locked: f.locked(), + } }) .collect(); - Some(overlay::dynamic_input_overlay(tab.last_cursor_screen, boxes)) + let prompt = tab + .active_cmd + .as_ref() + .map(|c| c.prompt()) + .unwrap_or_default(); + Some(overlay::dynamic_input_overlay( + tab.last_cursor_screen, + boxes, + prompt, + )) } else { None }; @@ -1150,8 +1177,13 @@ impl OpenCADStudio { && !tab.dyn_fields.is_empty()) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); + let active_prompt = tab.active_cmd.as_ref().map(|c| c.prompt()); let command_line_overlay = - iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing)) + iced::widget::container(self.command_line.view( + allow_autocomplete, + dyn_capturing, + active_prompt, + )) .width(Fill) .height(Fill) .align_x(iced::alignment::Horizontal::Center) @@ -3496,6 +3528,7 @@ fn dyn_component_label(c: DynComponent) -> String { DynComponent::Z => "Z".into(), DynComponent::Distance => "d".into(), DynComponent::Angle => "<".into(), + DynComponent::Scalar => "".into(), } } @@ -3527,5 +3560,7 @@ fn dyn_component_value( DynComponent::Z => format!("{:.4}", b.z), DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()), DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)), + // Typed-only scalar — no geometric value to track when empty. + DynComponent::Scalar => String::new(), } } diff --git a/src/command/mod.rs b/src/command/mod.rs index e73bedf00..75a5562dc 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -232,6 +232,10 @@ pub enum DynField { Distance, /// An angle, shown in degrees, measured from the last point. Angle, + /// A typed scalar with no geometric meaning at the cursor — a count + /// (number of sides / segments) or any value the command reads purely + /// from the keyboard. Shown as a single typed box. + Scalar, } // ── Trait ───────────────────────────────────────────────────────────────── @@ -379,6 +383,17 @@ pub trait CadCommand: Send { fn dyn_field(&self) -> DynField { DynField::Point } + + /// Live value for the dynamic-input scalar box, derived from the cursor + /// world position. Lets a command drive a typed prompt by mouse — e.g. + /// OFFSET returns the perpendicular distance from the cursor to the + /// object being offset, so moving the cursor fills in the distance. + /// Returns `None` when the value can only be typed (a count, or a + /// distance with no reference yet). The string the host commits is this + /// value formatted; the command's own `on_text_input` parses it back. + fn dyn_live_value(&self, _cursor: Vec3) -> Option { + None + } } // ── Autocomplete registry ───────────────────────────────────────────────── diff --git a/src/modules/home/draw/circle.rs b/src/modules/home/draw/circle.rs index c6f0ecf64..bdb604f26 100644 --- a/src/modules/home/draw/circle.rs +++ b/src/modules/home/draw/circle.rs @@ -781,6 +781,14 @@ impl CadCommand for CircleTTRCommand { matches!(self.step, StepTTR::Radius { .. }) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.step, StepTTR::Radius { .. }) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn prompt(&self) -> String { match &self.step { StepTTR::First => "CIRCLE TTR Select first tangent object:".into(), diff --git a/src/modules/home/draw/donut.rs b/src/modules/home/draw/donut.rs index 715d14048..091e269aa 100644 --- a/src/modules/home/draw/donut.rs +++ b/src/modules/home/draw/donut.rs @@ -55,6 +55,14 @@ impl CadCommand for DonutCommand { matches!(self.state, DonutState::AskInner | DonutState::AskOuter) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.state, DonutState::AskInner | DonutState::AskOuter) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { let val: f64 = text .trim() diff --git a/src/modules/home/draw/shapes.rs b/src/modules/home/draw/shapes.rs index 7d03b3a09..c38b999ae 100644 --- a/src/modules/home/draw/shapes.rs +++ b/src/modules/home/draw/shapes.rs @@ -351,6 +351,14 @@ impl CadCommand for PolyCommand { self.step == 0 } + fn dyn_field(&self) -> crate::command::DynField { + if self.step == 0 { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { if let Ok(n) = text.trim().parse::() { if (3..=1024).contains(&n) { @@ -443,6 +451,14 @@ impl CadCommand for PolyCCommand { self.step == 0 } + fn dyn_field(&self) -> crate::command::DynField { + if self.step == 0 { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { if let Ok(n) = text.trim().parse::() { if (3..=1024).contains(&n) { @@ -544,6 +560,14 @@ impl CadCommand for PolyECommand { self.step == 0 } + fn dyn_field(&self) -> crate::command::DynField { + if self.step == 0 { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { if let Ok(n) = text.trim().parse::() { if (3..=1024).contains(&n) { diff --git a/src/modules/home/inquiry/divide.rs b/src/modules/home/inquiry/divide.rs index ee329b3db..2f5140ad1 100644 --- a/src/modules/home/inquiry/divide.rs +++ b/src/modules/home/inquiry/divide.rs @@ -56,6 +56,14 @@ impl CadCommand for DivideCommand { self.waiting_for_n } + fn dyn_field(&self) -> crate::command::DynField { + if self.waiting_for_n { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { let n: usize = text.trim().parse().ok().filter(|&n| n >= 2)?; let handle = self.target?; @@ -117,6 +125,14 @@ impl CadCommand for MeasureCommand { self.waiting_for_dist } + fn dyn_field(&self) -> crate::command::DynField { + if self.waiting_for_dist { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { let dist: f64 = text .trim() diff --git a/src/modules/home/modify/array.rs b/src/modules/home/modify/array.rs index a76459232..0d0b2e277 100644 --- a/src/modules/home/modify/array.rs +++ b/src/modules/home/modify/array.rs @@ -122,6 +122,10 @@ impl CadCommand for ArrayRectCommand { true } + fn dyn_field(&self) -> crate::command::DynField { + crate::command::DynField::Scalar + } + fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); let t = t.as_str(); @@ -279,6 +283,14 @@ impl CadCommand for ArrayPolarCommand { matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. }) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. }) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); let t = t.as_str(); @@ -601,6 +613,14 @@ impl CadCommand for ArrayPathCommand { matches!(self.step, PathStep::Count { .. }) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.step, PathStep::Count { .. }) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult { if handle.is_null() || self.handles.contains(&handle) { return CmdResult::NeedPoint; @@ -811,6 +831,10 @@ impl CadCommand for Array3DCommand { true } + fn dyn_field(&self) -> crate::command::DynField { + crate::command::DynField::Scalar + } + fn on_text_input(&mut self, text: &str) -> Option { let t = text.trim().replace(',', "."); let t = t.as_str(); diff --git a/src/modules/home/modify/fillet.rs b/src/modules/home/modify/fillet.rs index e2ffa39be..a140ec175 100644 --- a/src/modules/home/modify/fillet.rs +++ b/src/modules/home/modify/fillet.rs @@ -1235,6 +1235,14 @@ impl CadCommand for FilletCommand { matches!(self.step, FilletStep::WaitingForRadius) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.step, FilletStep::WaitingForRadius) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { match self.step { FilletStep::WaitingForRadius => { @@ -1574,6 +1582,17 @@ impl CadCommand for ChamferCommand { ) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!( + self.step, + ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 + ) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { match self.step { ChamferStep::WaitingForDist1 => { diff --git a/src/modules/home/modify/lengthen.rs b/src/modules/home/modify/lengthen.rs index 8beb1ae91..50c641133 100644 --- a/src/modules/home/modify/lengthen.rs +++ b/src/modules/home/modify/lengthen.rs @@ -68,6 +68,14 @@ impl CadCommand for LengthenCommand { matches!(self.state, LenState::PickOption { .. }) } + fn dyn_field(&self) -> crate::command::DynField { + if matches!(self.state, LenState::PickOption { .. }) { + crate::command::DynField::Scalar + } else { + crate::command::DynField::Point + } + } + fn on_text_input(&mut self, text: &str) -> Option { let (handle, pick_pt) = match &self.state { LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt), diff --git a/src/modules/home/modify/offset.rs b/src/modules/home/modify/offset.rs index 60ca32272..d4e2cbc06 100644 --- a/src/modules/home/modify/offset.rs +++ b/src/modules/home/modify/offset.rs @@ -381,6 +381,44 @@ fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option f64 { + let pts = entity_wire_pts(entity); + if pts.len() < 2 { + return 0.0; + } + let px = pt.x as f64; + let py = pt.y as f64; + let mut best = f64::INFINITY; + for w in pts.windows(2) { + let ax = w[0][0] as f64; + let ay = w[0][1] as f64; + let bx = w[1][0] as f64; + let by = w[1][1] as f64; + let dx = bx - ax; + let dy = by - ay; + let len2 = dx * dx + dy * dy; + let t = if len2 < 1e-12 { + 0.0 + } else { + (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0) + }; + let cx = ax + t * dx; + let cy = ay + t * dy; + let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt(); + if d < best { + best = d; + } + } + best +} + // ── Wire preview points ───────────────────────────────────────────────────── fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { @@ -546,29 +584,27 @@ fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - Distance, - SelectObject { - dist: f64, - }, + SelectObject, + // `locked == None` is "through" mode: the offset magnitude follows the + // cursor (perpendicular distance to the picked object). Typing a value + // locks the magnitude; the cursor then only chooses the side. PickSide { - dist: f64, #[allow(dead_code)] handle: Handle, entity: EntityType, + locked: Option, }, } pub struct OffsetCommand { - default_dist: f64, step: Step, all_entities: Vec, } impl OffsetCommand { - pub fn new(last_dist: f32, all_entities: Vec) -> Self { + pub fn new(all_entities: Vec) -> Self { Self { - default_dist: last_dist as f64, - step: Step::Distance, + step: Step::SelectObject, all_entities, } } @@ -581,56 +617,26 @@ impl CadCommand for OffsetCommand { fn prompt(&self) -> String { match &self.step { - Step::Distance => format!( - "OFFSET Specify offset distance <{:.4}>:", - self.default_dist + Step::SelectObject => "OFFSET Select object to offset:".into(), + Step::PickSide { + locked: Some(d), .. + } => format!("OFFSET Click side [distance {d:.4}, type to change]:"), + Step::PickSide { locked: None, .. } => format!( + "OFFSET Click through point, or type a distance (last {:.4}):", + defaults::get_offset_dist() ), - Step::SelectObject { dist } => { - format!("OFFSET Select object to offset [dist={dist:.4}]:") - } - Step::PickSide { dist, .. } => { - format!("OFFSET Specify side to offset [dist={dist:.4}]:") - } } } - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Distance) - } - - fn on_text_input(&mut self, text: &str) -> Option { - if let Step::Distance = self.step { - let dist = if text.trim().is_empty() { - self.default_dist - } else { - text.trim() - .replace(',', ".") - .parse::() - .unwrap_or(self.default_dist) - .abs() - .max(1e-9) - }; - defaults::set_offset_dist(dist as f32); - self.default_dist = dist; - self.step = Step::SelectObject { dist }; - } - None - } - fn needs_entity_pick(&self) -> bool { - matches!(self.step, Step::SelectObject { .. }) + matches!(self.step, Step::SelectObject) } fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult { - if handle.is_null() { + if handle.is_null() || !matches!(self.step, Step::SelectObject) { return CmdResult::NeedPoint; } - let dist = match &self.step { - Step::SelectObject { dist } => *dist, - _ => return CmdResult::NeedPoint, - }; - let entity = self .all_entities .iter() @@ -643,9 +649,9 @@ impl CadCommand for OffsetCommand { | Some(e @ EntityType::Arc(_)) | Some(e @ EntityType::LwPolyline(_)) => { self.step = Step::PickSide { - dist, handle, entity: e, + locked: None, }; CmdResult::NeedPoint } @@ -653,6 +659,44 @@ impl CadCommand for OffsetCommand { } } + // The side step accepts an optional typed magnitude — through mode by + // default, a fixed distance once the user types one. + fn wants_text_input(&self) -> bool { + matches!(self.step, Step::PickSide { .. }) + } + + fn dyn_field(&self) -> crate::command::DynField { + match self.step { + Step::PickSide { .. } => crate::command::DynField::Scalar, + _ => crate::command::DynField::Point, + } + } + + fn dyn_live_value(&self, cursor: Vec3) -> Option { + match &self.step { + Step::PickSide { entity, locked, .. } => { + Some(locked.unwrap_or_else(|| perp_distance(entity, cursor))) + } + _ => None, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + if let Step::PickSide { locked, .. } = &mut self.step { + let t = text.trim().replace(',', "."); + if !t.is_empty() { + if let Ok(d) = t.parse::() { + let d = d.abs().max(1e-9); + defaults::set_offset_dist(d as f32); + *locked = Some(d); + } + } + // Stay on the side step — the click chooses which side. + return Some(CmdResult::NeedPoint); + } + None + } + fn on_hover_entity(&mut self, handle: Handle, _pt: Vec3) -> Vec { if handle.is_null() { return vec![]; @@ -676,24 +720,32 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: Vec3) -> CmdResult { - let (dist, entity) = match &self.step { - Step::PickSide { dist, entity, .. } => (*dist, entity.clone()), + let (locked, entity) = match &self.step { + Step::PickSide { locked, entity, .. } => (*locked, entity.clone()), _ => return CmdResult::NeedPoint, }; + let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt)); + if mag < 1e-9 { + return CmdResult::NeedPoint; + } - match compute_offset(&entity, dist, pt) { + match compute_offset(&entity, mag, pt) { Some(new_entity) => CmdResult::CommitAndExit(new_entity), None => CmdResult::NeedPoint, } } fn on_preview_wires(&mut self, pt: Vec3) -> Vec { - let (dist, entity) = match &self.step { - Step::PickSide { dist, entity, .. } => (*dist, entity.clone()), + let (locked, entity) = match &self.step { + Step::PickSide { locked, entity, .. } => (*locked, entity.clone()), _ => return vec![], }; + let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt)); + if mag < 1e-9 { + return vec![]; + } - if let Some(result) = compute_offset(&entity, dist, pt) { + if let Some(result) = compute_offset(&entity, mag, pt) { let pts = entity_wire_pts(&result); if !pts.is_empty() { return vec![WireModel::solid( diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 3311cc38e..e34776c8c 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -226,7 +226,12 @@ impl CommandLine { matches } - pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> { + pub fn view( + &self, + show_autocomplete: bool, + dyn_capturing: bool, + active_prompt: Option, + ) -> Element<'_, Message> { // Only the most recent entries pushed within the last few // seconds show on the overlay. The dropdown button keeps the // full backlog reachable when the user actually wants it. @@ -247,7 +252,12 @@ impl CommandLine { }; 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 a command is running, the prompt shows what the current + // step is asking for and stays put until that step completes — it + // does not fade like the history lines. With no active command it + // falls back to the generic entry prompt. + let prompt_label = active_prompt.unwrap_or_else(|| "Command:".to_string()); + let prompt = container(text(prompt_label).size(11).color(PROMPT_COLOR)).padding([5, 8]); // While dynamic input is capturing keystrokes, the command-line // text field is left without an `on_input` handler so it can't // grab focus or swallow numeric keys — those flow through the diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index a550feebc..2d98bc91e 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1188,10 +1188,15 @@ pub struct DynBox { pub locked: bool, } -pub fn dynamic_input_overlay<'a>(cursor_screen: Point, boxes: Vec) -> Element<'a, Message> { +pub fn dynamic_input_overlay<'a>( + cursor_screen: Point, + boxes: Vec, + prompt: String, +) -> Element<'a, Message> { canvas(DynInputCanvas { cursor_screen, boxes, + prompt, }) .width(Length::Fill) .height(Length::Fill) @@ -1201,6 +1206,9 @@ pub fn dynamic_input_overlay<'a>(cursor_screen: Point, boxes: Vec) -> El struct DynInputCanvas { cursor_screen: Point, boxes: Vec, + /// The active command's current prompt — tells the user what this step + /// is asking for, drawn just above the input boxes. + prompt: String, } impl canvas::Program for DynInputCanvas { @@ -1258,6 +1266,31 @@ impl canvas::Program for DynInputCanvas { by = (self.cursor_screen.y - BOX_H - 4.0).max(0.0); } + // Prompt line above the boxes — what this step wants right now. + // Drawn on an opaque pill so the viewport behind it stays legible. + if !self.prompt.is_empty() { + let py = (by - BOX_H - 2.0).max(0.0); + let pw = (self.prompt.len() as f32 * CHAR_W) + PAD * 2.0; + let prect = canvas::Path::rectangle( + Point { x: bx, 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: bx + 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() + }); + } + let mut x = bx; for (i, b) in self.boxes.iter().enumerate() { let w = widths[i];