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:
parent
18f1688486
commit
b0ee1c39bd
8 changed files with 608 additions and 145 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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()]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue