feat(dyn-input): TAB-editable distance/angle/coordinate fields

Dinamik giriş artık cursor yanında düzenlenebilir kutular gösteriyor.
TAB ile kutular arası geçilir, bir kutuya yazınca o kutu kilitlenir
(değer sabitlenir), diğerleri cursor'ı takip etmeye devam eder, Enter
ile çözülen nokta komuta point-pick olarak iletilir.

- DocumentTab.dyn_fields / dyn_active state; DynComponent + DynFieldEntry
- sync_dyn_fields komutun dyn_field() + base noktasına göre kutu kümesini
  türetir: nokta+base → [d, açı], nokta+base yok → [X, Y], radius → [d],
  açı → [açı]
- klavye yönlendirme: rakam/./-/+ aktif kutuya, TAB sonraki kutuya,
  Enter try_dyn_commit, Backspace kilidi açar
- dyn_capturing iken command-line text alanı on_input'suz bırakılıyor
  ki focus'u ve tuşları çalmasın; girişler global key aboneliğinden
  geliyor (numpad dahil text alanı üzerinden yakalanıyor)
- çoklu-kutu overlay render: aktif kutu vurgulu, kilitli kutu farklı
  kenar rengi

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-27 14:08:57 +03:00
1 parent 1fc73ba8b0
commit 4abde6001a
6 files changed
+377 -103

No files matched your search

+45
View File
@@ -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<String>,
}
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<DynFieldEntry>,
/// 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,
+2
View File
@@ -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).
+167 -1
View File
@@ -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<DynComponent> = 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<DynComponent> =
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<glam::Vec3> {
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::<f32>().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<DynComponent> = 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<Task<Message>> {
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;
+76 -45
View File
@@ -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<Element<'_, Message>> =
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<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(),
})
.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<glam::Vec3>,
) -> 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)),
}
}