feat(dynamic-input): per-step DynSpec framework + guide overhaul

Introduce a per-command, per-step dynamic-input description so each step
shows the value boxes and guide geometry that make sense for it, instead
of a one-size-fits-all distance/angle pair.

- command: add DynSpec{anchor, fields(role+label), guide}, DynRole
  (X/Y/Z/Distance/Angle/Radius/Diameter/Width/Height/Factor/Count with
  label + value scaling), DynGuide(None/Polar/AxisDelta/Radius/RectSides)
  and DynAnchor. New dyn_spec() trait method defaults to None so commands
  on the legacy dyn_field() path are unchanged.
- overlay: redraw the dynamic-input layer by guide — polar reference line
  + angle arc, single radius line, dotted axis legs, or a cursor row —
  and place each box by its role. Angle reads as the unsigned magnitude
  of the short arc; a typed polar angle takes the sign of the cursor side.
- app: route fields/guide/anchor through the tab; resolve points from the
  step anchor and de-scale typed values by role (typed diameter -> radius).
- circle: CR shows a radius value on one line to the cursor; CD shows the
  diameter (twice the cursor radius). No angle arc on radius steps.
- pline/mline: keep polar dynamic input on vertex steps that also accept
  keyword letters via point_step_accepts_keywords().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-15 17:12:08 +03:00
commit b0ee1c39bd
8 changed files with 608 additions and 145 deletions

View file

@ -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<String>,
}
@ -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<iced::Point>,
/// 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<DynFieldEntry>,
/// 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<glam::Vec3>,
/// 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(),

View file

@ -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<DynComponent> =
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<crate::command::DynRole> =
spec.fields.iter().map(|f| f.role).collect();
let cur_roles: Vec<crate::command::DynRole> =
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<DynComponent> = 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]

View file

@ -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<overlay::DynBox> = 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<glam::Vec3>) -> String {
fn dyn_component_value(
f: &DynFieldEntry,
w: glam::Vec3,
base: Option<glam::Vec3>,
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<glam::Vec3
DynComponent::X => 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(),

View file

@ -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<DynFieldSpec>,
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<DynSpec> {
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

View file

@ -189,6 +189,21 @@ impl CadCommand for CircleCommand {
StepCR::Radius(_) => DynField::Distance,
}
}
fn dyn_spec(&self) -> Option<crate::command::DynSpec> {
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<crate::command::DynSpec> {
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 ──────────────────────────────────────────────────────

View file

@ -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<CmdResult> {
// Waiting for scale value
if self.waiting_scale {

View file

@ -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<CmdResult> {
match text.trim().to_uppercase().as_str() {
"A" | "ARC" => {

View file

@ -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<Point>,
guide: DynGuide,
boxes: Vec<DynBox>,
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<Point>,
guide: DynGuide,
boxes: Vec<DynBox>,
/// 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<String> = self
.boxes
.iter()
.map(|b| {
if b.label.is_empty() {
b.value.clone()
} else {
format!("{}:{}", b.label, b.value)
}
})
.collect();
let widths: Vec<f32> = texts
.iter()
.map(|t| (t.len() as f32 * DYN_CHAR_W) + DYN_PAD * 2.0)
.collect();
let total_w: f32 =
widths.iter().sum::<f32>() + 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<Message> for DynInputCanvas {
type State = ();
@ -1252,139 +1509,28 @@ impl canvas::Program<Message> for DynInputCanvas {
) -> Vec<canvas::Geometry> {
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 "<label>:<value>"; width tracks the text length.
let texts: Vec<String> = self
.boxes
.iter()
.map(|b| format!("{}:{}", b.label, b.value))
.collect();
let widths: Vec<f32> = texts
.iter()
.map(|t| (t.len() as f32 * CHAR_W) + PAD * 2.0)
.collect();
let total_w: f32 = widths.iter().sum::<f32>() + GAP * (self.boxes.len() as f32 - 1.0);
let mut bx = self.cursor_screen.x + OFFSET_X;
let mut by = self.cursor_screen.y + OFFSET_Y;
if bx + total_w > bounds.width {
bx = (self.cursor_screen.x - total_w - 4.0).max(0.0);
// Guided layouts need the anchor; without it fall back to a cursor row.
match (self.guide, self.base_screen) {
(DynGuide::None, _) | (_, None) => self.draw_row(&mut frame, bounds),
(_, Some(base)) => self.draw_guided(&mut frame, bounds, base),
}
if by + BOX_H > bounds.height {
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];
let rect = canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: BOX_H });
// Active box: brighter fill + accent border. Locked (typed)
// boxes get a warm border so it's clear they hold a fixed
// value rather than tracking the cursor.
let (fill, border) = if b.active {
(
Color { r: 0.12, g: 0.18, b: 0.30, a: 0.92 },
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.85 },
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.85 },
Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 },
)
};
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 + PAD, y: by + PAD },
color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 },
size: iced::Pixels(FONT_SIZE),
..Default::default()
});
x += w + GAP;
}
vec![frame.into_geometry()]
}
}