diff --git a/src/app/document.rs b/src/app/document.rs index 42696d307..12a30324d 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -11,6 +11,42 @@ use acadrust::{CadDocument, Handle}; use iced; use std::path::PathBuf; +// ── Dynamic input ────────────────────────────────────────────────────────── + +/// One quantity shown in the dynamic-input overlay near the cursor. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum DynComponent { + /// Absolute X ordinate. + X, + /// Absolute Y ordinate. + Y, + /// Linear distance from the last point. + Distance, + /// Angle from the last point, in degrees. + Angle, +} + +/// A single editable dynamic-input field. `buffer == None` means the box +/// tracks the cursor live; once the user types, the typed text is held in +/// `buffer` and the box stops following the cursor (it is "locked"). +#[derive(Clone, Debug)] +pub(super) struct DynFieldEntry { + pub(super) component: DynComponent, + pub(super) buffer: Option, +} + +impl DynFieldEntry { + pub(super) fn new(component: DynComponent) -> Self { + Self { + component, + buffer: None, + } + } + pub(super) fn locked(&self) -> bool { + self.buffer.is_some() + } +} + // ── Per-document tab state ───────────────────────────────────────────────── pub(super) struct DocumentTab { @@ -31,6 +67,13 @@ pub(super) struct DocumentTab { pub(super) visual_style: String, pub(super) last_cursor_world: glam::Vec3, pub(super) last_cursor_screen: 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, + /// Index of the field that TAB has focused (the one keystrokes edit). + pub(super) dyn_active: usize, pub(super) history: HistoryState, pub(super) active_layer: String, /// Currently active UCS. `None` means WCS (identity transform). @@ -85,6 +128,8 @@ impl DocumentTab { visual_style: "Wireframe 2D".into(), last_cursor_world: glam::Vec3::ZERO, last_cursor_screen: iced::Point::ORIGIN, + dyn_fields: Vec::new(), + dyn_active: 0, history: HistoryState::default(), active_layer: "0".to_string(), active_ucs: None, diff --git a/src/app/mod.rs b/src/app/mod.rs index cdc60ee39..f3f8ea0bf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -355,6 +355,8 @@ pub enum Message { /// Pop the trailing character off the command-line input — backspace /// counterpart to `CommandAppendChar`. CommandBackspace, + /// TAB pressed: move focus to the next dynamic-input field (wraps). + DynTabNext, /// Recall previous command in history (↑ arrow key). CommandHistoryPrev, /// Recall next command in history (↓ arrow key). diff --git a/src/app/update.rs b/src/app/update.rs index 3fae01feb..69b319252 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -857,18 +857,56 @@ impl OpenCADStudio { // Filter out control characters — only push the typed // glyph(s). `Tab`, etc. arrive as Named keys, not here. if s.chars().all(|c| !c.is_control()) { - self.command_line.input.push_str(&s); + let i = self.active_tab; + // While dynamic input is showing fields, numeric + // glyphs edit the focused field instead of the + // command line. Letters still go to the command line + // so command-option keywords keep working. + let numeric = !s.is_empty() + && s.chars() + .all(|c| c.is_ascii_digit() || matches!(c, '.' | ',' | '-' | '+')); + if numeric && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() { + let a = self.tabs[i].dyn_active.min(self.tabs[i].dyn_fields.len() - 1); + self.tabs[i].dyn_fields[a] + .buffer + .get_or_insert_with(String::new) + .push_str(&s); + } else { + self.command_line.input.push_str(&s); + } } self.command_line.autocomplete_cursor = None; self.focus_cmd_input() } Message::CommandBackspace => { + let i = self.active_tab; + // Backspace edits the focused dynamic-input field first; + // emptying it unlocks the field (back to cursor tracking). + if self.dyn_input && !self.tabs[i].dyn_fields.is_empty() { + let a = self.tabs[i].dyn_active.min(self.tabs[i].dyn_fields.len() - 1); + if let Some(buf) = self.tabs[i].dyn_fields[a].buffer.as_mut() { + buf.pop(); + if buf.is_empty() { + self.tabs[i].dyn_fields[a].buffer = None; + } + return self.focus_cmd_input(); + } + } self.command_line.input.pop(); self.command_line.autocomplete_cursor = None; self.focus_cmd_input() } + Message::DynTabNext => { + let i = self.active_tab; + let n = self.tabs[i].dyn_fields.len(); + if n > 0 { + self.tabs[i].dyn_active = (self.tabs[i].dyn_active + 1) % n; + } + self.focus_cmd_input() + } + Message::CommandHistoryPrev => { // While autocomplete is showing suggestions, ↑ walks up // that list. Otherwise it falls back to recall history. @@ -922,6 +960,15 @@ impl OpenCADStudio { } } let i = self.active_tab; + // With the command line empty, a typed dynamic-input value + // commits as a point pick instead of an empty submit. + if self.tabs[i].active_cmd.is_some() + && self.command_line.input.trim().is_empty() + { + if let Some(task) = self.try_dyn_commit() { + return task; + } + } if self.tabs[i].active_cmd.is_some() { let text = self.command_line.input.trim().to_string(); self.command_line.input.clear(); @@ -1029,6 +1076,11 @@ impl OpenCADStudio { } Message::CommandFinalize => { + // A typed dynamic-input value commits as a point pick + // before the plain-Enter (on_enter) path runs. + if let Some(task) = self.try_dyn_commit() { + return task; + } let i = self.active_tab; if self.tabs[i].active_cmd.is_some() { let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_enter()); @@ -1837,6 +1889,7 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; } + self.sync_dyn_fields(); Task::none() } @@ -4960,6 +5013,119 @@ impl OpenCADStudio { // ── DimStyle dialog helpers ───────────────────────────────────────────────── impl OpenCADStudio { + /// Rebuild the active tab's dynamic-input field set to match what the + /// command is currently asking for. Called on cursor move and after + /// command-state changes. The field set only changes shape when the + /// command's `dyn_field()` or the presence of a base point changes; + /// existing typed buffers survive an unchanged shape. + fn sync_dyn_fields(&mut self) { + use super::document::{DynComponent, DynFieldEntry}; + let i = self.active_tab; + if !self.dyn_input || self.tabs[i].active_cmd.is_none() { + self.tabs[i].dyn_fields.clear(); + self.tabs[i].dyn_active = 0; + return; + } + let field = self + .tabs[i] + .active_cmd + .as_ref() + .map(|c| c.dyn_field()) + .unwrap_or(crate::command::DynField::Point); + let has_base = self.last_point.is_some(); + let desired: Vec = match field { + crate::command::DynField::Distance => vec![DynComponent::Distance], + crate::command::DynField::Angle => vec![DynComponent::Angle], + crate::command::DynField::Point if has_base => { + vec![DynComponent::Distance, DynComponent::Angle] + } + crate::command::DynField::Point => vec![DynComponent::X, DynComponent::Y], + }; + let current: Vec = + self.tabs[i].dyn_fields.iter().map(|f| f.component).collect(); + if current != desired { + self.tabs[i].dyn_fields = desired.into_iter().map(DynFieldEntry::new).collect(); + self.tabs[i].dyn_active = 0; + } + } + + /// Resolve the world point implied by the current dynamic-input field + /// values. Locked fields use their typed buffer; the rest fall back to + /// the live cursor-derived value. Returns `None` when the field set + /// isn't one we know how to turn into a point. + fn dyn_resolve_point(&self) -> Option { + use super::document::DynComponent; + let i = self.active_tab; + let fields = &self.tabs[i].dyn_fields; + if fields.is_empty() { + 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 val = |idx: usize, live: f32| -> f32 { + fields[idx] + .buffer + .as_ref() + .and_then(|s| s.trim().replace(',', ".").parse::().ok()) + .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 + let comps: Vec = fields.iter().map(|f| f.component).collect(); + match comps.as_slice() { + [DynComponent::X, DynComponent::Y] => { + Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z)) + } + [DynComponent::Distance, DynComponent::Angle] => { + let d = val(0, live_d); + let a = val(1, live_a.to_degrees()).to_radians(); + Some(glam::Vec3::new(base.x + d * a.cos(), base.y + d * a.sin(), base.z)) + } + [DynComponent::Distance] => { + // Keep the cursor's direction, override the magnitude. + let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X); + Some(base + dir * val(0, live_d)) + } + [DynComponent::Angle] => { + // Keep the cursor's distance, override the angle. + let a = val(0, live_a.to_degrees()).to_radians(); + Some(glam::Vec3::new( + base.x + live_d * a.cos(), + base.y + live_d * a.sin(), + base.z, + )) + } + _ => None, + } + } + + /// If dynamic input has at least one locked (typed) field, resolve the + /// implied point, feed it to the active command as a point pick, reset + /// the field buffers, and return the resulting task. Returns `None` + /// when there is nothing typed, so the caller falls back to its normal + /// Enter handling. + fn try_dyn_commit(&mut self) -> Option> { + let i = self.active_tab; + if !self.dyn_input + || self.tabs[i].active_cmd.is_none() + || self.tabs[i].dyn_fields.is_empty() + || !self.tabs[i].dyn_fields.iter().any(|f| f.locked()) + { + return None; + } + let pt = self.dyn_resolve_point()?; + self.last_point = Some(pt); + let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt)); + for f in self.tabs[i].dyn_fields.iter_mut() { + f.buffer = None; + } + self.tabs[i].dyn_active = 0; + result.map(|r| self.apply_cmd_result(r)) + } + /// Populate all edit buffers from the currently selected dim style. fn load_dimstyle_bufs(&mut self, tab: usize) { let doc = &self.tabs[tab].scene.document; diff --git a/src/app/view.rs b/src/app/view.rs index 808c3181c..980be536e 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1,8 +1,8 @@ use super::document::DocumentTab; use super::helpers::grid_plane_from_camera; use super::history::history_dropdown_labels; +use super::document::{DynComponent, DynFieldEntry}; use super::{Message, OpenCADStudio}; -use crate::command::DynField; use crate::scene::grip::{grips_to_screen, grips_to_screen_paper}; use crate::scene::paper_canvas::PaperCanvas; use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane}; @@ -463,46 +463,28 @@ impl OpenCADStudio { }) }; - // Dynamic input overlay — shown when a command is active and DYN is on. - // The label adapts to what the command is currently asking for: - // a point shows coordinates (or polar distance+angle once a base - // point exists); a radius/length prompt shows a single distance; - // an angle prompt shows degrees. + // Dynamic input overlay — editable boxes near the cursor, one per + // quantity the active command is asking for (X/Y, or polar + // distance+angle, or a single distance/angle). TAB moves focus + // between boxes; typing locks a box to a fixed value while the + // rest keep tracking the cursor. The field set is maintained in + // `tab.dyn_fields` by `sync_dyn_fields`. let dyn_input_overlay: Option> = - if self.dyn_input && tab.active_cmd.is_some() { + if self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty() { let w = tab.last_cursor_world; - let field = tab - .active_cmd - .as_ref() - .map(|c| c.dyn_field()) - .unwrap_or(DynField::Point); - // World is Z-up: the drawing plane is X/Y, so screen "up" - // maps to world Y — use w.y, not w.z (issue #27). - let label = match (field, self.last_point) { - (DynField::Distance, Some(base)) => { - let dx = (w.x - base.x) as f64; - let dy = (w.y - base.y) as f64; - format!("d={:.3}", (dx * dx + dy * dy).sqrt()) - } - (DynField::Angle, Some(base)) => { - let dx = (w.x - base.x) as f64; - let dy = (w.y - base.y) as f64; - format!("<{:.1}°", dy.atan2(dx).to_degrees().rem_euclid(360.0)) - } - (_, Some(base)) => { - // Point with a base: polar distance + angle. - let dx = (w.x - base.x) as f64; - let dy = (w.y - base.y) as f64; - let dist = (dx * dx + dy * dy).sqrt(); - let ang = dy.atan2(dx).to_degrees().rem_euclid(360.0); - format!("d={:.3} <{:.1}°", dist, ang) - } - (_, None) => format!("X:{:.3} Y:{:.3}", w.x, w.y), - }; - Some(overlay::dynamic_input_overlay( - tab.last_cursor_screen, - label, - )) + let base = self.last_point; + let boxes: Vec = 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(), + }) + .collect(); + Some(overlay::dynamic_input_overlay(tab.last_cursor_screen, boxes)) } else { None }; @@ -612,7 +594,12 @@ impl OpenCADStudio { // Autocomplete shows only when no command is collecting its // own input (otherwise typed prefixes are coordinates / values). let allow_autocomplete = tab.active_cmd.is_none(); - let command_line_overlay = iced::widget::container(self.command_line.view(allow_autocomplete)) + // Dynamic input captures keystrokes when its fields are showing, + // so the command-line field must release focus / its on_input. + let dyn_capturing = + self.dyn_input && tab.active_cmd.is_some() && !tab.dyn_fields.is_empty(); + let command_line_overlay = + iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing)) .width(Fill) .height(Fill) .align_x(iced::alignment::Horizontal::Center) @@ -791,7 +778,7 @@ impl OpenCADStudio { Some(Message::WindowResized(sz.width as f32, sz.height as f32)) } iced::Event::Keyboard(keyboard::Event::KeyPressed { - key, modifiers, .. + key, modifiers, text, .. }) => { let ctrl = modifiers.control(); let shift = modifiers.shift(); @@ -815,6 +802,11 @@ impl OpenCADStudio { { Some(Message::CommandBackspace) } + keyboard::Key::Named(keyboard::key::Named::Tab) + if status == Status::Ignored => + { + Some(Message::DynTabNext) + } keyboard::Key::Named(keyboard::key::Named::ArrowUp) => { Some(Message::CommandHistoryPrev) } @@ -859,10 +851,15 @@ impl OpenCADStudio { // else consumed — route them into the command // line input so typing always reaches it even // when focus is parked on a button / viewport. - keyboard::Key::Character(c) - if !ctrl && status == Status::Ignored => - { - Some(Message::CommandAppendChar(c.to_string())) + // Use the event's `text` (not the logical key) + // so numpad digits and other keypad glyphs — + // which don't arrive as `Key::Character` — are + // captured too. + _ if !ctrl && status == Status::Ignored => { + let glyph = text + .as_deref() + .filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control())); + glyph.map(|t| Message::CommandAppendChar(t.to_string())) } _ => None, } @@ -2031,3 +2028,37 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme }) .into() } + +// ── 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::Distance => "d".into(), + DynComponent::Angle => "<".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, +) -> String { + if let Some(b) = &f.buffer { + return b.clone(); + } + let b = base.unwrap_or(glam::Vec3::ZERO); + let dx = (w.x - b.x) as f64; + let dy = (w.y - b.y) as f64; + match f.component { + DynComponent::X => format!("{:.4}", w.x), + DynComponent::Y => format!("{:.4}", w.y), + DynComponent::Distance => format!("{:.4}", (dx * dx + dy * dy).sqrt()), + DynComponent::Angle => format!("{:.1}", dy.atan2(dx).to_degrees().rem_euclid(360.0)), + } +} diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index d479436d4..3311cc38e 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -226,7 +226,7 @@ impl CommandLine { matches } - pub fn view(&self, show_autocomplete: bool) -> Element<'_, Message> { + pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> 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. @@ -248,10 +248,17 @@ 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]); - let input = text_input("", &self.input) - .id(cmd_input_id()) - .on_input(Message::CommandInput) - .on_submit(Message::CommandSubmit) + // 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 + // global key subscription into the dynamic-input fields instead. + let mut input = text_input("", &self.input).id(cmd_input_id()); + if !dyn_capturing { + input = input + .on_input(Message::CommandInput) + .on_submit(Message::CommandSubmit); + } + let input = input .style(|_: &Theme, _| text_input::Style { background: Background::Color(INPUT_BG), border: Border { diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 167d93bb2..396973171 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -964,10 +964,22 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) { /// /// `cursor_screen` — cursor position in viewport pixels. /// `label` — text to display (e.g. "X: 12.34 Y: 56.78"). -pub fn dynamic_input_overlay<'a>(cursor_screen: Point, label: String) -> Element<'a, Message> { +/// One labelled box in the dynamic-input overlay (e.g. `d` = distance, +/// `<` = angle, `X` / `Y` = ordinates). +#[derive(Clone)] +pub struct DynBox { + pub label: String, + pub value: String, + /// TAB-focused box — keystrokes edit this one. + pub active: bool, + /// User has typed a value (the box no longer tracks the cursor). + pub locked: bool, +} + +pub fn dynamic_input_overlay<'a>(cursor_screen: Point, boxes: Vec) -> Element<'a, Message> { canvas(DynInputCanvas { cursor_screen, - label, + boxes, }) .width(Length::Fill) .height(Length::Fill) @@ -976,7 +988,7 @@ pub fn dynamic_input_overlay<'a>(cursor_screen: Point, label: String) -> Element struct DynInputCanvas { cursor_screen: Point, - label: String, + boxes: Vec, } impl canvas::Program for DynInputCanvas { @@ -1000,68 +1012,79 @@ impl canvas::Program for DynInputCanvas { _cursor: mouse::Cursor, ) -> Vec { let mut frame = canvas::Frame::new(renderer, bounds.size()); + if self.boxes.is_empty() { + return vec![frame.into_geometry()]; + } - // Offset the box 14 px right and 20 px below the cursor. + // 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 BOX_W: f32 = 160.0; + const CHAR_W: f32 = FONT_SIZE * 0.62; // monospace-ish width estimate const BOX_H: f32 = FONT_SIZE + PAD * 2.0; + // Each box is "