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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-15 17:12:08 +03:00
1 parent 18f1688486
commit b0ee1c39bd
8 files changed
+613 -150

No files matched your search

+276 -130
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()]
}
}