forked from 1b-agent/opencadstudio-upstream
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:
1 parent
c9c6e45793
commit
4b878b88c4
15 files changed
+405
-70
No files matched your search
+1
-4
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user