diff --git a/src/app/document.rs b/src/app/document.rs index 322b72c31..13b6bda16 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -41,6 +41,9 @@ pub(super) enum DynComponent { #[derive(Clone, Debug)] pub(super) struct DynFieldEntry { pub(super) component: DynComponent, + /// Semantic role — drives the label and value scaling (e.g. diameter). + /// Defaults to the role matching `component` on the legacy path. + pub(super) role: crate::command::DynRole, pub(super) buffer: Option, } @@ -48,6 +51,16 @@ impl DynFieldEntry { pub(super) fn new(component: DynComponent) -> Self { Self { component, + role: default_role_for(component), + buffer: None, + } + } + /// Build from an explicit role (spec-driven path); the resolution + /// component is derived from the role. + pub(super) fn from_role(role: crate::command::DynRole) -> Self { + Self { + component: component_for_role(role), + role, buffer: None, } } @@ -56,6 +69,32 @@ impl DynFieldEntry { } } +/// Map a [`DynRole`](crate::command::DynRole) to the ordinate/distance/angle +/// component used by point resolution. +pub(super) fn component_for_role(role: crate::command::DynRole) -> DynComponent { + use crate::command::DynRole; + match role { + DynRole::X | DynRole::Width => DynComponent::X, + DynRole::Y | DynRole::Height => DynComponent::Y, + DynRole::Z => DynComponent::Z, + DynRole::Distance | DynRole::Radius | DynRole::Diameter => DynComponent::Distance, + DynRole::Angle => DynComponent::Angle, + DynRole::Factor | DynRole::Count => DynComponent::Scalar, + } +} + +fn default_role_for(component: DynComponent) -> crate::command::DynRole { + use crate::command::DynRole; + match component { + DynComponent::X => DynRole::X, + DynComponent::Y => DynRole::Y, + DynComponent::Z => DynRole::Z, + DynComponent::Distance => DynRole::Distance, + DynComponent::Angle => DynRole::Angle, + DynComponent::Scalar => DynRole::Factor, + } +} + // ── Per-document tab state ───────────────────────────────────────────────── pub(super) struct DocumentTab { @@ -78,11 +117,21 @@ pub(super) struct DocumentTab { pub(super) visual_style: String, pub(super) last_cursor_world: glam::Vec3, pub(super) last_cursor_screen: iced::Point, + /// Base point (`App::last_point`) projected to viewport pixels, refreshed + /// on cursor move. Lets the dynamic-input overlay place the distance label + /// along the rubber-band line and the angle label at its end. + pub(super) last_point_screen: Option, /// Dynamic-input fields shown near the cursor while a command waits /// for a point/distance/angle. Rebuilt whenever the active command's /// `dyn_field()` or the presence of a base point changes. Empty when /// dynamic input is not active. pub(super) dyn_fields: Vec, + /// Guide geometry the overlay draws for the current step (set alongside + /// `dyn_fields`). Polar arc, radius line, axis-delta projections, etc. + pub(super) dyn_guide: crate::command::DynGuide, + /// World-space anchor the current step's values are measured from. `None` + /// falls back to `App::last_point`. + pub(super) dyn_anchor: Option, /// Index of the field that TAB has focused (the one keystrokes edit). pub(super) dyn_active: usize, pub(super) history: HistoryState, @@ -142,7 +191,10 @@ impl DocumentTab { visual_style: "Wireframe 2D".into(), last_cursor_world: glam::Vec3::ZERO, last_cursor_screen: iced::Point::ORIGIN, + last_point_screen: None, dyn_fields: Vec::new(), + dyn_guide: crate::command::DynGuide::Polar, + dyn_anchor: None, dyn_active: 0, history: HistoryState::default(), active_layer: "0".to_string(), diff --git a/src/app/update.rs b/src/app/update.rs index bee9796c0..b20d647d3 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2542,6 +2542,17 @@ impl OpenCADStudio { }; self.tabs[i].last_cursor_world = effective; self.tabs[i].last_cursor_screen = p_full; + // Project the step anchor (an explicit `dyn_anchor` or the + // last point) so the dynamic-input overlay can place its + // guide geometry and labels. + let anchor = self.tabs[i].dyn_anchor.or(self.last_point); + self.tabs[i].last_point_screen = anchor.map(|bp| { + let ndc = view_proj.project_point3(bp); + iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ) + }); // Entity-pick previews (TRIM/EXTEND/FILLET…) compare the // cursor against WCS document entities and return WCS wires. @@ -7464,6 +7475,13 @@ impl OpenCADStudio { self.tabs[i].dyn_active = 0; return; } + // A command may describe its step explicitly via `dyn_spec()` — that + // takes full control of the boxes, guide and anchor. Otherwise fall + // back to the legacy `dyn_field()` shaping below. + if let Some(spec) = self.tabs[i].active_cmd.as_ref().and_then(|c| c.dyn_spec()) { + self.apply_dyn_spec(i, spec); + return; + } let field = self.tabs[i] .active_cmd .as_ref() @@ -7480,6 +7498,15 @@ impl OpenCADStudio { .as_ref() .map(|c| c.wants_text_input()) .unwrap_or(false); + // A point step that also accepts keyword letters (PLINE A/L/C…) keeps + // its polar boxes: only letters reach the command line, digits stay + // coordinates. So such a step is NOT treated as a text-only prompt. + let point_keywords = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.point_step_accepts_keywords()) + .unwrap_or(false); + let wants_text = wants_text && !point_keywords; // 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 @@ -7551,6 +7578,39 @@ impl OpenCADStudio { self.tabs[i].dyn_fields = default.into_iter().map(DynFieldEntry::new).collect(); self.tabs[i].dyn_active = 0; } + // Derive the guide + anchor for the legacy field set so the overlay + // draws the right construction without each command opting in. + let comps: Vec = + self.tabs[i].dyn_fields.iter().map(|f| f.component).collect(); + self.tabs[i].dyn_guide = match comps.as_slice() { + [DynComponent::Distance, DynComponent::Angle] | [DynComponent::Angle] => { + crate::command::DynGuide::Polar + } + [DynComponent::Distance] => crate::command::DynGuide::Radius, + _ => crate::command::DynGuide::None, + }; + self.tabs[i].dyn_anchor = self.last_point; + } + + /// Apply an explicit per-step [`DynSpec`](crate::command::DynSpec): rebuild + /// the boxes from its roles (preserving typed buffers when the role set is + /// unchanged), and set the guide + anchor. + fn apply_dyn_spec(&mut self, i: usize, spec: crate::command::DynSpec) { + use super::document::DynFieldEntry; + let new_roles: Vec = + spec.fields.iter().map(|f| f.role).collect(); + let cur_roles: Vec = + self.tabs[i].dyn_fields.iter().map(|f| f.role).collect(); + if cur_roles != new_roles { + self.tabs[i].dyn_fields = + spec.fields.iter().map(|f| DynFieldEntry::from_role(f.role)).collect(); + self.tabs[i].dyn_active = 0; + } + self.tabs[i].dyn_guide = spec.guide; + self.tabs[i].dyn_anchor = match spec.anchor { + crate::command::DynAnchor::LastPoint => self.last_point, + crate::command::DynAnchor::Point(p) => Some(p), + }; } /// Track cursor dwell over a selected entity's grip. Sets @@ -7718,20 +7778,39 @@ impl OpenCADStudio { return None; } let w = self.tabs[i].last_cursor_world; - let base = self.last_point.unwrap_or(glam::Vec3::ZERO); - // Buffer value parsed as f32, or the supplied live value. + let base = self.tabs[i] + .dyn_anchor + .or(self.last_point) + .unwrap_or(glam::Vec3::ZERO); + // Buffer value parsed as f32 (de-scaled by the role so a typed diameter + // becomes a radius), or the supplied geometric live value. let val = |idx: usize, live: f32| -> f32 { fields[idx] .buffer .as_ref() .map(|s| s.trim().replace(',', ".")) .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)) + .map(|v| v / fields[idx].role.value_scale()) .unwrap_or(live) }; let dx = w.x - base.x; let dy = w.y - base.y; let live_d = (dx * dx + dy * dy).sqrt(); let live_a = dy.atan2(dx); // radians + // A typed angle is shown unsigned (0..180); give it the sign of the + // cursor's current side so an entry made below the X axis sweeps + // downward to match the arc instead of mirroring up. Untyped → live. + let angle_rad = |idx: usize| -> f32 { + match fields[idx] + .buffer + .as_ref() + .map(|s| s.trim().replace(',', ".")) + .and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32)) + { + Some(mag) => mag.abs().to_radians().copysign(dy), + None => live_a, + } + }; let comps: Vec = fields.iter().map(|f| f.component).collect(); // DYN-on defaults to RELATIVE coordinates when a base point is set // (see #26 / #35). The live cartesian fallback is the cursor @@ -7758,7 +7837,7 @@ impl OpenCADStudio { } [DynComponent::Distance, DynComponent::Angle] => { let d = val(0, live_d); - let a = val(1, live_a.to_degrees()).to_radians(); + let a = angle_rad(1); Some(glam::Vec3::new( base.x + d * a.cos(), base.y + d * a.sin(), @@ -7771,7 +7850,10 @@ impl OpenCADStudio { Some(base + dir * val(0, live_d)) } [DynComponent::Angle] => { - // Keep the cursor's distance, override the angle. + // Standalone angle (e.g. ROTATE): the typed value is an + // absolute CCW angle, not a cursor-signed magnitude — keep it + // literal. Only the polar Distance+Angle pair uses the + // cursor-signed `angle_rad`. let a = val(0, live_a.to_degrees()).to_radians(); Some(glam::Vec3::new( base.x + live_d * a.cos(), @@ -7887,10 +7969,12 @@ impl OpenCADStudio { // `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. + // A point-with-keywords step (PLINE) commits a typed distance/angle as + // a point, not as text, so it is excluded here. let wants_text = self.tabs[i] .active_cmd .as_ref() - .map(|c| c.wants_text_input()) + .map(|c| c.wants_text_input() && !c.point_step_accepts_keywords()) .unwrap_or(false); if wants_text { let text = self.tabs[i] diff --git a/src/app/view.rs b/src/app/view.rs index 879568995..3aaf566e6 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -718,6 +718,17 @@ impl OpenCADStudio { // 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)); + // A Distance+Angle set is polar point entry; its angle box is + // shown unsigned to match the overlay's short arc. A standalone + // angle (ROTATE) keeps absolute CCW. + let polar_pair = tab + .dyn_fields + .iter() + .any(|f| f.component == DynComponent::Distance) + && tab + .dyn_fields + .iter() + .any(|f| f.component == DynComponent::Angle); let boxes: Vec = tab .dyn_fields .iter() @@ -733,13 +744,14 @@ impl OpenCADStudio { { format!("{lv:.4}") } - _ => dyn_component_value(f, w, base), + _ => dyn_component_value(f, w, base, polar_pair), }; overlay::DynBox { - label: dyn_component_label(f.component), + label: f.role.label().to_string(), value, active: idx == tab.dyn_active, locked: f.locked(), + role: f.role, } }) .collect(); @@ -750,6 +762,8 @@ impl OpenCADStudio { .unwrap_or_default(); Some(overlay::dynamic_input_overlay( tab.last_cursor_screen, + tab.last_point_screen, + tab.dyn_guide, boxes, prompt, )) @@ -4050,21 +4064,15 @@ fn render_mode_picker<'a>(current: acadrust::entities::ViewportRenderMode) -> El // ── Dynamic-input field formatting ───────────────────────────────────────── /// Short prefix shown before a dynamic-input box's value. -fn dyn_component_label(c: DynComponent) -> String { - match c { - DynComponent::X => "X".into(), - DynComponent::Y => "Y".into(), - DynComponent::Z => "Z".into(), - DynComponent::Distance => "d".into(), - DynComponent::Angle => "<".into(), - DynComponent::Scalar => "".into(), - } -} - /// The string shown inside a dynamic-input box: the typed buffer when the /// field is locked, otherwise the live value derived from the cursor /// world position (and the base point for polar quantities). -fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option) -> String { +fn dyn_component_value( + f: &DynFieldEntry, + w: glam::Vec3, + base: Option, + polar_pair: bool, +) -> String { if let Some(b) = &f.buffer { return b.clone(); } @@ -4083,7 +4091,14 @@ fn dyn_component_value(f: &DynFieldEntry, w: glam::Vec3, base: Option format!("{:.4}", w.x), DynComponent::Y => format!("{:.4}", w.y), DynComponent::Z => format!("{:.4}", b.z), - DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()), + // Scaled by the role so a diameter box reads twice the radius. + DynComponent::Distance => { + format!("{:.4}", (dx * dx + dy * dy).sqrt() * f.role.value_scale() as f64) + } + // Polar point entry (Distance+Angle): unsigned [0,180] to match the + // short arc — above or below the X axis both read e.g. 30°. + DynComponent::Angle if polar_pair => format!("{:.1}", dy.atan2(dx).to_degrees().abs()), + // Standalone angle (ROTATE): absolute CCW 0..360, as before. 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 13f36002a..cd468f095 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -248,6 +248,112 @@ pub enum DynField { Scalar, } +// ── Per-step dynamic-input specification ─────────────────────────────────── +// +// `DynField` only says "this step wants a point / distance / angle". `DynSpec` +// lets a command describe its step precisely: which value boxes to show (with +// roles + labels), what guide geometry to draw, and where it is measured from. +// A command returns `Some(DynSpec)` from `dyn_spec()` to take explicit control; +// returning `None` (the default) keeps the legacy `dyn_field()` behaviour. + +/// Semantic role of a dynamic-input box. Resolution maps each role to a base +/// ordinate/distance/angle; the role additionally drives the label and any +/// value scaling (e.g. a diameter shows/accepts twice the geometric radius). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DynRole { + X, + Y, + Z, + /// Linear distance from the anchor. + Distance, + /// Angle from the anchor, degrees. + Angle, + /// Distance shown labelled `R` (circle/arc radius). + Radius, + /// Distance shown labelled `⌀`; displayed/typed value is twice the radius. + Diameter, + /// Cartesian X-delta shown labelled `W` (rectangle width). + Width, + /// Cartesian Y-delta shown labelled `H` (rectangle height). + Height, + /// Typed-only scale factor. + Factor, + /// Typed-only integer count. + Count, +} + +impl DynRole { + /// Default label shown before the value (empty = value only). + pub fn label(self) -> &'static str { + match self { + DynRole::X => "X", + DynRole::Y => "Y", + DynRole::Z => "Z", + DynRole::Distance | DynRole::Angle | DynRole::Factor => "", + DynRole::Radius => "R", + DynRole::Diameter => "\u{2300}", + DynRole::Width => "W", + DynRole::Height => "H", + DynRole::Count => "#", + } + } + + /// Multiplier between the geometric value and the displayed/typed value. + /// A diameter box shows and accepts twice the underlying radius. + pub fn value_scale(self) -> f32 { + match self { + DynRole::Diameter => 2.0, + _ => 1.0, + } + } +} + +/// Guide geometry the overlay draws for a step, anchored at the step's base. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum DynGuide { + /// No guide lines. + None, + /// +X reference line and the angle arc (polar point entry). + Polar, + /// Dotted projections from the cursor down to the anchor's X and Y axes. + AxisDelta, + /// A line from the anchor to the cursor (radius / single distance). + Radius, + /// The two rectangle sides (width × height) from the anchor corner. + RectSides, +} + +/// Where a step's values are measured from. +#[derive(Clone, Copy, PartialEq, Debug)] +pub enum DynAnchor { + /// The previous committed point (`App::last_point`). + LastPoint, + /// An explicit world point. + Point(Vec3), +} + +/// One value box in a [`DynSpec`]. +#[derive(Clone, Debug)] +pub struct DynFieldSpec { + pub role: DynRole, + /// Label override; `None` uses the role's default label. + pub label: Option<&'static str>, +} + +impl DynFieldSpec { + pub fn new(role: DynRole) -> Self { + Self { role, label: None } + } +} + +/// A full per-step dynamic-input description. +#[derive(Clone, Debug)] +pub struct DynSpec { + pub anchor: DynAnchor, + pub fields: Vec, + pub guide: DynGuide, +} + // ── Trait ───────────────────────────────────────────────────────────────── /// An interactive CAD command that collects user input step-by-step. @@ -348,6 +454,16 @@ pub trait CadCommand: Send { false } + /// Returns `true` when the current step is a point pick that *also* accepts + /// optional keyword letters (e.g. PLINE's A/L/C/U). Such a step keeps the + /// polar dynamic-input boxes: typed digits become coordinates while letters + /// still reach the command line as keywords. Without this, a command that + /// returns `wants_text_input() == true` for its keywords would suppress the + /// dynamic-input distance/angle entirely. Default `false`. + fn point_step_accepts_keywords(&self) -> bool { + false + } + /// Returns `true` when the active text prompt expects free-form prose /// that can legitimately contain whitespace (the body of a TEXT / /// MTEXT / DDEDIT entity, an attribute default value, etc.). For @@ -438,6 +554,14 @@ pub trait CadCommand: Send { DynField::Point } + /// Explicit per-step dynamic-input description. `Some(spec)` takes full + /// control of the boxes, guide geometry and anchor for this step; `None` + /// (the default) falls back to the legacy `dyn_field()` behaviour so + /// commands that haven't migrated keep working unchanged. + fn dyn_spec(&self) -> Option { + None + } + /// 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 diff --git a/src/modules/home/draw/circle.rs b/src/modules/home/draw/circle.rs index bdb604f26..18a7e9c93 100644 --- a/src/modules/home/draw/circle.rs +++ b/src/modules/home/draw/circle.rs @@ -189,6 +189,21 @@ impl CadCommand for CircleCommand { StepCR::Radius(_) => DynField::Distance, } } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match self.step { + // Center is a normal first-point pick. + StepCR::Center => None, + // Radius: a single R value with one dotted line from the centre to + // the cursor (no angle, no axis legs). + StepCR::Radius(c) => Some(DynSpec { + anchor: DynAnchor::Point(c), + fields: vec![DynFieldSpec::new(DynRole::Radius)], + guide: DynGuide::Radius, + }), + } + } } // ── Command: Center, Diameter ────────────────────────────────────────────── @@ -262,6 +277,20 @@ impl CadCommand for CircleCDCommand { } None } + + fn dyn_spec(&self) -> Option { + use crate::command::{DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec}; + match self.step { + StepCR::Center => None, + // Diameter: the box shows/accepts twice the cursor radius; resolved + // back to a radius point by the host (role scaling). + StepCR::Radius(c) => Some(DynSpec { + anchor: DynAnchor::Point(c), + fields: vec![DynFieldSpec::new(DynRole::Diameter)], + guide: DynGuide::Radius, + }), + } + } } // ── Command: 2-Point ────────────────────────────────────────────────────── diff --git a/src/modules/home/draw/mline.rs b/src/modules/home/draw/mline.rs index 545a03726..e9a31b705 100644 --- a/src/modules/home/draw/mline.rs +++ b/src/modules/home/draw/mline.rs @@ -63,6 +63,13 @@ impl CadCommand for MlineCommand { self.waiting_scale || !self.points.is_empty() } + fn point_step_accepts_keywords(&self) -> bool { + // The vertex steps accept J / S keywords but are point picks, so keep + // polar dynamic input. The scale prompt (`waiting_scale`) is genuine + // text entry and is excluded. + !self.waiting_scale && !self.points.is_empty() + } + fn on_text_input(&mut self, text: &str) -> Option { // Waiting for scale value if self.waiting_scale { diff --git a/src/modules/home/draw/polyline.rs b/src/modules/home/draw/polyline.rs index 69e2a467b..2b3d9d1bc 100644 --- a/src/modules/home/draw/polyline.rs +++ b/src/modules/home/draw/polyline.rs @@ -253,6 +253,12 @@ impl CadCommand for PlineCommand { !self.vertices.is_empty() } + fn point_step_accepts_keywords(&self) -> bool { + // Each segment is a point pick that also accepts A / L / C / U, so the + // polar dynamic-input distance/angle stays visible. + !self.vertices.is_empty() + } + fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { "A" | "ARC" => { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 0fbe3676e..b54cc8709 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1191,12 +1191,18 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) { // ── Dynamic Input overlay ───────────────────────────────────────────────── -/// Draw a small coordinate / distance-angle tooltip near the cursor. -/// -/// `cursor_screen` — cursor position in viewport pixels. -/// `label` — text to display (e.g. "X: 12.34 Y: 56.78"). -/// One labelled box in the dynamic-input overlay (e.g. `d` = distance, -/// `<` = angle, `X` / `Y` = ordinates). +use crate::command::{DynGuide, DynRole}; + +const DYN_OFFSET_X: f32 = 14.0; +const DYN_OFFSET_Y: f32 = 20.0; +const DYN_PAD: f32 = 4.0; +const DYN_GAP: f32 = 6.0; +const DYN_FONT: f32 = 11.0; +const DYN_CHAR_W: f32 = DYN_FONT * 0.62; +const DYN_BOX_H: f32 = DYN_FONT + DYN_PAD * 2.0; + +/// One value box in the dynamic-input overlay. Its `role` drives both the +/// label and where the box is placed relative to the step's guide geometry. #[derive(Clone)] pub struct DynBox { pub label: String, @@ -1205,15 +1211,20 @@ pub struct DynBox { pub active: bool, /// User has typed a value (the box no longer tracks the cursor). pub locked: bool, + pub role: DynRole, } pub fn dynamic_input_overlay<'a>( cursor_screen: Point, + base_screen: Option, + guide: DynGuide, boxes: Vec, prompt: String, ) -> Element<'a, Message> { canvas(DynInputCanvas { cursor_screen, + base_screen, + guide, boxes, prompt, }) @@ -1224,12 +1235,258 @@ pub fn dynamic_input_overlay<'a>( struct DynInputCanvas { cursor_screen: Point, + /// Step anchor in viewport pixels (projected `dyn_anchor`). Guided layouts + /// (polar / radius / axis-delta) need it; `None` falls back to a cursor row. + base_screen: Option, + guide: DynGuide, boxes: Vec, - /// The active command's current prompt — tells the user what this step - /// is asking for, drawn just above the input boxes. + /// The active command's current prompt, drawn just above the boxes. prompt: String, } +impl DynInputCanvas { + fn dotted() -> canvas::Stroke<'static> { + canvas::Stroke { + width: 1.0, + style: canvas::Style::Solid(Color { r: 0.55, g: 0.55, b: 0.58, a: 0.9 }), + line_dash: canvas::LineDash { segments: &[2.0, 3.0], offset: 0 }, + ..Default::default() + } + } + + fn box_content(b: &DynBox) -> String { + match b.role { + DynRole::Angle => format!("{}\u{00B0}", b.value), + _ if b.label.is_empty() => b.value.clone(), + _ => format!("{}{}", b.label, b.value), + } + } + + /// Draw a value box centred at `center`, clamped inside `bounds`. + fn draw_box(frame: &mut canvas::Frame, b: &DynBox, center: Point, bounds: iced::Rectangle) { + let content = Self::box_content(b); + let w = (content.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; + let x = (center.x - w * 0.5).clamp(0.0, (bounds.width - w).max(0.0)); + let y = (center.y - DYN_BOX_H * 0.5).clamp(0.0, (bounds.height - DYN_BOX_H).max(0.0)); + let rect = canvas::Path::rectangle(Point { x, y }, Size { width: w, height: DYN_BOX_H }); + let (fill, border) = Self::box_colors(b); + frame.fill(&rect, fill); + frame.stroke( + &rect, + canvas::Stroke::default() + .with_color(border) + .with_width(if b.active { 1.6 } else { 1.0 }), + ); + frame.fill_text(canvas::Text { + content, + position: Point { x: x + DYN_PAD, y: y + DYN_PAD }, + color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, + size: iced::Pixels(DYN_FONT), + ..Default::default() + }); + } + + fn box_colors(b: &DynBox) -> (Color, Color) { + if b.active { + ( + Color { r: 0.12, g: 0.18, b: 0.30, a: 0.95 }, + Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 }, + ) + } else if b.locked { + ( + Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, + Color { r: 0.95, g: 0.75, b: 0.30, a: 0.9 }, + ) + } else { + ( + Color { r: 0.05, g: 0.05, b: 0.12, a: 0.9 }, + Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 }, + ) + } + } + + /// Prompt pill at `pos`. + fn draw_prompt(&self, frame: &mut canvas::Frame, pos: Point) { + if self.prompt.is_empty() { + return; + } + let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; + let rect = canvas::Path::rectangle(pos, Size { width: pw, height: DYN_BOX_H }); + frame.fill(&rect, Color { r: 0.10, g: 0.10, b: 0.12, a: 1.0 }); + frame.stroke( + &rect, + 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: pos.x + DYN_PAD, y: pos.y + DYN_PAD }, + color: Color { r: 0.70, g: 0.85, b: 0.70, a: 1.0 }, + size: iced::Pixels(DYN_FONT), + ..Default::default() + }); + } + + /// Guided layout: draw the guide geometry anchored at `base`, then place + /// each box according to its role. + fn draw_guided(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle, base: Point) { + let cursor = self.cursor_screen; + let (vx, vy) = (cursor.x - base.x, cursor.y - base.y); + let len = (vx * vx + vy * vy).sqrt().max(1.0); + let (dx, dy) = (vx / len, vy / len); + // Perpendicular pointing to the lower half so labels sit under the line. + let (mut nx, mut ny) = (-dy, dx); + if ny < 0.0 { + nx = -nx; + ny = -ny; + } + // Short-way screen sweep from +X to the line, for the angle arc. + let mut sweep = dy.atan2(dx); + while sweep > std::f32::consts::PI { + sweep -= std::f32::consts::TAU; + } + while sweep < -std::f32::consts::PI { + sweep += std::f32::consts::TAU; + } + let corner = Point { x: cursor.x, y: base.y }; // axis-delta elbow + + // ── Guide geometry ── + match self.guide { + DynGuide::Polar => { + let href = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(Point { x: base.x + len, y: base.y }); + }); + frame.stroke(&href, Self::dotted()); + let arc = canvas::Path::new(|p| { + let steps = 48; + for k in 0..=steps { + let a = sweep * (k as f32 / steps as f32); + let pt = Point { + x: base.x + a.cos() * len, + y: base.y + a.sin() * len, + }; + if k == 0 { + p.move_to(pt); + } else { + p.line_to(pt); + } + } + }); + frame.stroke(&arc, Self::dotted()); + } + DynGuide::Radius => { + let line = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(cursor); + }); + frame.stroke(&line, Self::dotted()); + } + DynGuide::AxisDelta | DynGuide::RectSides => { + // Dotted legs from the anchor along its axes to the cursor. + let legs = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(corner); + p.line_to(cursor); + }); + frame.stroke(&legs, Self::dotted()); + if self.guide == DynGuide::RectSides { + // Close the rectangle so both side pairs read as a box. + let rest = canvas::Path::new(|p| { + p.move_to(base); + p.line_to(Point { x: base.x, y: cursor.y }); + p.line_to(cursor); + }); + frame.stroke(&rest, Self::dotted()); + } + } + DynGuide::None => {} + } + + // ── Box placement by role ── + for b in &self.boxes { + let center = match b.role { + DynRole::Angle => { + let a_mid = sweep * 0.5; + Point { + x: base.x + a_mid.cos() * len, + y: base.y + a_mid.sin() * len, + } + } + DynRole::X | DynRole::Width => Point { + x: (base.x + cursor.x) * 0.5, + y: base.y + 14.0, + }, + DynRole::Y | DynRole::Height => Point { + x: corner.x + 18.0, + y: (base.y + cursor.y) * 0.5, + }, + // Distance / Radius / Diameter and anything else ride the line. + _ => Point { + x: base.x + dx * len * 0.5 + nx * 16.0, + y: base.y + dy * len * 0.5 + ny * 16.0, + }, + }; + Self::draw_box(frame, b, center, bounds); + } + } + + /// Fallback row layout near the cursor (no anchor / `None` guide). + fn draw_row(&self, frame: &mut canvas::Frame, bounds: iced::Rectangle) { + let texts: Vec = self + .boxes + .iter() + .map(|b| { + if b.label.is_empty() { + b.value.clone() + } else { + format!("{}:{}", b.label, b.value) + } + }) + .collect(); + let widths: Vec = texts + .iter() + .map(|t| (t.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0) + .collect(); + let total_w: f32 = + widths.iter().sum::() + DYN_GAP * (self.boxes.len() as f32 - 1.0); + + let mut bx = self.cursor_screen.x + DYN_OFFSET_X; + let mut by = self.cursor_screen.y + DYN_OFFSET_Y; + if bx + total_w > bounds.width { + bx = (self.cursor_screen.x - total_w - 4.0).max(0.0); + } + if by + DYN_BOX_H > bounds.height { + by = (self.cursor_screen.y - DYN_BOX_H - 4.0).max(0.0); + } + self.draw_prompt(frame, Point { x: bx, y: (by - DYN_BOX_H - 2.0).max(0.0) }); + + let mut x = bx; + for (i, b) in self.boxes.iter().enumerate() { + let w = widths[i]; + let rect = + canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: DYN_BOX_H }); + let (fill, border) = Self::box_colors(b); + frame.fill(&rect, fill); + frame.stroke( + &rect, + canvas::Stroke::default() + .with_color(border) + .with_width(if b.active { 1.6 } else { 1.0 }), + ); + frame.fill_text(canvas::Text { + content: texts[i].clone(), + position: Point { x: x + DYN_PAD, y: by + DYN_PAD }, + color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 }, + size: iced::Pixels(DYN_FONT), + ..Default::default() + }); + x += w + DYN_GAP; + } + } +} + impl canvas::Program for DynInputCanvas { type State = (); @@ -1252,139 +1509,28 @@ impl canvas::Program for DynInputCanvas { ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); - // Offset the row 14 px right and 20 px below the cursor. - const OFFSET_X: f32 = 14.0; - const OFFSET_Y: f32 = 20.0; - const PAD: f32 = 4.0; - const GAP: f32 = 6.0; - const FONT_SIZE: f32 = 11.0; - const CHAR_W: f32 = FONT_SIZE * 0.62; // monospace-ish width estimate - 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. + // No boxes — just the prompt pill near the cursor. 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; + let pw = (self.prompt.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0; + let mut px = self.cursor_screen.x + DYN_OFFSET_X; + let mut py = self.cursor_screen.y + DYN_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); + if py + DYN_BOX_H > bounds.height { + py = (self.cursor_screen.y - DYN_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() - }); + self.draw_prompt(&mut frame, Point { x: px, y: py }); } return vec![frame.into_geometry()]; } - // Each box is "