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 <noreply@anthropic.com>
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(),
}
}