From 9aea9b16c4a339bc30c330beb53d03279a75676b Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:29:48 +0200 Subject: [PATCH] Dynamic Input: the CIRCLE radius prompt shows one distance field, not X/Y (fixes #1135) (#1137) --- crates/engine/src/cmd/draw.rs | 9 ++-- crates/engine/src/cmd/draw2.rs | 2 +- crates/engine/src/cmd/pline_opts.rs | 6 +-- crates/engine/src/prompt.rs | 10 ++++ crates/ui-egui/src/canvas.rs | 20 +++++--- crates/ui-egui/src/grip_dyn.rs | 71 +++++++++++++++++++++++++++-- 6 files changed, 100 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index a9530bd5..8fa3f6d6 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -755,9 +755,12 @@ impl Interactive for CircleM { let last_r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); match (self.mode, n) { (0, 0) => Prompt::new("Specify center point for circle", Accept::POINT).kw(&["3P", "2P", "Ttr (tan tan radius)"]), - (0, _) if self.diameter => Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()), + (0, _) if self.diameter => { + Prompt::new("Specify diameter of circle", Accept::POINT_OR_NUMBER).base_opt(self.pts.first().copied()).distance() + } (0, _) => { - let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()); + let p = + Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).kw(&["Diameter"]).base_opt(self.pts.first().copied()).distance(); if last_r > 0.0 { p.default(format!("{last_r:.4}")) } else { p } } (1, k) => Prompt::new( @@ -890,7 +893,7 @@ impl Interactive for PolygonM { (Some(_), false, Some(_), None) => Prompt::new("Enter an option", Accept::TEXT) .kw(&["Inscribed in circle", "Circumscribed about circle"]) .default(if s.last_used.polygon_circumscribed { "C" } else { "I" }), - (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c), + (Some(_), false, Some(c), Some(_)) => Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base(c).distance(), } } fn input(&mut self, s: &mut Session, i: Input) -> Result { diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index 75a25e52..da188680 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -578,7 +578,7 @@ impl Interactive for TanCircleM { 1 => Prompt::new("Specify point on object for second tangent of circle", Accept::POINT), _ => { let r = s.doc().map(|d| d.header.f64("CIRCLERAD", 0.0)).unwrap_or(0.0); - let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)); + let p = Prompt::new("Specify radius of circle", Accept::POINT_OR_NUMBER).base_opt(self.picks.last().map(|x| x.1)).distance(); if r > 0.0 { p.default(format!("{r:.4}")) } else { p } } } diff --git a/crates/engine/src/cmd/pline_opts.rs b/crates/engine/src/cmd/pline_opts.rs index b96cf33c..195a66c9 100644 --- a/crates/engine/src/cmd/pline_opts.rs +++ b/crates/engine/src/cmd/pline_opts.rs @@ -90,19 +90,19 @@ impl PlineAsk { PlineAsk::Angle | PlineAsk::CenterAngle(_) | PlineAsk::RadiusAngle(_) => Prompt::new("Specify included angle", Accept::NUMBER), PlineAsk::AngleEnd(_) => end(&["CEnter", "Radius"]), PlineAsk::AngleCenter(_) | PlineAsk::Center => Prompt::new("Specify center point of arc", Accept::POINT).base(st), - PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::AngleRadius(_) | PlineAsk::Radius => Prompt::new("Specify radius of arc", Accept::POINT_OR_NUMBER).base(st).distance(), PlineAsk::Chord(a, _) => { let d = shown_direction(s, chord_default(tangent, a)); Prompt::new("Specify direction of chord for arc", Accept::POINT_OR_NUMBER).base(st).default(d) } PlineAsk::CenterEnd(_) => end(&["Angle", "Length"]), - PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::CenterLength(_) => Prompt::new("Specify length of chord", Accept::POINT_OR_NUMBER).base(st).distance(), PlineAsk::Direction => Prompt::new("Specify the tangent direction for the start point of arc", Accept::POINT_OR_NUMBER).base(st), PlineAsk::DirectionEnd(_) => Prompt::new("Specify endpoint of the arc", Accept::POINT).base(st), PlineAsk::RadiusEnd(_) => end(&["Angle"]), PlineAsk::Second => Prompt::new("Specify second point on arc", Accept::POINT).base(st), PlineAsk::SecondEnd(_) => Prompt::new("Specify end point of arc", Accept::POINT).base(st), - PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st), + PlineAsk::Length => Prompt::new("Specify length of line", Accept::POINT_OR_NUMBER).base(st).distance(), } } diff --git a/crates/engine/src/prompt.rs b/crates/engine/src/prompt.rs index b5de8328..0af4f069 100644 --- a/crates/engine/src/prompt.rs +++ b/crates/engine/src/prompt.rs @@ -57,6 +57,11 @@ pub struct Prompt { /// prompt (the routine writes all of it). #[serde(skip_serializing_if = "std::ops::Not::not")] pub raw: bool, + /// The prompt asks for a distance from `base` (a radius, a diameter, a length), and a point + /// answers with its distance from it: Dynamic Input shows one distance field instead of the + /// pointer boxes. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub distance: bool, } /// Parse `@distance Self { + self.distance = true; + self + } /// Accept deferred tangent/perpendicular snaps (the `deferred` field). pub fn deferred(mut self) -> Self { self.deferred = true; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2773a230..8602b5e9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1854,9 +1854,11 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if app.session.settings.dynmode && let Some(hp) = hover_pos { - // A stretched grip shows its dimensions instead of the pointer boxes. - if !crate::grip_dyn::draw(app, &painter) { - dynamic_input(app, &painter, hp); + // A stretched grip shows its dimensions instead of the pointer boxes; a prompt asking + // for a distance shows its one field and keeps the prompt and its options. + let fields = crate::grip_dyn::draw(app, &painter); + if !fields || app.session.current_prompt().is_some_and(|p| p.distance) { + dynamic_input(app, &painter, hp, fields); } } } @@ -2022,8 +2024,9 @@ fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox /// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt /// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]); -/// otherwise the prompt with what is being typed, or the live distance and angle. -fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2) { +/// otherwise the prompt with what is being typed, or the live distance and angle. With `fields` +/// (a distance prompt's field is on show, [`crate::grip_dyn`]) only the prompt and its options. +fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2, fields: bool) { app.cmd.option_rows.clear(); // While the options menu is open the tooltip stays where it opened, so the menu can be reached. let at = match app.cmd.options { @@ -2037,7 +2040,8 @@ fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2) { // DYNPROMPT = 0: no prompt beside the cursor (the value boxes stay). let show_prompt = app.session.settings.dynprompt; let options = show_prompt && !prompt.keywords.is_empty(); - if let Some(f) = crate::cmdline::dyn_frame(app) + if !fields + && let Some(f) = crate::cmdline::dyn_frame(app) && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) && let Some(c) = app.canvas.cursor { @@ -2050,7 +2054,9 @@ fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2) { return; } let mut text = crate::i18n::t(&prompt.message).to_string(); - if !app.cmd.buffer.is_empty() { + if fields { + // The field shows the distance and what is typed. + } else if !app.cmd.buffer.is_empty() { text = format!("{}: {}", crate::i18n::t(&prompt.message), app.cmd.buffer); } else if let (Some(base), Some(c)) = (prompt.base, app.canvas.cursor) { text = format!( diff --git a/crates/ui-egui/src/grip_dyn.rs b/crates/ui-egui/src/grip_dyn.rs index e6e295a6..35714b5c 100644 --- a/crates/ui-egui/src/grip_dyn.rs +++ b/crates/ui-egui/src/grip_dyn.rs @@ -5,6 +5,10 @@ //! //! The command-line text is the active field's text, so Backspace and Escape behave as usual; //! text that isn't a value (a keyword such as `C` for Copy) goes to the command as typed. +//! +//! A prompt that asks for a distance from its base ([`cadcraft_engine::Prompt::distance`]: a +//! circle's radius, a polyline arc's) shows the same way, as one length field from the base, and +//! Enter sends the typed value to the command as a number. use cadcraft_doc::Handle; use cadcraft_engine::Input; @@ -20,22 +24,31 @@ use crate::theme::Tokens; pub struct GripDyn { /// The grip the fields belong to; another grip starts over. grip: Option<(Handle, usize)>, + /// The base of the distance prompt the field belongs to; another one starts over. + base: Option, /// The active field. pub active: usize, /// Locked fields: (field, value as typed). pub locked: Vec<(usize, String)>, } +/// The base of the prompt on show when it asks for a distance from it (a circle's radius, a +/// polyline arc's): its one field is that distance. +fn distance_base(app: &CadApp) -> Option { + crate::cmdline::dyn_frame(app).and_then(|_| app.session.current_prompt()).filter(|p| p.distance).and_then(|p| p.base) +} + /// The dimensions on show, after bringing the fields' state up to date with the prompt (empty when -/// no grip is stretched or the value boxes aren't live). +/// no grip is stretched, no distance is asked for, or the value boxes aren't live). pub fn dims(app: &mut CadApp) -> Vec { let grip = crate::cmdline::dyn_frame(app).and_then(|_| app.session.current_prompt()).and_then(|p| p.grip); + let base = distance_base(app); let st = &mut app.canvas.grip_dyn; - if st.grip != grip { - *st = GripDyn { grip, ..GripDyn::default() }; + if st.grip != grip || st.base != base { + *st = GripDyn { grip, base, ..GripDyn::default() }; } if grip.is_none() { - return Vec::new(); + return base.map(|b| vec![GripDim { kind: GripDimKind::Length, anchor: b, original: 0.0 }]).unwrap_or_default(); } let dims = grip_dims(&app.session); if app.canvas.grip_dyn.active >= dims.len() { @@ -97,6 +110,17 @@ pub fn submit(app: &mut CadApp) -> bool { if !typed.is_empty() && dims.get(app.canvas.grip_dyn.active).and_then(|d| d.parse(&app.session, typed)).is_none() { return false; } + // A distance prompt takes the value as typed (or as Tab locked it), so the command reads the + // number itself: a negative arc radius, a radius of zero refused. + if distance_base(app).is_some() { + if typed.is_empty() + && let Some((_, l)) = app.canvas.grip_dyn.locked.first() + { + app.cmd.buffer = l.clone(); + } + app.canvas.grip_dyn = GripDyn::default(); + return false; + } let vals = values(app, &dims); if vals.is_empty() { return false; @@ -278,6 +302,45 @@ mod tests { assert_eq!(lines, 2, "Copy mode stretched a copy"); } + /// CIRCLE's radius prompt shows one distance field from the centre, not the pointer boxes + /// (X/Y, or distance and angle), and a typed value is the radius (#1135). + #[test] + fn circle_radius_shows_one_distance_field_and_typed_value_is_the_radius() { + for cartesian in [true, false] { + let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default()); + app.session.settings.dynpi_cartesian = cartesian; + // The UCS icon paints "X" and "Y" too. + app.ui.show_ucs_icon = false; + let ctx = egui::Context::default(); + crate::canvas::tests::painted_text(&mut app, &ctx, pos2(700.0, 450.0)); + let at = app.canvas.xf.unwrap().to_screen(Vec2::ZERO) + vec2(130.0, -70.0); + let labels = |text: &str| text.lines().filter(|l| matches!(*l, "X" | "Y" | "<")).map(str::to_owned).collect::>(); + + app.session.cmdline("circle").unwrap(); + crate::canvas::tests::painted_text(&mut app, &ctx, at); + let text = crate::canvas::tests::painted_text(&mut app, &ctx, at); + assert_eq!(labels(&text), ["X", "Y"], "the centre is a point:\n{text}"); + + app.session.input(Input::Point(Vec2::ZERO)).unwrap(); + crate::canvas::tests::painted_text(&mut app, &ctx, at); + let text = crate::canvas::tests::painted_text(&mut app, &ctx, at); + assert!(labels(&text).is_empty(), "pointer boxes at the radius prompt (DYNPIFORMAT {cartesian}):\n{text}"); + assert_eq!(dims(&mut app).len(), 1, "one distance field"); + let r = cadcraft_engine::units::format_distance(app.canvas.cursor.unwrap().len(), 2, 4); + assert!(text.contains(&r), "no radius {r} in:\n{text}"); + assert!(text.contains("Specify radius of circle"), "{text}"); + + run_frame(&mut app, &ctx, vec![Event::Text("25".into())]); + run_frame(&mut app, &ctx, key(egui::Key::Enter)); + assert!(app.session.running.is_none()); + let radius = app.session.doc().unwrap().model.iter().find_map(|e| match &e.kind { + EntityKind::Circle(ci) => Some(ci.radius), + _ => None, + }); + assert_eq!(radius, Some(25.0)); + } + } + /// The grip's length fields read the drawing's length units (LUNITS/LUPREC) (#841). #[test] fn length_fields_follow_the_drawing_units() {