Dynamic Input: pointer input boxes with Tab, comma and relative entry (#60)

* Dynamic Input: pointer input boxes, Tab separator, relative entry

While a command asks for a point and Dynamic Input is on, the cursor
tooltip shows a pair of value boxes, following AutoCAD's pointer input:

- The first point of a command is X and Y, absolute.
- Second and next points default to polar (distance and angle) measured
  from the last point, so no `@` is needed.
- Typing `,` after a value locks it as X and moves to Y; `<` locks it as
  the distance and moves to the angle. A locked value holds the cursor and
  shows a lock.
- Tab types the separator for the format on show, so `40` Tab `90` is 40
  units at 90 degrees, and with the Cartesian setting `40` Tab `40` is 40
  over and 40 up.
- Enter or Space sends the entry with `@` in front, filling an empty box
  from the cursor. A plain number and Enter is still direct distance entry;
  `@`/`#` entries, keywords and command names go through untouched.

The command-line text is the single source of truth: the boxes are a view
of it, so the command line always shows exactly what will be sent
(`40,`, `40<90`) and Backspace simply edits it.

Tab is taken from egui in the raw input hook (desktop and web) while no
widget has focus. Otherwise egui moves keyboard focus to the next button on
Tab and the command line stops receiving keystrokes.

Two new settings mirror AutoCAD's: DYNPIFORMAT (1 = second points start
Cartesian) and DYNPICOORDS (1 = absolute), through SETVAR and in Drafting
Settings under Dynamic Input. The tooltip uses the theme tokens, matching
the command line.

Scripts, the control channel and MCP never pass through the boxes and keep
the plain command-line rules. The parsing lives in a small pure module
(dyninput.rs) with unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uMCkRAB8DMd8MEmoY9Vyr

* Dynamic Input tooltip: border colour from theme tokens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
by Truitt 2026-10-10 12:21:39 -05:00 committed by GitHub
parent a43e88e17a
commit 3e0adadf94
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 524 additions and 14 deletions

View File

@ -21,6 +21,10 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}

View File

@ -42,8 +42,9 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);

View File

@ -221,6 +221,12 @@ pub struct Settings {
/// Major grid line every N minor lines (GRIDMAJOR).
pub gridmajor: u32,
pub dynmode: bool,
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
/// polar `distance<angle` (the default).
pub dynpi_cartesian: bool,
/// DYNPICOORDS = 1: second and next points typed into Dynamic Input are absolute instead of
/// relative to the last point (the default).
pub dynpi_absolute: bool,
pub lwdisplay: bool,
pub transparency_display: bool,
pub selection_cycling: bool,
@ -253,6 +259,8 @@ impl Default for Settings {
gridunit: Vec2::new(0.5, 0.5),
gridmajor: 5,
dynmode: true,
dynpi_cartesian: false,
dynpi_absolute: false,
lwdisplay: false,
transparency_display: false,
selection_cycling: false,

View File

@ -15,6 +15,8 @@ const SESSION_VARS: &[&str] = &[
"GRIDUNIT",
"GRIDMAJOR",
"DYNMODE",
"DYNPIFORMAT",
"DYNPICOORDS",
"LWDISPLAY",
"QPMODE",
"PICKBOX",
@ -42,6 +44,8 @@ pub fn get(s: &Session, name: &str) -> Option<Value> {
"GRIDUNIT" => json!([st.gridunit.x, st.gridunit.y]),
"GRIDMAJOR" => json!(st.gridmajor),
"DYNMODE" => json!(i32::from(st.dynmode)),
"DYNPIFORMAT" => json!(i32::from(st.dynpi_cartesian)),
"DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)),
"LWDISPLAY" => json!(i32::from(st.lwdisplay)),
"QPMODE" => json!(i32::from(st.qpmode)),
"PICKBOX" => json!(st.pickbox),
@ -131,6 +135,8 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> {
"GRIDUNIT" => st.gridunit = as_pt(v).filter(|p| p.x > 0.0 && p.y > 0.0).ok_or_else(bad)?,
"GRIDMAJOR" => st.gridmajor = as_f64(v).ok_or_else(bad)?.clamp(1.0, 100.0) as u32,
"DYNMODE" => st.dynmode = as_bool(v).ok_or_else(bad)?,
"DYNPIFORMAT" => st.dynpi_cartesian = as_bool(v).ok_or_else(bad)?,
"DYNPICOORDS" => st.dynpi_absolute = as_bool(v).ok_or_else(bad)?,
"LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?,
"QPMODE" => st.qpmode = as_i(v).ok_or_else(bad)? > 0,
"PICKBOX" => st.pickbox = as_f64(v).ok_or_else(bad)?.clamp(0.0, 50.0),

View File

@ -503,6 +503,17 @@ fn setvar_keeps_header_types() {
assert_eq!(s.doc().unwrap().header.f64("LTSCALE", 0.0), 1.0);
}
#[test]
fn dynamic_input_pointer_settings() {
let mut s = Session::new();
assert_eq!(sysvars::get(&s, "DYNPIFORMAT"), Some(json!(0)));
assert_eq!(sysvars::get(&s, "dynpicoords"), Some(json!(0)));
s.execute("setvar", &json!({"name": "dynpiformat", "value": 1})).unwrap();
s.execute("setvar", &json!({"name": "DYNPICOORDS", "value": 1})).unwrap();
assert!(s.settings.dynpi_cartesian && s.settings.dynpi_absolute);
assert!(s.execute("setvar", &json!({"name": "DYNPIFORMAT", "value": "x"})).is_err());
}
#[test]
fn script_runs_commands() {
let mut s = Session::new();

View File

@ -617,8 +617,17 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
}
let raw_world = hover_pos.filter(|_| inside).map(|p| xf.to_world(p));
let dyn_frame = crate::cmdline::dyn_frame(app);
if let Some(w) = raw_world {
let eff = effective_point(app, w, &xf);
let mut eff = effective_point(app, w, &xf);
// A locked Dynamic Input value (`40,` or `40<` typed) holds the cursor and overrides snaps.
if let Some(f) = dyn_frame
&& let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f)
&& e.first_locked().is_some()
{
eff = crate::dyninput::constrain(&e, &f, eff);
app.canvas.snap = None;
}
app.canvas.cursor = Some(eff);
app.session.cursor = eff;
app.session.cursor_deferred = app.canvas.snap.and_then(|h| h.deferred);
@ -636,6 +645,12 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) {
{
if app.session.running.is_some() {
app.canvas.hot_grip = None;
// Clicking places the point; a value typed into the Dynamic Input boxes is used up.
if let Some(f) = dyn_frame
&& crate::dyninput::parse(&app.cmd.buffer, &f).is_some()
{
app.cmd.buffer.clear();
}
// A deferred tangent/perpendicular goes to the command as such.
let input = app.canvas.snap.filter(|h| h.deferred.is_some()).map_or(Input::Point(p), |h| h.input());
if let Err(e) = app.session.input(input) {
@ -924,8 +939,18 @@ fn draw_crosshair(app: &CadApp, p: &egui::Painter, rect: Rect, at: Pos2, pickbox
}
}
/// The Dynamic Input tooltip beside the cursor, styled like the command line. At a point prompt
/// it shows the pair of value boxes, a view of the command-line text (see [`crate::dyninput`]);
/// otherwise the prompt with what is being typed, or the live distance and angle.
fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) {
let Some(prompt) = app.session.current_prompt() else { return };
if let Some(f) = crate::cmdline::dyn_frame(app)
&& let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f)
&& let Some(c) = app.canvas.cursor
{
dynamic_input_boxes(p, at, &prompt.message, &f, &e, c);
return;
}
let mut text = prompt.message.clone();
if !app.cmd.buffer.is_empty() {
text = format!("{}: {}", prompt.message, app.cmd.buffer);
@ -944,11 +969,110 @@ fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) {
cadcraft_engine::units::format_distance(c.y, 2, 4)
);
}
let galley = p.layout_no_wrap(text, crate::theme::small(), Color32::from_rgb(0x15, 0x15, 0x15));
let r = Rect::from_min_size(at + vec2(18.0, -30.0), galley.size() + vec2(10.0, 6.0));
p.rect_filled(r, 2.0, Color32::from_rgba_unmultiplied(0xe9, 0xec, 0xf0, 235));
p.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(0x8a, 0x93, 0xa0)), egui::StrokeKind::Inside);
p.galley(r.min + vec2(5.0, 3.0), galley, Color32::BLACK);
let t = Tokens::get();
let galley = p.layout_no_wrap(text, crate::theme::small(), t.text);
let r = tooltip_frame(p, at, galley.size() + vec2(12.0, 8.0));
p.galley(r.min + vec2(6.0, 4.0), galley, t.text);
}
/// The tooltip background, matching the command line bar; returns its rectangle.
fn tooltip_frame(p: &egui::Painter, at: Pos2, size: egui::Vec2) -> Rect {
let t = Tokens::get();
let r = Rect::from_min_size(at + vec2(18.0, -size.y - 14.0), size);
p.rect_filled(r, 3.0, t.cmd_bg);
p.rect_stroke(r, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);
r
}
/// The Dynamic Input value boxes: X and Y, or distance and angle. Typed values show in full
/// text colour, a locked value (a separator typed after it) gets a lock, the active box gets the
/// accent outline, and open boxes show the cursor's live value, dimmed. Values measured from the
/// last point are marked with `@`.
fn dynamic_input_boxes(p: &egui::Painter, at: Pos2, message: &str, f: &crate::dyninput::Frame, e: &crate::dyninput::Entry<'_>, cursor: Vec2) {
use crate::dyninput::Format;
let t = Tokens::get();
let font = crate::theme::small();
let dist = |v: f64| cadcraft_engine::units::format_distance(v, 2, 4);
let (live_first, live_second) = f.live(e.format, cursor);
let live_second = match e.format {
Format::Cartesian => dist(live_second),
Format::Polar => format!("{}°", (live_second.to_degrees() * 10.0).round() / 10.0),
};
let (label_first, label_second) = match e.format {
Format::Cartesian => ("X", "Y"),
Format::Polar => ("", "<"),
};
// (label, text, typed, locked, active)
let cells = [
(
label_first,
if e.first.is_empty() { dist(live_first) } else { e.first.to_owned() },
!e.first.is_empty(),
e.first_locked().is_some(),
!e.split,
),
(label_second, if e.second.is_empty() { live_second } else { e.second.to_owned() }, !e.second.is_empty(), false, e.split),
];
let msg = p.layout_no_wrap(message.to_owned(), font.clone(), t.text_dim);
let marker = p.layout_no_wrap(if f.relative() { "@".to_owned() } else { String::new() }, font.clone(), t.text_faint);
let lock_w = 10.0;
let laid: Vec<_> = cells
.into_iter()
.map(|(label, text, typed, locked, active)| {
let label = p.layout_no_wrap(label.to_owned(), font.clone(), t.text_faint);
let value = p.layout_no_wrap(text, font.clone(), if typed { t.text } else { t.text_faint });
let w = (value.size().x + 10.0 + if locked { lock_w } else { 0.0 }).max(48.0);
(label, value, w, locked, active)
})
.collect();
// Measure, then draw.
let pad = 6.0;
let gap = 6.0;
let box_h = msg.size().y + 6.0;
let mut w = pad + msg.size().x + gap + marker.size().x;
for (label, _, bw, _, _) in &laid {
w += gap + label.size().x + 3.0 + bw;
}
let r = tooltip_frame(p, at, vec2(w + pad, box_h + 2.0 * pad - 2.0));
let cy = r.center().y;
let mut x = r.left() + pad;
let (msg_w, msg_h) = (msg.size().x, msg.size().y);
p.galley(pos2(x, cy - msg_h / 2.0), msg, t.text_dim);
x += msg_w + gap;
let mw = marker.size().x;
p.galley(pos2(x, cy - marker.size().y / 2.0), marker, t.text_faint);
x += mw;
for (label, value, bw, locked, active) in laid {
x += gap;
let lw = label.size().x;
p.galley(pos2(x, cy - label.size().y / 2.0), label, t.text_faint);
x += lw + 3.0;
let b = Rect::from_min_size(pos2(x, cy - box_h / 2.0), vec2(bw, box_h));
p.rect_filled(b, 2.0, t.chrome_dark);
let stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) };
p.rect_stroke(b, 2.0, stroke, egui::StrokeKind::Inside);
let vw = value.size().x;
p.galley(pos2(b.left() + 5.0, cy - value.size().y / 2.0), value, t.text);
if active {
let cx = b.left() + 6.0 + vw;
p.line_segment([pos2(cx, b.top() + 3.0), pos2(cx, b.bottom() - 3.0)], Stroke::new(1.0, t.text));
}
if locked {
draw_lock(p, pos2(b.right() - lock_w / 2.0 - 3.0, cy), t.accent);
}
x += bw;
}
}
/// A small padlock, drawn in code: body plus shackle.
fn draw_lock(p: &egui::Painter, c: Pos2, color: Color32) {
p.rect_filled(Rect::from_center_size(c + vec2(0.0, 1.5), vec2(7.0, 5.0)), 1.0, color);
p.add(Shape::line(
vec![pos2(c.x - 2.0, c.y - 1.0), pos2(c.x - 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 4.0), pos2(c.x + 2.0, c.y - 1.0)],
Stroke::new(1.2, color),
));
}
fn draw_ucs_icon(p: &egui::Painter, rect: Rect) {

View File

@ -26,6 +26,52 @@ pub struct CmdLine {
pub const MAX_LINES: usize = 12;
const LINE_H: f32 = 16.0;
/// The Dynamic Input frame for the prompt on screen, or `None` when the value boxes are not live
/// (Dynamic Input off, no point prompt, a hot grip or a pending selection window).
pub fn dyn_frame(app: &CadApp) -> Option<crate::dyninput::Frame> {
let st = &app.session.settings;
if !st.dynmode || app.canvas.hot_grip.is_some() || app.session.pending_window.is_some() {
return None;
}
let p = app.session.current_prompt().filter(|p| p.accept.point && !p.accept.select)?;
Some(crate::dyninput::Frame { base: p.base, cartesian: st.dynpi_cartesian, absolute: st.dynpi_absolute })
}
/// Take Tab away from egui before it moves keyboard focus to the next widget (which would leave
/// the command line deaf to typing). While no widget has focus, a pressed Tab becomes a `"\t"`
/// text event, which keeps its place among the other keystrokes; [`keyboard`] handles it.
pub fn capture_tab(ctx: &egui::Context, raw: &mut egui::RawInput) {
if ctx.egui_wants_keyboard_input() {
return;
}
raw.events.retain_mut(|e| {
let tab = match e {
egui::Event::Key { key: Key::Tab, pressed, .. } => Some(*pressed),
_ => None,
};
match tab {
Some(true) => {
*e = egui::Event::Text("\t".into());
true
}
Some(false) => false,
None => true,
}
});
}
/// Tab on the command line: type the Dynamic Input separator (`,` or `<`) after a value, or
/// complete a command name.
fn tab(app: &mut CadApp) {
if let Some(f) = dyn_frame(app)
&& let Some(sep) = crate::dyninput::tab_separator(&app.cmd.buffer, &f)
{
app.cmd.buffer.push(sep);
} else if let Some((id, _)) = suggestions(&app.cmd.buffer).first() {
app.cmd.buffer = id.clone();
}
}
/// Matching command names/aliases for AutoComplete.
fn suggestions(prefix: &str) -> Vec<(String, &'static str)> {
if prefix.is_empty() || prefix.contains(' ') {
@ -91,7 +137,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
if app.canvas.hot_grip.is_some() {
for ev in &events {
match ev {
egui::Event::Text(t) if t != " " => app.cmd.buffer.push_str(t),
egui::Event::Text(t) if t != " " && t != "\t" => app.cmd.buffer.push_str(t),
egui::Event::Key { key: Key::Backspace, pressed: true, .. } => {
app.cmd.buffer.pop();
}
@ -125,7 +171,9 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
for ev in events {
match ev {
egui::Event::Text(t) => {
if t == " " && !text_prompt {
if t == "\t" {
tab(app);
} else if t == " " && !text_prompt {
submit(app);
} else {
app.cmd.buffer.push_str(&t);
@ -173,11 +221,7 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
}
}
}
Key::Tab => {
if let Some((id, _)) = suggestions(&app.cmd.buffer).first() {
app.cmd.buffer = id.clone();
}
}
Key::Tab => tab(app),
Key::Delete if app.cmd.buffer.is_empty() && app.session.running.is_none() => {
let _ = app.run("erase.selection", serde_json::json!({}));
}
@ -190,6 +234,16 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) {
}
pub fn submit(app: &mut CadApp) {
// Dynamic Input: a split entry (`40,30`, `40<90`, `40,`) is completed from the cursor and
// measured from the last point.
if let Some(f) = dyn_frame(app) {
let cursor = app.canvas.cursor.unwrap_or(app.session.cursor);
let last = app.session.last_point;
let done = crate::dyninput::parse(&app.cmd.buffer, &f).and_then(|e| crate::dyninput::complete(&e, &f, cursor, last));
if let Some(text) = done {
app.cmd.buffer = text;
}
}
let text = std::mem::take(&mut app.cmd.buffer);
app.cmd.history_pos = None;
if !text.trim().is_empty() && app.session.running.is_none() {

View File

@ -87,6 +87,20 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
});
ui.separator();
ui.checkbox(&mut s.dynmode, "Enable Dynamic Input (F12)");
// Pointer input format for second and next points (DYNPIFORMAT, DYNPICOORDS).
ui.indent("dynpi", |ui| {
ui.add_enabled_ui(s.dynmode, |ui| {
ui.label("Second and next points:");
ui.horizontal(|ui| {
ui.radio_value(&mut s.dynpi_cartesian, false, "Polar (distance < angle)");
ui.radio_value(&mut s.dynpi_cartesian, true, "Cartesian (x, y)");
});
ui.horizontal(|ui| {
ui.radio_value(&mut s.dynpi_absolute, false, "Relative");
ui.radio_value(&mut s.dynpi_absolute, true, "Absolute");
});
});
});
ui.checkbox(&mut s.orthomode, "Ortho (F8)");
});
}

View File

@ -0,0 +1,287 @@
//! Dynamic Input pointer input: the value boxes beside the cursor while a command asks for a point.
//!
//! The command-line text is the single source of truth; the boxes are a view of it:
//! - `40` is typing in the first box (X, or the distance for second and next points, which
//! default to polar as in AutoCAD; DYNPIFORMAT = 1 makes them Cartesian).
//! - `40,` locks X = 40 and moves to Y; `40<` locks the distance and moves to the angle. A locked
//! value holds the cursor.
//! - Tab types the separator for the format on show, so `40` Tab `90` reads `40<90`.
//! - Enter sends the entry with `@` in front when it is measured from the last point (relative is
//! the default; DYNPICOORDS = 1 makes it absolute), and fills an empty box from the cursor.
//! - A plain number and Enter stays direct distance entry; `@`/`#` entries, keywords and command
//! names go through untouched.
//!
//! Scripts, the control channel and MCP never pass through here, so they keep the plain
//! command-line rules.
use cadcraft_geom::Vec2;
/// How the boxes read the point.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Format {
/// X and Y.
#[default]
Cartesian,
/// Distance and angle.
Polar,
}
impl Format {
/// The character that ends the first value.
pub fn separator(self) -> char {
match self {
Format::Cartesian => ',',
Format::Polar => '<',
}
}
}
/// The settings and prompt the boxes are working for.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The prompt's base point (the last point), if it has one.
pub base: Option<Vec2>,
/// DYNPIFORMAT = 1: second and next points start Cartesian instead of polar.
pub cartesian: bool,
/// DYNPICOORDS = 1: second and next points are absolute instead of relative.
pub absolute: bool,
}
impl Frame {
/// The format the boxes show before a separator is typed.
pub fn default_format(&self) -> Format {
if self.base.is_some() && !self.cartesian { Format::Polar } else { Format::Cartesian }
}
/// Where the typed values are measured from.
pub fn origin(&self) -> Vec2 {
match self.base {
Some(b) if !self.absolute => b,
_ => Vec2::ZERO,
}
}
/// Whether values are offsets from the last point.
pub fn relative(&self) -> bool {
self.base.is_some() && !self.absolute
}
/// The live (X, Y) or (distance, angle in radians) of `cursor`.
pub fn live(&self, format: Format, cursor: Vec2) -> (f64, f64) {
let d = cursor - self.origin();
match format {
Format::Cartesian => (d.x, d.y),
Format::Polar => (d.len(), d.y.atan2(d.x)),
}
}
}
/// The command-line text read as a pointer-input entry.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry<'a> {
pub format: Format,
/// Text in the first box (X or distance).
pub first: &'a str,
/// A separator was typed: the first box is done and the second box is active.
pub split: bool,
/// Text in the second box (Y or angle).
pub second: &'a str,
}
impl Entry<'_> {
/// The locked first value, once a separator follows it.
pub fn first_locked(&self) -> Option<f64> {
if self.split { cadcraft_engine::units::parse_distance(self.first) } else { None }
}
/// The value typed in the second box (radians for an angle).
pub fn second_value(&self) -> Option<f64> {
let t = self.second.trim();
if t.is_empty() {
return None;
}
match self.format {
Format::Cartesian => cadcraft_engine::units::parse_distance(t),
Format::Polar => cadcraft_engine::units::parse_angle(t),
}
}
}
/// Read `text` as a pointer-input entry, or `None` when it is something else (an explicit
/// `@`/`#` entry, a keyword, a command name, a 3D point).
pub fn parse<'a>(text: &'a str, frame: &Frame) -> Option<Entry<'a>> {
let t = text.trim();
if t.starts_with('@') || t.starts_with('#') {
return None;
}
let entry = if let Some((a, b)) = t.split_once(',') {
Entry { format: Format::Cartesian, first: a.trim(), split: true, second: b.trim() }
} else if let Some((a, b)) = t.split_once('<') {
Entry { format: Format::Polar, first: a.trim(), split: true, second: b.trim() }
} else {
Entry { format: frame.default_format(), first: t, split: false, second: "" }
};
let number_or_empty = |s: &str| s.is_empty() || cadcraft_engine::units::parse_distance(s).is_some();
if !number_or_empty(entry.first) || entry.second.contains([',', '<']) {
return None;
}
if !entry.second.is_empty() && entry.second_value().is_none() {
return None;
}
Some(entry)
}
/// What Tab types: the separator for the format on show, when the first box is still active.
pub fn tab_separator(text: &str, frame: &Frame) -> Option<char> {
parse(text, frame).filter(|e| !e.split).map(|e| e.format.separator())
}
/// Hold the cursor to the locked first value.
pub fn constrain(entry: &Entry<'_>, frame: &Frame, p: Vec2) -> Vec2 {
let Some(v) = entry.first_locked() else { return p };
let o = frame.origin();
match entry.format {
Format::Cartesian => Vec2::new(o.x + v, p.y),
Format::Polar => {
let d = p - o;
let l = d.len();
let dir = if l > 1e-12 { d * (1.0 / l) } else { Vec2::X };
o + dir * v
}
}
}
/// The command-line text to send for a finished entry, or `None` to send the text as typed
/// (nothing split yet: a plain number stays direct distance entry). Empty boxes take the cursor's
/// value. Entries measured from the last point are sent with `@` when the prompt's base is the
/// session's last point (`last`, what `@` resolves against); otherwise, and for absolute entries,
/// the finished point is sent as `#x,y`, so what was previewed is exactly what is placed.
pub fn complete(entry: &Entry<'_>, frame: &Frame, cursor: Vec2, last: Vec2) -> Option<String> {
if !entry.split {
return None;
}
let (live_first, live_second) = frame.live(entry.format, constrain(entry, frame, cursor));
let first = entry.first_locked().unwrap_or(live_first);
let second = entry.second_value().unwrap_or(live_second);
if frame.relative() && frame.base == Some(last) {
return Some(match entry.format {
Format::Cartesian => format!("@{first},{second}"),
Format::Polar => format!("@{first}<{}", second.to_degrees()),
});
}
let p = match entry.format {
Format::Cartesian => frame.origin() + Vec2::new(first, second),
Format::Polar => frame.origin() + Vec2::from_angle(second) * first,
};
p.is_finite().then(|| format!("#{},{}", p.x, p.y))
}
#[cfg(test)]
mod tests {
use super::*;
fn second_point(base: Vec2) -> Frame {
Frame { base: Some(base), cartesian: false, absolute: false }
}
fn sent(text: &str, frame: &Frame, cursor: Vec2, last: Vec2) -> Option<Vec2> {
let e = parse(text, frame)?;
let t = complete(&e, frame, cursor, last)?;
cadcraft_engine::prompt::parse_point(&t, last)
}
fn close(a: Option<Vec2>, b: Vec2) -> bool {
a.is_some_and(|a| a.dist(b) < 1e-9)
}
#[test]
fn rectangle_corner_with_comma_is_relative() {
// Corner snapped at (0,60); `40,40` Enter -> (40,100).
let base = Vec2::new(0.0, 60.0);
let f = second_point(base);
assert!(close(sent("40,40", &f, Vec2::ZERO, base), Vec2::new(40.0, 100.0)));
}
#[test]
fn tab_types_the_separator_for_the_format_on_show() {
let base = Vec2::new(10.0, 10.0);
let polar = second_point(base);
assert_eq!(tab_separator("40", &polar), Some('<'));
// `40` Tab `90` Enter: 40 units straight up.
assert!(close(sent("40<90", &polar, Vec2::ZERO, base), Vec2::new(10.0, 50.0)));
// Already split: Tab does nothing more.
assert_eq!(tab_separator("40<", &polar), None);
let cart = Frame { cartesian: true, ..polar };
assert_eq!(tab_separator("40", &cart), Some(','));
// First point: always X then Y.
assert_eq!(tab_separator("3", &Frame::default()), Some(','));
// Not a number: Tab falls back to AutoComplete.
assert_eq!(tab_separator("rec", &polar), None);
}
#[test]
fn first_point_is_absolute() {
let f = Frame::default();
assert!(close(sent("0,60", &f, Vec2::new(9.0, 9.0), Vec2::new(5.0, 5.0)), Vec2::new(0.0, 60.0)));
}
#[test]
fn absolute_setting_measures_from_the_origin() {
let f = Frame { base: Some(Vec2::new(5.0, 5.0)), cartesian: true, absolute: true };
assert!(close(sent("40,100", &f, Vec2::ZERO, Vec2::new(5.0, 5.0)), Vec2::new(40.0, 100.0)));
}
#[test]
fn empty_box_takes_the_cursor() {
let base = Vec2::new(1.0, 1.0);
let f = second_point(base);
// X locked at 3 (relative), Y from the cursor.
assert!(close(sent("3,", &f, Vec2::new(-7.0, 9.0), base), Vec2::new(4.0, 9.0)));
// Distance locked at 5, angle from the cursor (straight up).
assert!(close(sent("5<", &f, Vec2::new(1.0, 100.0), base), Vec2::new(1.0, 6.0)));
}
#[test]
fn locked_value_holds_the_cursor() {
let base = Vec2::new(1.0, 1.0);
let f = second_point(base);
let e = parse("5<", &f).unwrap();
assert!(close(Some(constrain(&e, &f, Vec2::new(1.0, 100.0))), Vec2::new(1.0, 6.0)));
let e = parse("3,", &f).unwrap();
assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0)), Vec2::new(4.0, 9.0));
// Still typing the first value: nothing is locked yet.
let e = parse("3", &f).unwrap();
assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0)), Vec2::new(-7.0, 9.0));
}
#[test]
fn plain_numbers_and_other_text_go_through_as_typed() {
let f = second_point(Vec2::ZERO);
// Direct distance entry.
assert_eq!(parse("25", &f).and_then(|e| complete(&e, &f, Vec2::X, Vec2::ZERO)), None);
for t in ["@40,40", "#0,0", "c", "rec", "1,2,3", "40,abc"] {
assert!(parse(t, &f).and_then(|e| complete(&e, &f, Vec2::X, Vec2::ZERO)).is_none(), "{t}");
}
}
#[test]
fn relative_entries_use_at_only_when_the_base_is_the_last_point() {
let base = Vec2::new(0.0, 60.0);
let f = second_point(base);
let e = parse("40,40", &f).unwrap();
assert_eq!(complete(&e, &f, Vec2::ZERO, base).as_deref(), Some("@40,40"));
// A prompt whose base is not the last point: send the finished point itself.
let other_last = Vec2::new(500.0, 500.0);
let t = complete(&e, &f, Vec2::ZERO, other_last).unwrap();
assert!(t.starts_with('#'), "{t}");
assert!(close(cadcraft_engine::prompt::parse_point(&t, other_last), Vec2::new(40.0, 100.0)));
// Polar too.
let e = parse("40<90", &f).unwrap();
let t = complete(&e, &f, Vec2::ZERO, other_last).unwrap();
assert!(close(cadcraft_engine::prompt::parse_point(&t, other_last), Vec2::new(0.0, 100.0)));
}
#[test]
fn empty_text_is_an_empty_entry() {
let f = second_point(Vec2::ZERO);
let e = parse("", &f).unwrap();
assert_eq!((e.format, e.split), (Format::Polar, false));
}
}

View File

@ -15,6 +15,7 @@ pub mod cmdline;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod dyninput;
pub mod gpu;
pub mod icons;
pub mod layers;