feat(input): scalar entry via dynamic input

Text-input steps (OFFSET distance, POLYGON sides, radii, counts) were
swallowed by the dynamic-input overlay: typed digits landed in the field
buffer and Enter committed a point pick, so the value never reached
on_text_input (issue #61). Now each text step shows one scalar box whose
value routes to on_text_input on commit; non-numeric prompts (names,
keywords) keep going to the command line so mixed text like "Bolt12"
isn't split.

OFFSET reworked: object first, then through-mode by default — the offset
follows the cursor at the perpendicular distance to the object. Typing a
number locks the magnitude (cursor then only picks the side); a value
typed and clicked without Enter is applied before the point pick.

The dynamic-input overlay and the command line now show the active
step's prompt and hold it until the step completes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-09 18:32:03 +03:00
1 parent c9c6e45793
commit 4b878b88c4
15 files changed
+405 -70

No files matched your search

+1 -4
View File
@@ -1734,10 +1734,7 @@ impl OpenCADStudio {
self.tabs[i].scene.document.get_entity(h).cloned()
})
.collect();
let new_cmd = OffsetCommand::new(
crate::modules::home::defaults::get_offset_dist(),
all_entities,
);
let new_cmd = OffsetCommand::new(all_entities);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
+4
View File
@@ -27,6 +27,10 @@ pub(super) enum DynComponent {
Distance,
/// Angle from the last point, in degrees.
Angle,
/// A scalar the command reads from the command line (a count, a radius,
/// a delta). Typed-only — it has no geometric live value derived from
/// the cursor unless the command supplies one via `dyn_live_value`.
Scalar,
}
/// A single editable dynamic-input field. `buffer == None` means the box
+83 -1
View File
@@ -2850,6 +2850,31 @@ impl OpenCADStudio {
None
}
} else {
// A scalar typed into the dynamic-input box but not
// yet confirmed with Enter is applied before the
// point pick — e.g. an OFFSET distance typed and then
// clicked takes effect rather than being discarded.
let wants_text = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
if wants_text {
if let Some(text) = self.tabs[i]
.dyn_fields
.iter()
.find_map(|f| f.buffer.clone())
{
let text = super::expr_eval::eval_to_string(text.trim());
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
c.on_text_input(&text);
}
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
self.tabs[i].dyn_active = 0;
}
}
self.last_point = Some(world_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
@@ -7106,10 +7131,28 @@ impl OpenCADStudio {
.as_ref()
.map(|c| c.dyn_field())
.unwrap_or(crate::command::DynField::Point);
// A text-input step reads a single scalar. The overlay shows one box
// the user types into (or, when the command supplies a live value,
// sets by moving the cursor); on commit the host routes it to
// `on_text_input` instead of `on_point`. A distance prompt keeps the
// `Distance` box (so a perpendicular-distance live value reads
// naturally); everything else uses the typed-only `Scalar` box.
let wants_text = self
.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
let has_base = self.last_point.is_some();
let default: Vec<DynComponent> = match field {
crate::command::DynField::Distance => vec![DynComponent::Distance],
crate::command::DynField::Angle => vec![DynComponent::Angle],
crate::command::DynField::Scalar => vec![DynComponent::Scalar],
// A text prompt with the default `Point` field reads free text /
// a name / a keyword (which may itself contain digits) from the
// command line — show no scalar box, so digits reach the command
// line instead of being captured into a dyn buffer.
crate::command::DynField::Point if wants_text => vec![],
crate::command::DynField::Point if has_base => {
vec![DynComponent::Distance, DynComponent::Angle]
}
@@ -7128,7 +7171,7 @@ impl OpenCADStudio {
// the command's default so e.g. clicking the first point of LINE
// flips a stale `[X, Y]` (from before there was a base) over to
// the polar `[Distance, Angle]` the prompt actually wants.
let current_is_acceptable = if self.dyn_user_reshaped {
let current_is_acceptable = if self.dyn_user_reshaped && !wants_text {
match field {
crate::command::DynField::Distance => {
matches!(current.as_slice(), [DynComponent::Distance])
@@ -7136,6 +7179,9 @@ impl OpenCADStudio {
crate::command::DynField::Angle => {
matches!(current.as_slice(), [DynComponent::Angle])
}
crate::command::DynField::Scalar => {
matches!(current.as_slice(), [DynComponent::Scalar])
}
crate::command::DynField::Point => matches!(
current.as_slice(),
[DynComponent::Distance, DynComponent::Angle]
@@ -7447,6 +7493,42 @@ impl OpenCADStudio {
{
return None;
}
// A text-input step reads its single box as a string and commits via
// `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.
let wants_text = self
.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.wants_text_input())
.unwrap_or(false);
if wants_text {
let text = self.tabs[i]
.dyn_fields
.iter()
.find_map(|f| f.buffer.clone())
.unwrap_or_default();
let text = super::expr_eval::eval_to_string(text.trim());
let result = self.tabs[i]
.active_cmd
.as_mut()
.and_then(|c| c.on_text_input(&text));
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
self.tabs[i].dyn_active = 0;
self.sync_dyn_fields();
let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt());
if let Some(p) = prompt {
self.command_line.push_info(&p);
}
self.refresh_active_cmd_preview(i);
return Some(match result {
Some(r) => self.apply_cmd_result(r),
None => self.focus_cmd_input(),
});
}
let pt = self.dyn_resolve_point()?;
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
+42 -7
View File
@@ -676,18 +676,45 @@ impl OpenCADStudio {
if self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty() {
let w = tab.last_cursor_world;
let base = self.last_point;
// A command may drive a typed scalar by mouse (e.g. a
// 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));
let boxes: Vec<overlay::DynBox> = tab
.dyn_fields
.iter()
.enumerate()
.map(|(idx, f)| overlay::DynBox {
label: dyn_component_label(f.component),
value: dyn_component_value(f, w, base),
active: idx == tab.dyn_active,
locked: f.locked(),
.map(|(idx, f)| {
let value = match (&f.buffer, live) {
(Some(b), _) => b.clone(),
(None, Some(lv))
if matches!(
f.component,
DynComponent::Scalar | DynComponent::Distance
) =>
{
format!("{lv:.4}")
}
_ => dyn_component_value(f, w, base),
};
overlay::DynBox {
label: dyn_component_label(f.component),
value,
active: idx == tab.dyn_active,
locked: f.locked(),
}
})
.collect();
Some(overlay::dynamic_input_overlay(tab.last_cursor_screen, boxes))
let prompt = tab
.active_cmd
.as_ref()
.map(|c| c.prompt())
.unwrap_or_default();
Some(overlay::dynamic_input_overlay(
tab.last_cursor_screen,
boxes,
prompt,
))
} else {
None
};
@@ -1150,8 +1177,13 @@ impl OpenCADStudio {
&& !tab.dyn_fields.is_empty())
|| self.mtext_editor.as_ref().is_some_and(|e| e.show_preview)
|| self.text_inline.is_some();
let active_prompt = tab.active_cmd.as_ref().map(|c| c.prompt());
let command_line_overlay =
iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing))
iced::widget::container(self.command_line.view(
allow_autocomplete,
dyn_capturing,
active_prompt,
))
.width(Fill)
.height(Fill)
.align_x(iced::alignment::Horizontal::Center)
@@ -3496,6 +3528,7 @@ fn dyn_component_label(c: DynComponent) -> String {
DynComponent::Z => "Z".into(),
DynComponent::Distance => "d".into(),
DynComponent::Angle => "<".into(),
DynComponent::Scalar => "".into(),
}
}
@@ -3527,5 +3560,7 @@ fn dyn_component_value(
DynComponent::Z => format!("{:.4}", b.z),
DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()),
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(),
}
}
+15
View File
@@ -232,6 +232,10 @@ pub enum DynField {
Distance,
/// An angle, shown in degrees, measured from the last point.
Angle,
/// A typed scalar with no geometric meaning at the cursor — a count
/// (number of sides / segments) or any value the command reads purely
/// from the keyboard. Shown as a single typed box.
Scalar,
}
// ── Trait ─────────────────────────────────────────────────────────────────
@@ -379,6 +383,17 @@ pub trait CadCommand: Send {
fn dyn_field(&self) -> DynField {
DynField::Point
}
/// 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
/// object being offset, so moving the cursor fills in the distance.
/// Returns `None` when the value can only be typed (a count, or a
/// distance with no reference yet). The string the host commits is this
/// value formatted; the command's own `on_text_input` parses it back.
fn dyn_live_value(&self, _cursor: Vec3) -> Option<f64> {
None
}
}
// ── Autocomplete registry ─────────────────────────────────────────────────
+8
View File
@@ -781,6 +781,14 @@ impl CadCommand for CircleTTRCommand {
matches!(self.step, StepTTR::Radius { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, StepTTR::Radius { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn prompt(&self) -> String {
match &self.step {
StepTTR::First => "CIRCLE TTR Select first tangent object:".into(),
+8
View File
@@ -55,6 +55,14 @@ impl CadCommand for DonutCommand {
matches!(self.state, DonutState::AskInner | DonutState::AskOuter)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.state, DonutState::AskInner | DonutState::AskOuter) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let val: f64 = text
.trim()
+24
View File
@@ -351,6 +351,14 @@ impl CadCommand for PolyCommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
@@ -443,6 +451,14 @@ impl CadCommand for PolyCCommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
@@ -544,6 +560,14 @@ impl CadCommand for PolyECommand {
self.step == 0
}
fn dyn_field(&self) -> crate::command::DynField {
if self.step == 0 {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Ok(n) = text.trim().parse::<u32>() {
if (3..=1024).contains(&n) {
+16
View File
@@ -56,6 +56,14 @@ impl CadCommand for DivideCommand {
self.waiting_for_n
}
fn dyn_field(&self) -> crate::command::DynField {
if self.waiting_for_n {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let n: usize = text.trim().parse().ok().filter(|&n| n >= 2)?;
let handle = self.target?;
@@ -117,6 +125,14 @@ impl CadCommand for MeasureCommand {
self.waiting_for_dist
}
fn dyn_field(&self) -> crate::command::DynField {
if self.waiting_for_dist {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let dist: f64 = text
.trim()
+24
View File
@@ -122,6 +122,10 @@ impl CadCommand for ArrayRectCommand {
true
}
fn dyn_field(&self) -> crate::command::DynField {
crate::command::DynField::Scalar
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
@@ -279,6 +283,14 @@ impl CadCommand for ArrayPolarCommand {
matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, PolarStep::Count { .. } | PolarStep::Angle { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
@@ -601,6 +613,14 @@ impl CadCommand for ArrayPathCommand {
matches!(self.step, PathStep::Count { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, PathStep::Count { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() || self.handles.contains(&handle) {
return CmdResult::NeedPoint;
@@ -811,6 +831,10 @@ impl CadCommand for Array3DCommand {
true
}
fn dyn_field(&self) -> crate::command::DynField {
crate::command::DynField::Scalar
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let t = text.trim().replace(',', ".");
let t = t.as_str();
+19
View File
@@ -1235,6 +1235,14 @@ impl CadCommand for FilletCommand {
matches!(self.step, FilletStep::WaitingForRadius)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.step, FilletStep::WaitingForRadius) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match self.step {
FilletStep::WaitingForRadius => {
@@ -1574,6 +1582,17 @@ impl CadCommand for ChamferCommand {
)
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(
self.step,
ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2
) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match self.step {
ChamferStep::WaitingForDist1 => {
+8
View File
@@ -68,6 +68,14 @@ impl CadCommand for LengthenCommand {
matches!(self.state, LenState::PickOption { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
if matches!(self.state, LenState::PickOption { .. }) {
crate::command::DynField::Scalar
} else {
crate::command::DynField::Point
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let (handle, pick_pt) = match &self.state {
LenState::PickOption { handle, pick_pt } => (*handle, *pick_pt),
+107 -55
View File
@@ -381,6 +381,44 @@ fn compute_offset(entity: &EntityType, dist: f64, side_pt: Vec3) -> Option<Entit
}
}
// ── Through-mode distance ─────────────────────────────────────────────────
//
// Nearest distance from the cursor to the entity outline, used by "through"
// mode so the offset copy passes through the cursor. Measured against the
// tessellated wire (point-to-segment), which approximates the perpendicular
// distance for every supported entity type.
fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
let pts = entity_wire_pts(entity);
if pts.len() < 2 {
return 0.0;
}
let px = pt.x as f64;
let py = pt.y as f64;
let mut best = f64::INFINITY;
for w in pts.windows(2) {
let ax = w[0][0] as f64;
let ay = w[0][1] as f64;
let bx = w[1][0] as f64;
let by = w[1][1] as f64;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
let t = if len2 < 1e-12 {
0.0
} else {
(((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0)
};
let cx = ax + t * dx;
let cy = ay + t * dy;
let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
if d < best {
best = d;
}
}
best
}
// ── Wire preview points ─────────────────────────────────────────────────────
fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> {
@@ -546,29 +584,27 @@ fn lwpolyline_pts(p: &LwPolyline) -> Vec<[f32; 3]> {
// ── Command implementation ─────────────────────────────────────────────────
enum Step {
Distance,
SelectObject {
dist: f64,
},
SelectObject,
// `locked == None` is "through" mode: the offset magnitude follows the
// cursor (perpendicular distance to the picked object). Typing a value
// locks the magnitude; the cursor then only chooses the side.
PickSide {
dist: f64,
#[allow(dead_code)]
handle: Handle,
entity: EntityType,
locked: Option<f64>,
},
}
pub struct OffsetCommand {
default_dist: f64,
step: Step,
all_entities: Vec<EntityType>,
}
impl OffsetCommand {
pub fn new(last_dist: f32, all_entities: Vec<EntityType>) -> Self {
pub fn new(all_entities: Vec<EntityType>) -> Self {
Self {
default_dist: last_dist as f64,
step: Step::Distance,
step: Step::SelectObject,
all_entities,
}
}
@@ -581,56 +617,26 @@ impl CadCommand for OffsetCommand {
fn prompt(&self) -> String {
match &self.step {
Step::Distance => format!(
"OFFSET Specify offset distance <{:.4}>:",
self.default_dist
Step::SelectObject => "OFFSET Select object to offset:".into(),
Step::PickSide {
locked: Some(d), ..
} => format!("OFFSET Click side [distance {d:.4}, type to change]:"),
Step::PickSide { locked: None, .. } => format!(
"OFFSET Click through point, or type a distance (last {:.4}):",
defaults::get_offset_dist()
),
Step::SelectObject { dist } => {
format!("OFFSET Select object to offset [dist={dist:.4}]:")
}
Step::PickSide { dist, .. } => {
format!("OFFSET Specify side to offset [dist={dist:.4}]:")
}
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Distance)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::Distance = self.step {
let dist = if text.trim().is_empty() {
self.default_dist
} else {
text.trim()
.replace(',', ".")
.parse::<f64>()
.unwrap_or(self.default_dist)
.abs()
.max(1e-9)
};
defaults::set_offset_dist(dist as f32);
self.default_dist = dist;
self.step = Step::SelectObject { dist };
}
None
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, Step::SelectObject { .. })
matches!(self.step, Step::SelectObject)
}
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
if handle.is_null() {
if handle.is_null() || !matches!(self.step, Step::SelectObject) {
return CmdResult::NeedPoint;
}
let dist = match &self.step {
Step::SelectObject { dist } => *dist,
_ => return CmdResult::NeedPoint,
};
let entity = self
.all_entities
.iter()
@@ -643,9 +649,9 @@ impl CadCommand for OffsetCommand {
| Some(e @ EntityType::Arc(_))
| Some(e @ EntityType::LwPolyline(_)) => {
self.step = Step::PickSide {
dist,
handle,
entity: e,
locked: None,
};
CmdResult::NeedPoint
}
@@ -653,6 +659,44 @@ impl CadCommand for OffsetCommand {
}
}
// The side step accepts an optional typed magnitude — through mode by
// default, a fixed distance once the user types one.
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::PickSide { .. })
}
fn dyn_field(&self) -> crate::command::DynField {
match self.step {
Step::PickSide { .. } => crate::command::DynField::Scalar,
_ => crate::command::DynField::Point,
}
}
fn dyn_live_value(&self, cursor: Vec3) -> Option<f64> {
match &self.step {
Step::PickSide { entity, locked, .. } => {
Some(locked.unwrap_or_else(|| perp_distance(entity, cursor)))
}
_ => None,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if let Step::PickSide { locked, .. } = &mut self.step {
let t = text.trim().replace(',', ".");
if !t.is_empty() {
if let Ok(d) = t.parse::<f64>() {
let d = d.abs().max(1e-9);
defaults::set_offset_dist(d as f32);
*locked = Some(d);
}
}
// Stay on the side step — the click chooses which side.
return Some(CmdResult::NeedPoint);
}
None
}
fn on_hover_entity(&mut self, handle: Handle, _pt: Vec3) -> Vec<WireModel> {
if handle.is_null() {
return vec![];
@@ -676,24 +720,32 @@ impl CadCommand for OffsetCommand {
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
let (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return CmdResult::NeedPoint,
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return CmdResult::NeedPoint;
}
match compute_offset(&entity, dist, pt) {
match compute_offset(&entity, mag, pt) {
Some(new_entity) => CmdResult::CommitAndExit(new_entity),
None => CmdResult::NeedPoint,
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
let (dist, entity) = match &self.step {
Step::PickSide { dist, entity, .. } => (*dist, entity.clone()),
let (locked, entity) = match &self.step {
Step::PickSide { locked, entity, .. } => (*locked, entity.clone()),
_ => return vec![],
};
let mag = locked.unwrap_or_else(|| perp_distance(&entity, pt));
if mag < 1e-9 {
return vec![];
}
if let Some(result) = compute_offset(&entity, dist, pt) {
if let Some(result) = compute_offset(&entity, mag, pt) {
let pts = entity_wire_pts(&result);
if !pts.is_empty() {
return vec![WireModel::solid(
+12 -2
View File
@@ -226,7 +226,12 @@ impl CommandLine {
matches
}
pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> {
pub fn view(
&self,
show_autocomplete: bool,
dyn_capturing: bool,
active_prompt: Option<String>,
) -> Element<'_, Message> {
// Only the most recent entries pushed within the last few
// seconds show on the overlay. The dropdown button keeps the
// full backlog reachable when the user actually wants it.
@@ -247,7 +252,12 @@ impl CommandLine {
};
col.push(container(text(&entry.text).size(11).color(color)).padding([1, 8]))
});
let prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]);
// While a command is running, the prompt shows what the current
// step is asking for and stays put until that step completes — it
// does not fade like the history lines. With no active command it
// falls back to the generic entry prompt.
let prompt_label = active_prompt.unwrap_or_else(|| "Command:".to_string());
let prompt = container(text(prompt_label).size(11).color(PROMPT_COLOR)).padding([5, 8]);
// While dynamic input is capturing keystrokes, the command-line
// text field is left without an `on_input` handler so it can't
// grab focus or swallow numeric keys — those flow through the
+34 -1
View File
@@ -1188,10 +1188,15 @@ pub struct DynBox {
pub locked: bool,
}
pub fn dynamic_input_overlay<'a>(cursor_screen: Point, boxes: Vec<DynBox>) -> Element<'a, Message> {
pub fn dynamic_input_overlay<'a>(
cursor_screen: Point,
boxes: Vec<DynBox>,
prompt: String,
) -> Element<'a, Message> {
canvas(DynInputCanvas {
cursor_screen,
boxes,
prompt,
})
.width(Length::Fill)
.height(Length::Fill)
@@ -1201,6 +1206,9 @@ pub fn dynamic_input_overlay<'a>(cursor_screen: Point, boxes: Vec<DynBox>) -> El
struct DynInputCanvas {
cursor_screen: Point,
boxes: Vec<DynBox>,
/// The active command's current prompt — tells the user what this step
/// is asking for, drawn just above the input boxes.
prompt: String,
}
impl canvas::Program<Message> for DynInputCanvas {
@@ -1258,6 +1266,31 @@ impl canvas::Program<Message> for DynInputCanvas {
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];