diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21b3a4691..847f036b7 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -330,6 +330,9 @@ pub struct Settings { /// 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, + /// DYNPROMPT: Dynamic Input shows the prompt beside the cursor, and Down Arrow opens the + /// prompt's options there (on by default). + pub dynprompt: bool, pub lwdisplay: bool, pub transparency_display: bool, pub selection_cycling: bool, @@ -386,6 +389,7 @@ impl Default for Settings { dynmode: true, dynpi_cartesian: false, dynpi_absolute: false, + dynprompt: true, lwdisplay: false, transparency_display: false, selection_cycling: false, diff --git a/crates/engine/src/snap.rs b/crates/engine/src/snap.rs index 1d1134b44..54ce34aa8 100644 --- a/crates/engine/src/snap.rs +++ b/crates/engine/src/snap.rs @@ -224,6 +224,12 @@ pub(crate) fn gather( cands.push((mode::INS, i.insert.xy())); cands.extend(i.attribs.iter().filter(|a| !a.invisible).map(|a| (mode::INS, a.text.justify_point()))); } + EntityKind::AttDef(a) => cands.push((mode::INS, a.text.justify_point())), + // A multileader: its text's insertion point, or its block content's. + EntityKind::MLeader(m) => { + cands.extend(m.text.iter().map(|t| (mode::INS, t.insert.xy()))); + cands.extend(m.block.iter().map(|b| (mode::INS, b.insert.xy()))); + } _ => {} } } @@ -436,11 +442,15 @@ impl Sink<'_> { } self.near.push(p); } - if osmode & mode::GCEN != 0 - && let EntityKind::LwPolyline(pl) = kind - && pl.closed - { - let pts = cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0); + if osmode & mode::GCEN != 0 { + // The centroid of the area a closed polyline or spline encloses. + let pts = match kind { + EntityKind::LwPolyline(pl) if pl.closed => { + cadcraft_geom::Polyline { vertices: pl.vertices.clone(), closed: true }.tessellate(self.aperture / 10.0) + } + EntityKind::Spline(s) if s.closed => s.tessellate(1e-3), + _ => Vec::new(), + }; if let Some(c) = centroid(&pts) { self.cands.push((mode::GCEN, at(c))); } diff --git a/crates/engine/src/sysvars.rs b/crates/engine/src/sysvars.rs index 3a31c8839..ad815a268 100644 --- a/crates/engine/src/sysvars.rs +++ b/crates/engine/src/sysvars.rs @@ -27,6 +27,7 @@ const SESSION_VARS: &[&str] = &[ "DYNMODE", "DYNPIFORMAT", "DYNPICOORDS", + "DYNPROMPT", "LWDISPLAY", "SELECTSIMILARMODE", "OBJECTISOLATIONMODE", @@ -73,6 +74,7 @@ pub fn get(s: &Session, name: &str) -> Option { "DYNMODE" => json!(i32::from(st.dynmode)), "DYNPIFORMAT" => json!(i32::from(st.dynpi_cartesian)), "DYNPICOORDS" => json!(i32::from(st.dynpi_absolute)), + "DYNPROMPT" => json!(i32::from(st.dynprompt)), "LWDISPLAY" => json!(i32::from(st.lwdisplay)), "SELECTSIMILARMODE" => json!(st.selectsimilarmode), // Isolation is temporary: hidden objects show again when the drawing is reopened. @@ -210,6 +212,7 @@ pub fn set(s: &mut Session, name: &str, v: &Value) -> Result<()> { "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)?, + "DYNPROMPT" => st.dynprompt = as_bool(v).ok_or_else(bad)?, "LWDISPLAY" => st.lwdisplay = as_bool(v).ok_or_else(bad)?, "SELECTSIMILARMODE" => st.selectsimilarmode = as_i(v).filter(|b| (0..=255).contains(b)).ok_or_else(bad)? as u32, "OBJECTISOLATIONMODE" => match as_i(v) { diff --git a/crates/engine/tests/dyn_input.rs b/crates/engine/tests/dyn_input.rs new file mode 100644 index 000000000..1e3f9c988 --- /dev/null +++ b/crates/engine/tests/dyn_input.rs @@ -0,0 +1,40 @@ +//! Dynamic Input (#519): DYNPROMPT and the options a prompt offers at the cursor. + +use cadcraft_engine::{Input, Session, sysvars}; +use serde_json::json; + +#[test] +fn dynprompt_is_a_setting_on_by_default() { + let mut s = Session::new(); + assert!(s.settings.dynprompt); + assert_eq!(sysvars::get(&s, "DYNPROMPT"), Some(json!(1))); + s.execute("setvar", &json!({"name": "dynprompt", "value": 0})).unwrap(); + assert!(!s.settings.dynprompt); + assert_eq!(sysvars::get(&s, "dynprompt"), Some(json!(0))); + s.cmdline("setvar dynprompt 1").unwrap(); + assert!(s.settings.dynprompt); + assert!(s.execute("setvar", &json!({"name": "DYNPROMPT", "value": "x"})).is_err()); + assert!(s.settings.dynprompt); +} + +#[test] +fn an_option_picked_at_the_cursor_answers_the_prompt() { + // The options menu lists the prompt's keywords and sends the picked one, as a click on it + // at the command line does. + let mut s = Session::new(); + s.cmdline("circle").unwrap(); + let kws = s.current_prompt().unwrap().keywords; + let three_p = kws.iter().find(|k| k.eq_ignore_ascii_case("3P")).cloned().expect("CIRCLE offers 3P"); + s.input(Input::Keyword(three_p)).unwrap(); + for p in [[0.0, 0.0], [10.0, 0.0], [5.0, 5.0]] { + s.input(Input::Point(cadcraft_geom::Vec2::new(p[0], p[1]))).unwrap(); + } + assert!(s.running.is_none()); + let doc = s.doc().unwrap(); + let circle = doc.model.iter().find_map(|e| match &e.kind { + cadcraft_doc::EntityKind::Circle(c) => Some(c.clone()), + _ => None, + }); + let c = circle.expect("a three-point circle"); + assert!((c.center.x - 5.0).abs() < 1e-9 && c.center.y.abs() < 1e-9 && (c.radius - 5.0).abs() < 1e-9, "{c:?}"); +} diff --git a/crates/engine/tests/snaps_more.rs b/crates/engine/tests/snaps_more.rs new file mode 100644 index 000000000..44d727e99 --- /dev/null +++ b/crates/engine/tests/snaps_more.rs @@ -0,0 +1,67 @@ +//! The remaining object snaps (#519, gaps.md U8): Insertion on attribute definitions and +//! multileaders, Geometric Center on closed splines, and no Midpoint on closed curves. + +use cadcraft_doc::{EntityKind, Space}; +use cadcraft_engine::Session; +use cadcraft_engine::snap::{self, mode}; +use cadcraft_geom::Vec2; +use serde_json::json; + +/// The snap of `modes` near `p` (a hair off it, inside a 0.1 aperture). +fn snap_at(s: &Session, p: Vec2, modes: u32) -> Option<(Vec2, u32)> { + snap::osnap(s.doc().unwrap(), &Space::Model, p + Vec2::new(0.02, -0.01), 0.1, modes, None, false).map(|h| (h.point, h.mode)) +} + +fn near(a: Option<(Vec2, u32)>, p: Vec2, m: u32) -> bool { + a.is_some_and(|(q, k)| k == m && q.dist(p) < 1e-6) +} + +#[test] +fn insertion_snaps_to_attribute_definitions() { + let mut s = Session::new(); + s.execute("attdef", &json!({"tag": "PART", "default": "A-1", "at": [3, 4]})).unwrap(); + assert!(near(snap_at(&s, Vec2::new(3.0, 4.0), mode::INS), Vec2::new(3.0, 4.0), mode::INS)); +} + +#[test] +fn insertion_snaps_to_a_multileader_text() { + let mut s = Session::new(); + s.execute("mleader", &json!({"points": [[0, 0], [10, 5]], "text": "NOTE"})).unwrap(); + let at = s + .doc() + .unwrap() + .model + .iter() + .find_map(|e| match &e.kind { + EntityKind::MLeader(m) => m.text.as_ref().map(|t| t.insert.xy()), + _ => None, + }) + .expect("a multileader with text"); + assert!(near(snap_at(&s, at, mode::INS), at, mode::INS)); +} + +#[test] +fn geometric_center_of_a_closed_spline() { + let mut s = Session::new(); + // A closed fit spline symmetric about (5,5): its centroid is the centre. + s.execute("spline", &json!({"fit": [[0, 5], [5, 0], [10, 5], [5, 10]], "closed": true})).unwrap(); + let c = Vec2::new(5.0, 5.0); + let hit = snap::osnap(s.doc().unwrap(), &Space::Model, c + Vec2::new(0.05, 0.0), 0.5, mode::GCEN, None, false).expect("a geometric center"); + assert_eq!(hit.mode, mode::GCEN); + assert!(hit.point.dist(c) < 1e-3, "{:?}", hit.point); + // An open spline has none. + let mut s = Session::new(); + s.execute("spline", &json!({"fit": [[0, 5], [5, 0], [10, 5], [5, 10]]})).unwrap(); + assert!(snap::osnap(s.doc().unwrap(), &Space::Model, c, 0.5, mode::GCEN, None, false).is_none()); +} + +#[test] +fn closed_curves_have_no_midpoint() { + // As in AutoCAD: Midpoint works on arcs and elliptical arcs, not on a full circle or ellipse. + let mut s = Session::new(); + s.cmdline("circle 0,0 5").unwrap(); + s.cmdline("ellipse 20,0 30,0 3").unwrap(); + for p in [Vec2::new(-5.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(30.0, 0.0), Vec2::new(20.0, 3.0)] { + assert_eq!(snap_at(&s, p, mode::MID), None, "{p:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8fdc391f1..e1931ea76 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -688,7 +688,7 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { && let Some(e) = crate::dyninput::parse(&app.cmd.buffer, &f) && e.first_locked().is_some() { - eff = crate::dyninput::constrain(&e, &f, eff); + eff = crate::dyninput::constrain(&e, &f, eff, app.cmd.second_locked()); app.canvas.snap = None; app.canvas.track = None; } @@ -705,6 +705,18 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { app.session.cursor_deferred = None; } + // A click on the Dynamic Input options menu picks the keyword; elsewhere it closes the menu. + let mut picked = false; + if pressed_primary && app.cmd.options.is_some() { + match hover_pos.and_then(|h| app.cmd.option_at(h)) { + Some(k) => { + crate::cmdline::pick_option(app, k); + picked = true; + } + None => app.cmd.options = None, + } + } + let pressed_primary = pressed_primary && !picked; // Clicks. if inside && pressed_primary @@ -1101,13 +1113,30 @@ 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) { +fn dynamic_input(app: &mut CadApp, p: &egui::Painter, hover: Pos2) { + app.cmd.option_rows.clear(); + // While the options menu is open the tooltip stays where it opened, so the menu can be reached. + let at = match app.cmd.options { + Some(_) => *app.cmd.options_anchor.get_or_insert(hover), + None => { + app.cmd.options_anchor = None; + hover + } + }; let Some(prompt) = app.session.current_prompt() else { 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(); 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, crate::i18n::t(&prompt.message), &f, &e, c); + let message = if show_prompt { crate::i18n::t(&prompt.message) } else { "" }; + let r = dynamic_input_boxes(p, at, message, &f, &e, c, app.cmd.second_locked(), options); + options_menu(app, p, r, hover); + return; + } + if !show_prompt { return; } let mut text = crate::i18n::t(&prompt.message).to_string(); @@ -1130,8 +1159,54 @@ fn dynamic_input(app: &CadApp, p: &egui::Painter, at: Pos2) { } 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)); + let hint = if options { OPTIONS_HINT_W } else { 0.0 }; + let r = tooltip_frame(p, at, galley.size() + vec2(12.0 + hint, 8.0)); p.galley(r.min + vec2(6.0, 4.0), galley, t.text); + if options { + options_hint(p, r); + } + options_menu(app, p, r, hover); +} + +/// Room for the down-arrow sign that a prompt has options. +const OPTIONS_HINT_W: f32 = 14.0; + +/// The down-arrow sign at the right end of the tooltip `r`: the prompt has options, and Down +/// Arrow lists them. +fn options_hint(p: &egui::Painter, r: Rect) { + let t = Tokens::get(); + let c = pos2(r.right() - OPTIONS_HINT_W / 2.0 - 2.0, r.center().y + 1.0); + p.add(Shape::convex_polygon(vec![pos2(c.x - 4.0, c.y - 2.5), pos2(c.x + 4.0, c.y - 2.5), pos2(c.x, c.y + 2.5)], t.text_dim, Stroke::NONE)); +} + +/// The options menu under the tooltip `r` (DYNPROMPT): one row per keyword, the highlighted one +/// marked; the rows are kept for clicks. +fn options_menu(app: &mut CadApp, p: &egui::Painter, r: Rect, hover: Pos2) { + let Some(mut menu) = app.cmd.options.clone() else { return }; + let t = Tokens::get(); + let font = crate::theme::small(); + let rows: Vec<_> = menu.keywords.iter().map(|k| p.layout_no_wrap(k.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(60.0, f32::max) + 28.0; + let h = row_h * rows.len() as f32 + 4.0; + let m = Rect::from_min_size(pos2(r.left(), r.bottom() + 2.0), vec2(w, h)); + 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); + let row_at = |i: usize| Rect::from_min_size(pos2(m.left() + 2.0, m.top() + 2.0 + row_h * i as f32), vec2(w - 4.0, row_h)); + // The row under the pointer is the highlighted one. + if let Some(i) = (0..menu.keywords.len()).find(|i| row_at(*i).contains(hover)) { + menu.index = i; + app.cmd.options = Some(menu.clone()); + } + for (i, (g, k)) in rows.into_iter().zip(&menu.keywords).enumerate() { + let row = row_at(i); + if i == menu.index { + p.rect_filled(row, 2.0, t.cmd_keyword_hover); + p.circle_filled(pos2(row.left() + 9.0, row.center().y), 2.5, t.accent); + } + p.galley(pos2(row.left() + 20.0, row.center().y - g.size().y / 2.0), g, t.text); + app.cmd.option_rows.push((row, k.clone())); + } } /// The tooltip background, matching the command line bar; returns its rectangle. @@ -1147,7 +1222,17 @@ fn tooltip_frame(p: &egui::Painter, at: Pos2, size: egui::Vec2) -> Rect { /// 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) { +#[allow(clippy::too_many_arguments)] +fn dynamic_input_boxes( + p: &egui::Painter, + at: Pos2, + message: &str, + f: &crate::dyninput::Frame, + e: &crate::dyninput::Entry<'_>, + cursor: Vec2, + second_locked: bool, + options: bool, +) -> Rect { use crate::dyninput::Format; let t = Tokens::get(); let font = crate::theme::small(); @@ -1170,7 +1255,13 @@ fn dynamic_input_boxes(p: &egui::Painter, at: Pos2, message: &str, f: &crate::dy 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), + ( + label_second, + if e.second.is_empty() { live_second } else { e.second.to_owned() }, + !e.second.is_empty(), + second_locked, + e.split && !second_locked, + ), ]; let msg = p.layout_no_wrap(message.to_owned(), font.clone(), t.text_dim); @@ -1194,7 +1285,11 @@ fn dynamic_input_boxes(p: &egui::Painter, at: Pos2, message: &str, f: &crate::dy 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 hint = if options { OPTIONS_HINT_W } else { 0.0 }; + let r = tooltip_frame(p, at, vec2(w + pad + hint, box_h + 2.0 * pad - 2.0)); + if options { + options_hint(p, r); + } let cy = r.center().y; let mut x = r.left() + pad; let (msg_w, msg_h) = (msg.size().x, msg.size().y); @@ -1223,6 +1318,7 @@ fn dynamic_input_boxes(p: &egui::Painter, at: Pos2, message: &str, f: &crate::dy } x += bw; } + r } /// A small padlock, drawn in code: body plus shackle. diff --git a/crates/ui-egui/src/cmdline.rs b/crates/ui-egui/src/cmdline.rs index 0ad42187d..ce371bb25 100644 --- a/crates/ui-egui/src/cmdline.rs +++ b/crates/ui-egui/src/cmdline.rs @@ -23,6 +23,14 @@ pub struct CmdLine { pub expanded: bool, /// Dragging the history's top edge: (pointer y, line count) when the drag started. resize_from: Option<(f32, usize)>, + /// The Dynamic Input options menu at the cursor (Down Arrow at a prompt with keywords). + pub options: Option, + /// The text whose second Dynamic Input value Tab locked; the lock holds while it is unchanged. + locked_second: Option, + /// Where the options menu's rows were drawn last frame (screen), for clicks on them. + pub option_rows: Vec<(Rect, String)>, + /// Where the Dynamic Input tooltip stays while the options menu is open. + pub options_anchor: Option, } /// The most history lines the command line shows above its bar. @@ -73,6 +81,11 @@ fn tab(app: &mut CadApp) { && let Some(sep) = crate::dyninput::tab_separator(&app.cmd.buffer, &f) { app.cmd.buffer.push(sep); + } else if let Some(f) = dyn_frame(app) + && crate::dyninput::can_lock_second(&app.cmd.buffer, &f) + { + // Both values typed: Tab locks the second one as well. + app.cmd.locked_second = Some(app.cmd.buffer.clone()); } else if let Some((id, _)) = suggestions(&app.cmd.buffer).first() { app.cmd.buffer = id.clone(); } @@ -109,6 +122,53 @@ impl CmdLine { self.typed_since_selection = true; } } + + /// Tab locked the second Dynamic Input value and nothing was typed since. + pub fn second_locked(&self) -> bool { + self.locked_second.as_deref() == Some(self.buffer.as_str()) + } + + /// The options menu row under `at` (screen), if the menu is open. + pub fn option_at(&self, at: Pos2) -> Option { + self.options.as_ref()?; + self.option_rows.iter().find(|(r, _)| r.contains(at)).map(|(_, k)| k.clone()) + } +} + +/// Keep the options menu only while its prompt is on screen with Dynamic Input and DYNPROMPT on. +fn options_still_fit(app: &mut CadApp) { + let st = &app.session.settings; + let on = st.dynmode && st.dynprompt && app.canvas.hot_grips.is_empty(); + let kws = app.session.current_prompt().map(|p| p.keywords).unwrap_or_default(); + if app.cmd.options.as_ref().is_some_and(|m| !on || !m.fits(&kws)) { + app.cmd.options = None; + } +} + +/// Down Arrow at a prompt with keywords (Dynamic Input on, DYNPROMPT on, not browsing the +/// history): open the options menu at the cursor, or move its highlight. `false` when the key is +/// left for the history. +fn options_key(app: &mut CadApp, step: isize) -> bool { + if let Some(m) = app.cmd.options.as_mut() { + m.step(step); + return true; + } + let st = &app.session.settings; + if step < 0 || !st.dynmode || !st.dynprompt || app.cmd.history_pos.is_some() || !app.canvas.hot_grips.is_empty() { + return false; + } + let kws = app.session.current_prompt().map(|p| p.keywords).unwrap_or_default(); + app.cmd.options = crate::dyninput::OptionsMenu::open(&kws); + app.cmd.options.is_some() +} + +/// Send a keyword picked from the options menu, as a click on it at the command line does. +pub fn pick_option(app: &mut CadApp, keyword: String) { + app.cmd.options = None; + app.cmd.buffer.clear(); + if let Err(e) = app.session.input(Input::Keyword(keyword)) { + app.session.echo(e.to_string()); + } } /// Whether Backspace should erase the selection instead of editing the command line. The key @@ -140,7 +200,39 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { app.cmd.watch_selection(&selection); let mac = ctx.os() == egui::os::OperatingSystem::Mac; let text_prompt = app.session.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number); + options_still_fit(app); for ev in events { + // The options menu takes the arrows, Enter and Escape; typing closes it. + if app.cmd.options.is_some() { + let space = matches!(&ev, egui::Event::Text(t) if t == " ") && !text_prompt; + match &ev { + _ if space => { + if let Some(k) = app.cmd.options.as_ref().and_then(|m| m.current()).map(str::to_owned) { + pick_option(app, k); + } + continue; + } + egui::Event::Key { key: Key::Enter, pressed: true, .. } => { + if let Some(k) = app.cmd.options.as_ref().and_then(|m| m.current()).map(str::to_owned) { + pick_option(app, k); + } + continue; + } + egui::Event::Key { key: Key::Escape, pressed: true, .. } => { + app.cmd.options = None; + continue; + } + egui::Event::Key { key: Key::ArrowUp, pressed: true, modifiers, .. } if !modifiers.any() => { + options_key(app, -1); + continue; + } + egui::Event::Key { key: Key::ArrowDown | Key::ArrowUp, pressed: true, .. } => {} + // Space arrives as a key and as text; the text picks. + egui::Event::Key { key: Key::Space, .. } => {} + egui::Event::Text(_) | egui::Event::Key { pressed: true, .. } => app.cmd.options = None, + _ => {} + } + } match ev { egui::Event::Text(t) => { if t == "\t" { @@ -182,7 +274,9 @@ pub fn keyboard(app: &mut CadApp, ctx: &egui::Context) { } } Key::ArrowDown if !modifiers.any() => { - if let Some(p) = app.cmd.history_pos { + if !options_key(app, 1) + && let Some(p) = app.cmd.history_pos + { let np = p + 1; if np >= app.cmd.history.len() { app.cmd.history_pos = None; @@ -217,6 +311,7 @@ pub fn submit(app: &mut CadApp) { } } let text = std::mem::take(&mut app.cmd.buffer); + app.cmd.locked_second = None; app.cmd.history_pos = None; if !text.trim().is_empty() && app.session.running.is_none() { app.cmd.history.push(text.clone()); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b2a9e165a..f6a2dba8b 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -115,6 +115,8 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { ui.radio_value(&mut s.dynpi_absolute, false, crate::tl!("Relative")); ui.radio_value(&mut s.dynpi_absolute, true, crate::tl!("Absolute")); }); + // DYNPROMPT: the prompt and its options (Down Arrow) beside the cursor. + ui.checkbox(&mut s.dynprompt, crate::tl!("Show command prompting near the crosshairs")); }); }); ui.checkbox(&mut s.orthomode, crate::tl!("Ortho (F8)")); diff --git a/crates/ui-egui/src/dyninput.rs b/crates/ui-egui/src/dyninput.rs index 708ca5ecf..229ba1739 100644 --- a/crates/ui-egui/src/dyninput.rs +++ b/crates/ui-egui/src/dyninput.rs @@ -5,12 +5,15 @@ //! 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`. +//! - Tab types the separator for the format on show, so `40` Tab `90` reads `40<90`; Tab after the +//! second value locks it too (both boxes show a lock and the cursor sits on the point). //! - 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. //! +//! - Down Arrow at a prompt with options (DYNPROMPT on) lists them at the cursor ([`OptionsMenu`]). +//! //! Scripts, the control channel and MCP never pass through here, so they keep the plain //! command-line rules. @@ -133,10 +136,25 @@ pub fn tab_separator(text: &str, frame: &Frame) -> Option { 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 { +/// Whether Tab locks the second value too: both values are typed. +pub fn can_lock_second(text: &str, frame: &Frame) -> bool { + parse(text, frame).is_some_and(|e| e.split && e.first_locked().is_some() && e.second_value().is_some()) +} + +/// Hold the cursor to the locked values: the first one once a separator follows it, and both +/// when Tab has locked the second one too (`second_locked`), which pins the cursor to the point. +pub fn constrain(entry: &Entry<'_>, frame: &Frame, p: Vec2, second_locked: bool) -> Vec2 { let Some(v) = entry.first_locked() else { return p }; let o = frame.origin(); + if second_locked && let Some(w) = entry.second_value() { + let q = match entry.format { + Format::Cartesian => o + Vec2::new(v, w), + Format::Polar => o + Vec2::from_angle(w) * v, + }; + if q.is_finite() { + return q; + } + } match entry.format { Format::Cartesian => Vec2::new(o.x + v, p.y), Format::Polar => { @@ -157,7 +175,7 @@ pub fn complete(entry: &Entry<'_>, frame: &Frame, cursor: Vec2, last: Vec2) -> O if !entry.split { return None; } - let (live_first, live_second) = frame.live(entry.format, constrain(entry, frame, cursor)); + let (live_first, live_second) = frame.live(entry.format, constrain(entry, frame, cursor, false)); 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) { @@ -174,6 +192,39 @@ pub fn complete(entry: &Entry<'_>, frame: &Frame, cursor: Vec2, last: Vec2) -> O p.is_finite().then(|| format!("#{},{}", p.x, p.y)) } +/// The Dynamic Input options menu (DYNPROMPT): Down Arrow at a prompt with keywords lists them +/// at the cursor; the arrows move the highlight and Enter or a click sends the keyword. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OptionsMenu { + /// The prompt's keywords, as they were when the menu opened. + pub keywords: Vec, + /// The highlighted keyword. + pub index: usize, +} + +impl OptionsMenu { + /// The menu for a prompt's keywords, or `None` when it has none. + pub fn open(keywords: &[String]) -> Option { + (!keywords.is_empty()).then(|| OptionsMenu { keywords: keywords.to_vec(), index: 0 }) + } + /// Move the highlight down (`step` = 1) or up (-1), wrapping around. + pub fn step(&mut self, step: isize) { + let n = self.keywords.len(); + if n > 0 { + self.index = (self.index % n).checked_add_signed(step).unwrap_or(n - 1) % n; + } + } + /// The highlighted keyword. + pub fn current(&self) -> Option<&str> { + self.keywords.get(self.index).map(String::as_str) + } + /// Whether the menu still belongs to a prompt with these keywords (it closes when the prompt + /// moves on). + pub fn fits(&self, keywords: &[String]) -> bool { + self.keywords == keywords + } +} + #[cfg(test)] mod tests { use super::*; @@ -244,12 +295,46 @@ mod tests { 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))); + assert!(close(Some(constrain(&e, &f, Vec2::new(1.0, 100.0), false)), 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)); + assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0), false), 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)); + assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0), false), Vec2::new(-7.0, 9.0)); + } + + #[test] + fn tab_locks_the_second_value_and_pins_the_cursor() { + let base = Vec2::new(1.0, 1.0); + let f = second_point(base); + // Only once both values are typed. + assert!(!can_lock_second("5", &f)); + assert!(!can_lock_second("5<", &f)); + assert!(can_lock_second("5<90", &f)); + assert!(can_lock_second("3,4", &f)); + let e = parse("5<90", &f).unwrap(); + // Unlocked, the typed angle doesn't hold the cursor; locked, the cursor sits on the point. + assert!(close(Some(constrain(&e, &f, Vec2::new(100.0, 1.0), false)), Vec2::new(6.0, 1.0))); + assert!(close(Some(constrain(&e, &f, Vec2::new(100.0, 1.0), true)), Vec2::new(1.0, 6.0))); + let e = parse("3,4", &f).unwrap(); + assert_eq!(constrain(&e, &f, Vec2::new(-7.0, 9.0), true), Vec2::new(4.0, 5.0)); + } + + #[test] + fn options_menu_cycles_the_keywords() { + assert_eq!(OptionsMenu::open(&[]), None); + let kws: Vec = ["3P", "2P", "Ttr"].iter().map(|s| s.to_string()).collect(); + let mut m = OptionsMenu::open(&kws).unwrap(); + assert_eq!(m.current(), Some("3P")); + m.step(1); + assert_eq!(m.current(), Some("2P")); + m.step(1); + m.step(1); + assert_eq!(m.current(), Some("3P"), "wraps past the last"); + m.step(-1); + assert_eq!(m.current(), Some("Ttr"), "wraps before the first"); + assert!(m.fits(&kws)); + assert!(!m.fits(&kws[..2])); } #[test] @@ -279,6 +364,79 @@ mod tests { assert!(close(cadcraft_engine::prompt::parse_point(&t, other_last), Vec2::new(0.0, 100.0))); } + fn run_frame(app: &mut crate::CadApp, ctx: &egui::Context, events: Vec) { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::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(); + } + + fn key(key: egui::Key) -> Vec { + let m = egui::Modifiers::NONE; + vec![ + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }, + egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }, + ] + } + + #[test] + fn down_arrow_lists_the_options_and_enter_or_a_click_picks_one() { + let mut app = crate::CadApp::new(cadcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + run_frame(&mut app, &ctx, Vec::new()); + let center = app.canvas.rect.map_or(egui::pos2(700.0, 450.0), |r| r.center()); + let hover = vec![egui::Event::PointerMoved(center)]; + app.cmdline("circle"); + let kws = app.session.current_prompt().unwrap().keywords; + assert!(kws.len() >= 2, "{kws:?}"); + run_frame(&mut app, &ctx, hover.clone()); + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + assert_eq!(app.cmd.options.as_ref().and_then(|m| m.current()), kws.first().map(String::as_str)); + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + assert_eq!(app.cmd.options.as_ref().map(|m| m.index), Some(1)); + // Escape closes the menu and leaves the command running. + run_frame(&mut app, &ctx, key(egui::Key::Escape)); + assert!(app.cmd.options.is_none() && app.session.running.is_some()); + // Down, Down, Enter: the second option answers the prompt. + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + run_frame(&mut app, &ctx, key(egui::Key::Enter)); + assert!(app.cmd.options.is_none()); + let after = app.session.current_prompt().map(|p| p.message).unwrap_or_default(); + assert!(app.session.running.is_some(), "the option leads to its next prompt"); + assert_ne!(after, "", "{after}"); + + // A click on a row picks it. + app.session.cancel(); + app.cmdline("circle"); + run_frame(&mut app, &ctx, hover); + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + run_frame(&mut app, &ctx, Vec::new()); + let (row, kw) = app.cmd.option_rows.get(1).cloned().expect("the menu is drawn at the cursor"); + assert_eq!(Some(&kw), kws.get(1)); + let at = row.center(); + let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + run_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + run_frame(&mut app, &ctx, vec![click(true)]); + run_frame(&mut app, &ctx, vec![click(false)]); + assert!(app.cmd.options.is_none()); + assert_eq!(app.session.current_prompt().map(|p| p.message), Some(after), "same option, same next prompt"); + assert!(app.session.doc().unwrap().model.iter().next().is_none(), "the click placed no point"); + + // DYNPROMPT = 0: Down Arrow is left to the history. + app.session.cancel(); + app.session.settings.dynprompt = false; + app.cmdline("circle"); + run_frame(&mut app, &ctx, key(egui::Key::ArrowDown)); + assert!(app.cmd.options.is_none()); + } + #[test] fn empty_text_is_an_empty_entry() { let f = second_point(Vec2::ZERO); diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e7116d652..3cdc9e6e6 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -771,6 +771,7 @@ Show All Показати всі Show All Constraint Bars Показати всі панелі обмежень Show All Dynamic Dimensions Показати всі динамічні розміри + Show command prompting near the crosshairs Показувати запити команд біля перехрестя Show Layer List Показати список шарів Show/Hide Lineweight Показати/приховати товщину ліній Show/Hide Transparency Показати/приховати прозорість