forked from 1b-agent/opencadstudio-upstream
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1fc73ba8b0
commit
4abde6001a
6 files changed
+377
-103
No files matched your search
@@ -11,6 +11,42 @@ use acadrust::{CadDocument, Handle};
|
|||||||
use iced;
|
use iced;
|
||||||
use std::path::PathBuf;
|
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 ─────────────────────────────────────────────────
|
// ── Per-document tab state ─────────────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) struct DocumentTab {
|
pub(super) struct DocumentTab {
|
||||||
@@ -31,6 +67,13 @@ pub(super) struct DocumentTab {
|
|||||||
pub(super) visual_style: String,
|
pub(super) visual_style: String,
|
||||||
pub(super) last_cursor_world: glam::Vec3,
|
pub(super) last_cursor_world: glam::Vec3,
|
||||||
pub(super) last_cursor_screen: iced::Point,
|
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) history: HistoryState,
|
||||||
pub(super) active_layer: String,
|
pub(super) active_layer: String,
|
||||||
/// Currently active UCS. `None` means WCS (identity transform).
|
/// Currently active UCS. `None` means WCS (identity transform).
|
||||||
@@ -85,6 +128,8 @@ impl DocumentTab {
|
|||||||
visual_style: "Wireframe 2D".into(),
|
visual_style: "Wireframe 2D".into(),
|
||||||
last_cursor_world: glam::Vec3::ZERO,
|
last_cursor_world: glam::Vec3::ZERO,
|
||||||
last_cursor_screen: iced::Point::ORIGIN,
|
last_cursor_screen: iced::Point::ORIGIN,
|
||||||
|
dyn_fields: Vec::new(),
|
||||||
|
dyn_active: 0,
|
||||||
history: HistoryState::default(),
|
history: HistoryState::default(),
|
||||||
active_layer: "0".to_string(),
|
active_layer: "0".to_string(),
|
||||||
active_ucs: None,
|
active_ucs: None,
|
||||||
|
|||||||
@@ -355,6 +355,8 @@ pub enum Message {
|
|||||||
/// Pop the trailing character off the command-line input — backspace
|
/// Pop the trailing character off the command-line input — backspace
|
||||||
/// counterpart to `CommandAppendChar`.
|
/// counterpart to `CommandAppendChar`.
|
||||||
CommandBackspace,
|
CommandBackspace,
|
||||||
|
/// TAB pressed: move focus to the next dynamic-input field (wraps).
|
||||||
|
DynTabNext,
|
||||||
/// Recall previous command in history (↑ arrow key).
|
/// Recall previous command in history (↑ arrow key).
|
||||||
CommandHistoryPrev,
|
CommandHistoryPrev,
|
||||||
/// Recall next command in history (↓ arrow key).
|
/// Recall next command in history (↓ arrow key).
|
||||||
|
|||||||
+167
-1
@@ -857,18 +857,56 @@ impl OpenCADStudio {
|
|||||||
// Filter out control characters — only push the typed
|
// Filter out control characters — only push the typed
|
||||||
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
// glyph(s). `Tab`, etc. arrive as Named keys, not here.
|
||||||
if s.chars().all(|c| !c.is_control()) {
|
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.command_line.autocomplete_cursor = None;
|
||||||
self.focus_cmd_input()
|
self.focus_cmd_input()
|
||||||
}
|
}
|
||||||
|
|
||||||
Message::CommandBackspace => {
|
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.input.pop();
|
||||||
self.command_line.autocomplete_cursor = None;
|
self.command_line.autocomplete_cursor = None;
|
||||||
self.focus_cmd_input()
|
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 => {
|
Message::CommandHistoryPrev => {
|
||||||
// While autocomplete is showing suggestions, ↑ walks up
|
// While autocomplete is showing suggestions, ↑ walks up
|
||||||
// that list. Otherwise it falls back to recall history.
|
// that list. Otherwise it falls back to recall history.
|
||||||
@@ -922,6 +960,15 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let i = self.active_tab;
|
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() {
|
if self.tabs[i].active_cmd.is_some() {
|
||||||
let text = self.command_line.input.trim().to_string();
|
let text = self.command_line.input.trim().to_string();
|
||||||
self.command_line.input.clear();
|
self.command_line.input.clear();
|
||||||
@@ -1029,6 +1076,11 @@ impl OpenCADStudio {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Message::CommandFinalize => {
|
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;
|
let i = self.active_tab;
|
||||||
if self.tabs[i].active_cmd.is_some() {
|
if self.tabs[i].active_cmd.is_some() {
|
||||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_enter());
|
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.tabs[i].snap_result = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sync_dyn_fields();
|
||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4960,6 +5013,119 @@ impl OpenCADStudio {
|
|||||||
// ── DimStyle dialog helpers ─────────────────────────────────────────────────
|
// ── DimStyle dialog helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
impl OpenCADStudio {
|
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.
|
/// Populate all edit buffers from the currently selected dim style.
|
||||||
fn load_dimstyle_bufs(&mut self, tab: usize) {
|
fn load_dimstyle_bufs(&mut self, tab: usize) {
|
||||||
let doc = &self.tabs[tab].scene.document;
|
let doc = &self.tabs[tab].scene.document;
|
||||||
|
|||||||
+76
-45
@@ -1,8 +1,8 @@
|
|||||||
use super::document::DocumentTab;
|
use super::document::DocumentTab;
|
||||||
use super::helpers::grid_plane_from_camera;
|
use super::helpers::grid_plane_from_camera;
|
||||||
use super::history::history_dropdown_labels;
|
use super::history::history_dropdown_labels;
|
||||||
|
use super::document::{DynComponent, DynFieldEntry};
|
||||||
use super::{Message, OpenCADStudio};
|
use super::{Message, OpenCADStudio};
|
||||||
use crate::command::DynField;
|
|
||||||
use crate::scene::grip::{grips_to_screen, grips_to_screen_paper};
|
use crate::scene::grip::{grips_to_screen, grips_to_screen_paper};
|
||||||
use crate::scene::paper_canvas::PaperCanvas;
|
use crate::scene::paper_canvas::PaperCanvas;
|
||||||
use crate::scene::viewport_pane::{PaperViewportPane, ViewportPane};
|
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.
|
// Dynamic input overlay — editable boxes near the cursor, one per
|
||||||
// The label adapts to what the command is currently asking for:
|
// quantity the active command is asking for (X/Y, or polar
|
||||||
// a point shows coordinates (or polar distance+angle once a base
|
// distance+angle, or a single distance/angle). TAB moves focus
|
||||||
// point exists); a radius/length prompt shows a single distance;
|
// between boxes; typing locks a box to a fixed value while the
|
||||||
// an angle prompt shows degrees.
|
// 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>> =
|
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 w = tab.last_cursor_world;
|
||||||
let field = tab
|
let base = self.last_point;
|
||||||
.active_cmd
|
let boxes: Vec<overlay::DynBox> = tab
|
||||||
.as_ref()
|
.dyn_fields
|
||||||
.map(|c| c.dyn_field())
|
.iter()
|
||||||
.unwrap_or(DynField::Point);
|
.enumerate()
|
||||||
// World is Z-up: the drawing plane is X/Y, so screen "up"
|
.map(|(idx, f)| overlay::DynBox {
|
||||||
// maps to world Y — use w.y, not w.z (issue #27).
|
label: dyn_component_label(f.component),
|
||||||
let label = match (field, self.last_point) {
|
value: dyn_component_value(f, w, base),
|
||||||
(DynField::Distance, Some(base)) => {
|
active: idx == tab.dyn_active,
|
||||||
let dx = (w.x - base.x) as f64;
|
locked: f.locked(),
|
||||||
let dy = (w.y - base.y) as f64;
|
})
|
||||||
format!("d={:.3}", (dx * dx + dy * dy).sqrt())
|
.collect();
|
||||||
}
|
Some(overlay::dynamic_input_overlay(tab.last_cursor_screen, boxes))
|
||||||
(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,
|
|
||||||
))
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@@ -612,7 +594,12 @@ impl OpenCADStudio {
|
|||||||
// Autocomplete shows only when no command is collecting its
|
// Autocomplete shows only when no command is collecting its
|
||||||
// own input (otherwise typed prefixes are coordinates / values).
|
// own input (otherwise typed prefixes are coordinates / values).
|
||||||
let allow_autocomplete = tab.active_cmd.is_none();
|
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)
|
.width(Fill)
|
||||||
.height(Fill)
|
.height(Fill)
|
||||||
.align_x(iced::alignment::Horizontal::Center)
|
.align_x(iced::alignment::Horizontal::Center)
|
||||||
@@ -791,7 +778,7 @@ impl OpenCADStudio {
|
|||||||
Some(Message::WindowResized(sz.width as f32, sz.height as f32))
|
Some(Message::WindowResized(sz.width as f32, sz.height as f32))
|
||||||
}
|
}
|
||||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||||
key, modifiers, ..
|
key, modifiers, text, ..
|
||||||
}) => {
|
}) => {
|
||||||
let ctrl = modifiers.control();
|
let ctrl = modifiers.control();
|
||||||
let shift = modifiers.shift();
|
let shift = modifiers.shift();
|
||||||
@@ -815,6 +802,11 @@ impl OpenCADStudio {
|
|||||||
{
|
{
|
||||||
Some(Message::CommandBackspace)
|
Some(Message::CommandBackspace)
|
||||||
}
|
}
|
||||||
|
keyboard::Key::Named(keyboard::key::Named::Tab)
|
||||||
|
if status == Status::Ignored =>
|
||||||
|
{
|
||||||
|
Some(Message::DynTabNext)
|
||||||
|
}
|
||||||
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
|
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => {
|
||||||
Some(Message::CommandHistoryPrev)
|
Some(Message::CommandHistoryPrev)
|
||||||
}
|
}
|
||||||
@@ -859,10 +851,15 @@ impl OpenCADStudio {
|
|||||||
// else consumed — route them into the command
|
// else consumed — route them into the command
|
||||||
// line input so typing always reaches it even
|
// line input so typing always reaches it even
|
||||||
// when focus is parked on a button / viewport.
|
// when focus is parked on a button / viewport.
|
||||||
keyboard::Key::Character(c)
|
// Use the event's `text` (not the logical key)
|
||||||
if !ctrl && status == Status::Ignored =>
|
// so numpad digits and other keypad glyphs —
|
||||||
{
|
// which don't arrive as `Key::Character` — are
|
||||||
Some(Message::CommandAppendChar(c.to_string()))
|
// 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,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -2031,3 +2028,37 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme
|
|||||||
})
|
})
|
||||||
.into()
|
.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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -226,7 +226,7 @@ impl CommandLine {
|
|||||||
matches
|
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
|
// Only the most recent entries pushed within the last few
|
||||||
// seconds show on the overlay. The dropdown button keeps the
|
// seconds show on the overlay. The dropdown button keeps the
|
||||||
// full backlog reachable when the user actually wants it.
|
// 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]))
|
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 prompt = container(text("Command:").size(11).color(PROMPT_COLOR)).padding([5, 8]);
|
||||||
let input = text_input("", &self.input)
|
// While dynamic input is capturing keystrokes, the command-line
|
||||||
.id(cmd_input_id())
|
// text field is left without an `on_input` handler so it can't
|
||||||
.on_input(Message::CommandInput)
|
// grab focus or swallow numeric keys — those flow through the
|
||||||
.on_submit(Message::CommandSubmit)
|
// 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 {
|
.style(|_: &Theme, _| text_input::Style {
|
||||||
background: Background::Color(INPUT_BG),
|
background: Background::Color(INPUT_BG),
|
||||||
border: Border {
|
border: Border {
|
||||||
|
|||||||
+75
-52
@@ -964,10 +964,22 @@ fn draw_ucs_icon(frame: &mut canvas::Frame, vp: Mat4, bounds: iced::Rectangle) {
|
|||||||
///
|
///
|
||||||
/// `cursor_screen` — cursor position in viewport pixels.
|
/// `cursor_screen` — cursor position in viewport pixels.
|
||||||
/// `label` — text to display (e.g. "X: 12.34 Y: 56.78").
|
/// `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<DynBox>) -> Element<'a, Message> {
|
||||||
canvas(DynInputCanvas {
|
canvas(DynInputCanvas {
|
||||||
cursor_screen,
|
cursor_screen,
|
||||||
label,
|
boxes,
|
||||||
})
|
})
|
||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
@@ -976,7 +988,7 @@ pub fn dynamic_input_overlay<'a>(cursor_screen: Point, label: String) -> Element
|
|||||||
|
|
||||||
struct DynInputCanvas {
|
struct DynInputCanvas {
|
||||||
cursor_screen: Point,
|
cursor_screen: Point,
|
||||||
label: String,
|
boxes: Vec<DynBox>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl canvas::Program<Message> for DynInputCanvas {
|
impl canvas::Program<Message> for DynInputCanvas {
|
||||||
@@ -1000,68 +1012,79 @@ impl canvas::Program<Message> for DynInputCanvas {
|
|||||||
_cursor: mouse::Cursor,
|
_cursor: mouse::Cursor,
|
||||||
) -> Vec<canvas::Geometry> {
|
) -> Vec<canvas::Geometry> {
|
||||||
let mut frame = canvas::Frame::new(renderer, bounds.size());
|
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_X: f32 = 14.0;
|
||||||
const OFFSET_Y: f32 = 20.0;
|
const OFFSET_Y: f32 = 20.0;
|
||||||
const PAD: f32 = 4.0;
|
const PAD: f32 = 4.0;
|
||||||
|
const GAP: f32 = 6.0;
|
||||||
const FONT_SIZE: f32 = 11.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;
|
const BOX_H: f32 = FONT_SIZE + PAD * 2.0;
|
||||||
|
|
||||||
|
// Each box is "<label>:<value>"; width tracks the text length.
|
||||||
|
let texts: Vec<String> = self
|
||||||
|
.boxes
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{}:{}", b.label, b.value))
|
||||||
|
.collect();
|
||||||
|
let widths: Vec<f32> = texts
|
||||||
|
.iter()
|
||||||
|
.map(|t| (t.len() as f32 * CHAR_W) + PAD * 2.0)
|
||||||
|
.collect();
|
||||||
|
let total_w: f32 = widths.iter().sum::<f32>() + GAP * (self.boxes.len() as f32 - 1.0);
|
||||||
|
|
||||||
let mut bx = self.cursor_screen.x + OFFSET_X;
|
let mut bx = self.cursor_screen.x + OFFSET_X;
|
||||||
let mut by = self.cursor_screen.y + OFFSET_Y;
|
let mut by = self.cursor_screen.y + OFFSET_Y;
|
||||||
|
if bx + total_w > bounds.width {
|
||||||
// Keep box inside viewport.
|
bx = (self.cursor_screen.x - total_w - 4.0).max(0.0);
|
||||||
if bx + BOX_W > bounds.width {
|
|
||||||
bx = self.cursor_screen.x - BOX_W - 4.0;
|
|
||||||
}
|
}
|
||||||
if by + BOX_H > bounds.height {
|
if by + BOX_H > bounds.height {
|
||||||
by = self.cursor_screen.y - BOX_H - 4.0;
|
by = (self.cursor_screen.y - BOX_H - 4.0).max(0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bg = canvas::Path::rectangle(
|
let mut x = bx;
|
||||||
Point { x: bx, y: by },
|
for (i, b) in self.boxes.iter().enumerate() {
|
||||||
Size {
|
let w = widths[i];
|
||||||
width: BOX_W,
|
let rect = canvas::Path::rectangle(Point { x, y: by }, Size { width: w, height: BOX_H });
|
||||||
height: BOX_H,
|
// Active box: brighter fill + accent border. Locked (typed)
|
||||||
},
|
// boxes get a warm border so it's clear they hold a fixed
|
||||||
);
|
// value rather than tracking the cursor.
|
||||||
frame.fill(
|
let (fill, border) = if b.active {
|
||||||
&bg,
|
(
|
||||||
Color {
|
Color { r: 0.12, g: 0.18, b: 0.30, a: 0.92 },
|
||||||
r: 0.05,
|
Color { r: 0.45, g: 0.70, b: 1.0, a: 1.0 },
|
||||||
g: 0.05,
|
)
|
||||||
b: 0.12,
|
} else if b.locked {
|
||||||
a: 0.85,
|
(
|
||||||
},
|
Color { r: 0.05, g: 0.05, b: 0.12, a: 0.85 },
|
||||||
);
|
Color { r: 0.95, g: 0.75, b: 0.30, a: 0.9 },
|
||||||
frame.stroke(
|
)
|
||||||
&bg,
|
} else {
|
||||||
canvas::Stroke::default()
|
(
|
||||||
.with_color(Color {
|
Color { r: 0.05, g: 0.05, b: 0.12, a: 0.85 },
|
||||||
r: 0.35,
|
Color { r: 0.35, g: 0.55, b: 0.90, a: 0.9 },
|
||||||
g: 0.55,
|
)
|
||||||
b: 0.90,
|
};
|
||||||
a: 0.9,
|
frame.fill(&rect, fill);
|
||||||
})
|
frame.stroke(
|
||||||
.with_width(1.0),
|
&rect,
|
||||||
);
|
canvas::Stroke::default()
|
||||||
frame.fill_text(canvas::Text {
|
.with_color(border)
|
||||||
content: self.label.clone(),
|
.with_width(if b.active { 1.6 } else { 1.0 }),
|
||||||
position: Point {
|
);
|
||||||
x: bx + PAD,
|
frame.fill_text(canvas::Text {
|
||||||
y: by + PAD,
|
content: texts[i].clone(),
|
||||||
},
|
position: Point { x: x + PAD, y: by + PAD },
|
||||||
color: Color {
|
color: Color { r: 0.92, g: 0.92, b: 0.92, a: 1.0 },
|
||||||
r: 0.90,
|
size: iced::Pixels(FONT_SIZE),
|
||||||
g: 0.90,
|
..Default::default()
|
||||||
b: 0.90,
|
});
|
||||||
a: 1.0,
|
x += w + GAP;
|
||||||
},
|
}
|
||||||
size: iced::Pixels(FONT_SIZE),
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
vec![frame.into_geometry()]
|
vec![frame.into_geometry()]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user