Dynamic Input options menu (DYNPROMPT), second-value lock; INS on ATTDEF/MLEADER, GCEN on closed splines (part of #519) (#523)

Squash of PR #523 by @vangel76.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 12:10:52 -04:00
1 parent c334fedf31
commit 8ca4265627
10 files changed
+496 -20

No files matched your search

+4
View File
@@ -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,
+15 -5
View File
@@ -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)));
}
+3
View File
@@ -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<Value> {
"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) {
+40
View File
@@ -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:?}");
}
+67
View File
@@ -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:?}");
}
}
+103 -7
View File
@@ -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.
+96 -1
View File
@@ -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<crate::dyninput::OptionsMenu>,
/// The text whose second Dynamic Input value Tab locked; the lock holds while it is unchanged.
locked_second: Option<String>,
/// 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<Pos2>,
}
/// 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<String> {
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());
+2
View File
@@ -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)"));
+165 -7
View File
@@ -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<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 {
/// 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<String>,
/// 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<Self> {
(!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<String> = ["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<egui::Event>) {
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<egui::Event> {
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);
+1
View File
@@ -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 Показати/приховати прозорість
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
771
772
773
774
775
776
777