mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
1 parent
c334fedf31
commit
8ca4265627
10 files changed
+496
-20
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 Показати/приховати прозорість
|
||||
|
||||
|
Reference in new issue
Block a user