Browse / github.com/storytold/cadcraft / patch
Dynamic Input: type a command beside the cursor and pick it from the AutoComplete list (#2127)
Dynmode Dynmode doesnt do what it's supposed to do, respectively move the command input text box from the one on the bottom to a floating one next to your cursor. Also when you write a command, you have to press Tab to confirm, instead of just pressing enter on the first suggestion. <img width="403" height="283" alt="Image" src="https://github.com/user-attachments/assets/279afa26-403f-402d-905e-75ae4235ec23" /> This is what it should look like <img width="471" height="458" alt="Image" src="https://github.com/user-attachments/assets/1288aa67-cf31-4ac4-bc53-f336c1b14c20" /> This is the dynamic inp
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- thealexspike (imported from pull request #2130, open)
- Base
63a5569970afd4dc037cf7c0f44556c90ef3a73a- Size
- +207 −12 in 3 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d3ef2b6daa9c62989e140b7b
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/canvas.rs crates/ui-egui/src/cmdline.rs docs/ui-parity.md
@@ -91,6 +91,10 @@ pub struct CanvasState { pub snap: Option<SnapHit>, /// Effective cursor point after snaps/ortho/polar. pub cursor: Option<Vec2>,+ /// Where the crosshair stays while a command name is typed beside it (Dynamic Input).+ pub typing_at: Option<Pos2>,+ /// The AutoComplete rows beside the cursor as drawn last frame (screen, command id), for clicks.+ pub tip_rows: Vec<(Rect, String)>, /// The polar or object snap tracking path(s) the cursor is on. pub track: Option<snap::tracking::Track>, pan_last: Option<Pos2>,@@ -1633,6 +1637,21 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { None => app.cmd.options = None, } }+ // A click on an AutoComplete row beside the cursor starts that command; rows left from a+ // frame before a command started (from a dialog, say) no longer count, and a dialog, palette+ // or selection cycling row over the list keeps its click.+ if pressed_primary+ && !picked+ && inside+ && !on_menu+ && command_tip_text(app).is_some()+ && let Some(id) = hover_pos.and_then(|h| app.canvas.tip_rows.iter().find(|(r, _)| r.contains(h)).map(|(_, id)| id.clone()))+ {+ app.cmd.buffer.clear();+ app.canvas.tip_rows.clear();+ app.start(&id.to_ascii_lowercase());+ picked = true;+ } // A double-click with no command running (on a layout viewport, say) leaves no selection // window behind; while a command asks for points, every click still counts (#815). let idle_dbl = dbl_primary && app.session.running.is_none();@@ -2026,11 +2045,20 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if app.ui.show_command_line { crate::cmdline::show(app, ui, full); }+ // Typing a command name beside the cursor: the crosshair and the text stay where typing+ // started and the pointer moves on its own, until the command starts or is cancelled.+ let typing_at = typing_anchor(app, hover_pos.filter(|_| inside)); // Crosshair cursor. if inside && !on_menu { // Hide the OS cursor under the drawn crosshair, unless assistive tech needs it (#39).- ui.ctx().set_cursor_icon(if app.ui.system_cursor { egui::CursorIcon::Crosshair } else { egui::CursorIcon::None });- if let Some(hp) = hover_pos {+ ui.ctx().set_cursor_icon(if typing_at.is_some() {+ egui::CursorIcon::Default+ } else if app.ui.system_cursor {+ egui::CursorIcon::Crosshair+ } else {+ egui::CursorIcon::None+ });+ if let Some(hp) = typing_at.or(hover_pos) { // Use the painted sheet even in MSPACE, where the editing transform follows a // model viewport. The whole cursor takes one colour from its centre. let ink = paper_rect.map_or(t.crosshair, |paper| crate::theme::crosshair_color(if paper.contains(hp) { t.paper } else { surround }));@@ -2349,7 +2377,11 @@ fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2, fields: bool) hover } };- let Some(prompt) = app.session.current_prompt() else { return };+ let Some(prompt) = app.session.current_prompt() else {+ // Held where typing started, so the free pointer can reach the list.+ command_tip(app, p, app.canvas.typing_at.unwrap_or(at), hover);+ return;+ }; // DYNPROMPT = 0: no prompt beside the cursor (the value boxes stay). let show_prompt = app.session.settings.dynprompt; let options = show_prompt && !prompt.keywords.is_empty();@@ -2392,6 +2424,91 @@ fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2, fields: bool) options_menu(app, p, r, hover); } +/// What Dynamic Input shows beside the cursor with no command running: the typed command name+/// and its AutoComplete matches; `None` with nothing typed, DYNMODE or DYNPROMPT off (#2127).+fn command_tip_text(app: &CadApp) -> Option<(String, Vec<crate::cmdline::Suggestion>)> {+ let st = &app.session.settings;+ if !st.dynmode || !st.dynprompt || app.session.prompting() || app.cmd.buffer.is_empty() {+ return None;+ }+ Some((app.cmd.buffer.clone(), crate::cmdline::suggestions(&app.cmd.buffer)))+}++/// The typed command name shows beside the cursor (over the drawing), and so not on the command+/// line, as in AutoCAD.+pub(crate) fn command_tip_shown(app: &CadApp) -> bool {+ app.canvas.cursor.is_some() && command_tip_text(app).is_some()+}++/// Where the crosshair stays while a command name is typed over the drawing: the pointer's+/// position when typing started (`hover`), held until the text is cleared or a command starts.+fn typing_anchor(app: &mut CadApp, hover: Option<Pos2>) -> Option<Pos2> {+ if hover.is_none() || command_tip_text(app).is_none() {+ app.canvas.typing_at = None;+ app.canvas.tip_rows.clear();+ } else if app.canvas.typing_at.is_none() {+ app.canvas.typing_at = hover;+ }+ app.canvas.typing_at+}++/// The typed command name beside the cursor, the AutoComplete list under it: the first match,+/// which Tab completes, marked like the command line's list; the row under the pointer (`hover`)+/// highlighted, and the rows kept for clicks.+fn command_tip(app: &mut CadApp, p: &egui::Painter, at: Pos2, hover: Pos2) {+ app.canvas.tip_rows.clear();+ let Some((typed, sug)) = command_tip_text(app) else { return };+ let t = Tokens::get();+ let font = crate::theme::small();+ let galley = p.layout_no_wrap(typed, font.clone(), t.text);+ let tip = galley.size() + vec2(12.0, 8.0);+ let rows: Vec<_> = sug.iter().map(|s| p.layout_no_wrap(s.display.clone(), font.clone(), t.text)).collect();+ let row_h = rows.iter().map(|g| g.size().y).fold(12.0, f32::max) + 6.0;+ let w = rows.iter().map(|g| g.size().x).fold(tip.x, f32::max) + 16.0;+ let list_h = if rows.is_empty() { 0.0 } else { row_h * rows.len() as f32 + 6.0 };+ // Tip and list together stay inside the drawing, so what is typed is never clipped away.+ let area = p.clip_rect();+ let r = Rect::from_min_size(fit_tip(at, vec2(w.max(tip.x), tip.y + list_h), tip.y, area), tip);+ 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);+ let caret = r.min.x + 6.0 + galley.size().x + 1.0;+ p.galley(r.min + vec2(6.0, 4.0), galley, t.text);+ p.line_segment([pos2(caret, r.top() + 4.0), pos2(caret, r.bottom() - 4.0)], Stroke::new(1.0, t.text));+ if rows.is_empty() {+ return;+ }+ let m = Rect::from_min_size(pos2(r.left(), r.bottom() + 2.0), vec2(w, list_h - 2.0));+ p.rect_filled(m, 3.0, t.cmd_bg);+ p.rect_stroke(m, 3.0, Stroke::new(1.0, t.cmd_border), egui::StrokeKind::Inside);+ for (i, (g, s)) in rows.into_iter().zip(&sug).enumerate() {+ let row = Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h));+ if row.contains(hover) {+ p.rect_filled(row, 2.0, t.cmd_keyword_hover);+ } else if i == 0 {+ p.rect_filled(row, 2.0, t.list_row);+ }+ p.galley(pos2(row.left() + 6.0, row.center().y - g.size().y / 2.0), g, t.text);+ // Only the visible part of a row takes clicks.+ let seen = row.intersect(area);+ if seen.is_positive() {+ app.canvas.tip_rows.push((seen, s.id.clone()));+ }+ }+}++/// The top-left corner of the command tip with its list (`size`, the tip `tip_h` tall on top),+/// placed like the other tooltips (right of and above the cursor `at`) and moved to stay inside+/// `area`: to the cursor's left near the right edge, down or up near the top and bottom.+fn fit_tip(at: Pos2, size: egui::Vec2, tip_h: f32, area: Rect) -> Pos2 {+ let mut x = at.x + 18.0;+ if x + size.x > area.right() {+ x = at.x - 18.0 - size.x;+ }+ let x = x.min(area.right() - size.x).max(area.left());+ let y = (at.y - tip_h - 14.0).min(area.bottom() - size.y).max(area.top());+ pos2(x, y)+}+ /// The drawing's length units: (LUNITS, LUPREC). pub(crate) fn length_units(session: &cadcraft_engine::Session) -> (i64, i64) { session.doc().map(|d| (d.header.i64("LUNITS", 2), d.header.i64("LUPREC", 4))).unwrap_or((2, 4))@@ -2719,6 +2836,74 @@ pub(crate) mod tests { v.iter().map(|&h| Handle(h)).collect() } + /// A click on an AutoComplete row beside the cursor starts that command and places no point+ /// (#2127).+ #[test]+ fn a_click_on_a_suggestion_beside_the_cursor_starts_it() {+ let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());+ let ctx = egui::Context::default();+ let frame = |app: &mut CadApp, events: Vec<egui::Event>| {+ let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() };+ let mut out = ctx.run_ui(raw, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ });+ out.textures_delta.clear();+ };+ frame(&mut app, Vec::new());+ let center = app.canvas.rect.map_or(pos2(700.0, 450.0), |r| r.center());+ frame(&mut app, vec![egui::Event::PointerMoved(center)]);+ frame(&mut app, vec![egui::Event::Text("l".into())]);+ frame(&mut app, Vec::new());+ let (row, id) = app.canvas.tip_rows.first().cloned().expect("the list is drawn beside the cursor");+ assert_eq!(id, "LINE");+ // The pointer moves to the row; the crosshair stays where typing started.+ let at = row.center();+ frame(&mut app, vec![egui::Event::PointerMoved(at)]);+ assert_eq!(app.canvas.typing_at, Some(center));+ let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };+ frame(&mut app, vec![click(true)]);+ frame(&mut app, vec![click(false)]);+ assert!(app.session.running.is_some(), "LINE started");+ assert!(app.cmd.buffer.is_empty());+ assert!(app.session.doc().unwrap().model.iter().next().is_none(), "the click placed no point");+ }++ /// With no command running, Dynamic Input shows the typed command name and its AutoComplete+ /// matches beside the cursor; nothing when DYNMODE or DYNPROMPT is off (#2127).+ #[test]+ fn typed_command_shows_beside_the_cursor() {+ let mut app = CadApp::new(cadcraft_engine::Session::empty(), crate::Services::default());+ assert!(command_tip_text(&app).is_none());+ app.cmd.buffer = "l".into();+ let (typed, sug) = command_tip_text(&app).unwrap();+ assert_eq!(typed, "l");+ assert_eq!(sug[0].id, "LINE");+ // Only over the drawing does it move off the command line.+ assert!(!command_tip_shown(&app));+ app.canvas.cursor = Some(Vec2::new(0.0, 0.0));+ assert!(command_tip_shown(&app));+ // The crosshair stays where typing started while the pointer moves.+ assert_eq!(typing_anchor(&mut app, Some(pos2(10.0, 20.0))), Some(pos2(10.0, 20.0)));+ assert_eq!(typing_anchor(&mut app, Some(pos2(300.0, 200.0))), Some(pos2(10.0, 20.0)));+ app.cmd.buffer.clear();+ assert_eq!(typing_anchor(&mut app, Some(pos2(300.0, 200.0))), None);+ app.cmd.buffer = "l".into();+ // The tip and its list stay inside the drawing near its edges.+ let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(800.0, 600.0));+ let size = vec2(120.0, 100.0);+ assert_eq!(fit_tip(pos2(400.0, 300.0), size, 20.0, area), pos2(418.0, 266.0));+ assert_eq!(fit_tip(pos2(780.0, 300.0), size, 20.0, area), pos2(642.0, 266.0));+ assert_eq!(fit_tip(pos2(400.0, 5.0), size, 20.0, area), pos2(418.0, 0.0));+ assert_eq!(fit_tip(pos2(400.0, 590.0), size, 20.0, area), pos2(418.0, 500.0));+ // DYNPROMPT off: the typed text stays on the command line.+ app.session.settings.dynprompt = false;+ assert!(command_tip_text(&app).is_none());+ app.session.settings.dynprompt = true;+ app.session.settings.dynmode = false;+ assert!(command_tip_text(&app).is_none());+ }+ /// A dragged object keeps its transparency in the GPU preview when TRANSPARENCYDISPLAY is /// on, and is opaque when it is off. #[test]@@ -119,7 +119,7 @@ pub struct Suggestion { } /// Matching command names/aliases for AutoComplete.-fn suggestions(prefix: &str) -> Vec<Suggestion> {+pub(crate) fn suggestions(prefix: &str) -> Vec<Suggestion> { if prefix.is_empty() || prefix.contains(' ') { return Vec::new(); }@@ -454,6 +454,8 @@ fn visible_lines(app: &CadApp, canvas: Rect, bar: Rect) -> usize { } /// Draw the floating command bar and its history over the bottom-centre of `canvas`; the bar is 48 % of the canvas width, clamped to 360–760 points and never wider than the canvas minus 8 points.+/// It holds the prompt with its keywords, the typed text with its caret and the AutoComplete list;+/// the typed text and its list stay off the bar while Dynamic Input shows them beside the cursor (#2127). pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { let t = Tokens::get(); let w = (canvas.width() * 0.48).clamp(360.0, 760.0).min((canvas.width() - 8.0).max(0.0));@@ -551,7 +553,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } } }- let g = p.layout_no_wrap(app.cmd.buffer.clone(), font.clone(), t.text);+ // Typed beside the cursor (Dynamic Input), the command line stays empty.+ let at_cursor = crate::canvas::command_tip_shown(app);+ let g = p.layout_no_wrap(if at_cursor { String::new() } else { app.cmd.buffer.clone() }, font.clone(), t.text); let gw = g.size().x; p.galley(pos2(x, bar.center().y - g.size().y / 2.0), g, t.text); let mut caret_x = x + gw;@@ -565,7 +569,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { } // Caret. let blink = (ui.input(|i| i.time) * 2.0).floor() as i64 % 2 == 0;- if blink {+ if blink && !at_cursor { p.line_segment([pos2(caret_x + 1.0, bar.top() + 5.0), pos2(caret_x + 1.0, bar.bottom() - 5.0)], Stroke::new(1.0, t.text)); } // Where the IME's composition window follows: the whole command line and the caret in it.@@ -579,7 +583,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui, canvas: Rect) { false, ); // AutoComplete list.- if !app.session.prompting() {+ if !app.session.prompting() && !at_cursor { let sug = suggestions(&app.cmd.buffer); if !sug.is_empty() { let rh = 20.0;@@ -628,6 +632,8 @@ struct Zones { all: Rect, } +/// Where the command line's parts sit around the bar `bar`; the AutoComplete list counts in `all`+/// only while it shows on the command line, not beside the cursor (#2127). fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { let n = if app.cmd.expanded { 0 } else { visible_lines(app, canvas, bar).min(app.session.log.len()) }; let lines =@@ -641,7 +647,7 @@ fn zones(app: &CadApp, canvas: Rect, bar: Rect) -> Zones { let top = window.or(lines).map_or(bar.top(), |r| r.top()); let grip = Rect::from_min_max(pos2(bar.left(), top - 3.0), pos2(bar.right(), top + 3.0)); let mut all = bar.union(grip);- if !app.session.prompting() {+ if !app.session.prompting() && !crate::canvas::command_tip_shown(app) { let k = suggestions(&app.cmd.buffer).len(); if k > 0 { let above = if n == 0 { 0.0 } else { LINE_H * n as f32 + 6.0 };@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (typed command names beside the cursor) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -103,9 +103,12 @@ CADCraft has pointer input boxes since #60 (`crates/ui-egui/src/dyninput.rs`): v beside the cursor while a command asks for a point; second and later points default to polar distance and angle (DYNPIFORMAT = 1 for Cartesian); `,` and `<` lock a value and move to the next box; Tab types the separator; Enter sends a relative entry (DYNPICOORDS = 1 for absolute) and-fills an empty box from the cursor; a plain number stays direct distance entry. Missing:-dimensional input on grips (length/angle fields while stretching), lock icons, the prompt with a-down-arrow options menu (DYNPROMPT), and DYNDIM.+fills an empty box from the cursor; a plain number stays direct distance entry. With DYNPROMPT+on, the prompt and its down-arrow options menu show beside the cursor, and so does a command name+typed with no command running, with its AutoComplete list (#2127): the crosshair holds where+typing started, the free pointer can pick a row, and the command line stays empty. Missing:+dimensional input on grips (length/angle fields while stretching), lock icons, a Dynamic Input+tab in Drafting Settings (its checkboxes sit under Object Snap), and DYNDIM. ## Grips (≈ 40%) @@ -209,6 +212,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | 2026-10-14 | minor | Hatch Edit dialog adds boundaries from picked points or closed objects and removes selected loops | | 2026-10-14 | minor | Holding Shift overrides ortho while it is held (on when ORTHOMODE is off, off when it is on), as in AutoCAD | | 2026-10-16 | minor | ALIASEDIT opens the Command Aliases dialog (#1381) |+| 2026-10-25 | minor | Dynamic input (DYNMODE and DYNPROMPT on): a command name typed over the drawing shows beside the cursor with its AutoComplete list, the crosshair holds and a row can be clicked (#2127) | | 2026-10-18 | minor | In-window menu bar: once a menu is open, pointing at another title closes it and opens that one (#1567) | | 2026-10-13 | minor | Options ▸ Selection adds Select using two clicks only; PICKDRAG 0 disables drag-window creation/completion, 2 preserves clicks or drag, and the preference survives restart; UI pointer and profile regressions | | 2026-10-12 | minor | Selected and hovered objects redrawn in the selection colour (text and arrowheads included); Options ▸ Selection laid out as AutoCAD's: Object grouping, Show grips, grip object limit, selection preview, Visual Effect Settings (selection area, preview filter) |