diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 0e14c78e..fbdb2b20 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -5,7 +5,10 @@ //! - Holding the left button still (or a finger, on touch screens) for [`HOLD_SECS`], or the //! viewport menu's "Select Other…", opens a list at the cursor of every pickable thing there, //! in the selection filter's and the open dialog's terms: sketch points and curves first, then -//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first. +//! vertices, edges, faces, bodies, profiles, planes and axes, each kind nearest first. On a +//! dense mesh (a modelled thread, an imported mesh) thousands of facets can share one cursor, +//! so at most [`MAX_PER_KIND`] rows are kept per kind, nearest first, and the list reports how +//! many things there were in all. //! - Hovering a row lights its entity up in the viewport; clicking it selects it (Shift/Ctrl add, //! as clicks do) or, with a dialog open, gives it to the dialog's active selection input. //! Esc or a click elsewhere closes the list. @@ -28,6 +31,10 @@ use crate::viewport::{Hit, Proj}; pub const HOLD_SECS: f64 = 0.5; /// How far (screen points) a repeat click may be from the last one and still cycle. const CYCLE_SLOP: f32 = 4.0; +/// The most rows kept per kind of thing (vertices, edges, faces, …). The list exists to tell a +/// handful of overlapping things apart; nearest first, farther ones of the same kind are dropped +/// so it stays readable and bounded. +const MAX_PER_KIND: usize = 20; /// One row of the list. #[derive(Clone, Debug)] @@ -43,6 +50,8 @@ pub struct Row { pub struct Popup { pub at: Pos2, pub rows: Vec, + /// How many things were under the cursor in all (the rows are capped per kind). + pub total: usize, /// The row under the pointer (lit in the viewport). pub hovered: Option, /// The model and dialog it was made for: it closes when they change. @@ -78,6 +87,11 @@ pub struct State { /// Every pickable thing under `pos`, ordered by kind (the sketch being edited first), then depth. pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { + rows_capped(app, proj, pos).0 +} + +/// [`rows`], with how many things were under the cursor in all. +fn rows_capped(app: &SolveApp, proj: &Proj, pos: Pos2) -> (Vec, usize) { let hits = crate::viewport::pick_all(app, proj, pos); let mut found = crate::viewport::candidates(app, &hits); let dialog = app.dialog.as_ref().is_some_and(|d| d.wants_picks()); @@ -97,12 +111,25 @@ pub fn rows(app: &SolveApp, proj: &Proj, pos: Pos2) -> Vec { } let sketching = app.session.active_sketch.is_some(); out.sort_by_key(|(_, s)| rank(s, sketching)); - out.into_iter() + let total = out.len(); + // Nearest first within a kind, so capping drops the farther ones. + let mut per_kind: std::collections::HashMap = std::collections::HashMap::new(); + let rows: Vec = out + .into_iter() + .filter(|(_, s)| { + let n = per_kind.entry(rank(s, sketching).1).or_default(); + if *n >= MAX_PER_KIND { + return false; + } + *n += 1; + true + }) .map(|(hit, sel)| { let label = label(app, &sel); Row { icon: icon(app, &sel), hit, sel, label } }) - .collect() + .collect(); + (rows, total) } /// Sort key: in a sketch its own points and curves come first; then by kind. @@ -255,14 +282,14 @@ pub fn open(app: &mut SolveApp, at: Pos2) -> bool { } fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool { - let rows = rows(app, proj, at); + let (rows, total) = rows_capped(app, proj, at); if rows.is_empty() { app.status = Some(("Nothing to select here".into(), crate::now_ms(), false)); return false; } close(app); app.viewport.select_other.popup = - Some(Popup { at, rows, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false }); + Some(Popup { at, rows, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false }); true } @@ -306,6 +333,7 @@ pub fn describe(app: &SolveApp) -> Value { "open": true, "at": [p.at.x, p.at.y], "hovered": p.hovered, + "total": p.total, "rows": p.rows.iter().map(|r| json!({ "label": r.label, "icon": r.icon, "sel": r.sel })).collect::>(), }), } @@ -474,13 +502,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .show(ctx, |ui| { crate::context_menu::menu_frame(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); - ui.painter().text( - pos2(r.left() + 8.0, r.center().y), - Align2::LEFT_CENTER, - crate::i18n::tr_label("Select Other").to_uppercase(), - FontId::proportional(10.5), - t.text_dim, - ); + let title = crate::i18n::tr_label("Select Other").to_uppercase(); + let title = if p.total > p.rows.len() { format!("{title} · {} / {}", p.rows.len(), p.total) } else { title }; + ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, title, FontId::proportional(10.5), t.text_dim); egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { for (i, row) in p.rows.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); diff --git a/crates/ui-egui/src/select_other_tests.rs b/crates/ui-egui/src/select_other_tests.rs index b4da736a..23b2e8cf 100644 --- a/crates/ui-egui/src/select_other_tests.rs +++ b/crates/ui-egui/src/select_other_tests.rs @@ -170,3 +170,27 @@ fn rows_name_points_by_their_curves() { let rows = labels(&h); assert!(rows.iter().any(|r| r.starts_with("Point (") && r.contains("centre")), "{rows:?}"); } + +#[test] +fn a_dense_spot_is_capped_per_kind_and_reports_the_total() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + // Thirty lines drawn on top of each other: one kind of thing, far beyond the per-kind cap. + for _ in 0..30 { + h.app.run("sketch.line", json!({"points":[[0,10],[20,10]],"infer":false,"connect":false})).unwrap(); + } + h.app.cam.distance = 60.0; + h.settle(); + let at = h.call("ui.at", json!({"sketch":[10,10]}))["result"].clone(); + h.step(&json!({"hold": at})).unwrap(); + assert!(crate::select_other::is_open(&h.app)); + let d = crate::select_other::describe(&h.app); + let rows = d["rows"].as_array().expect("rows"); + let lines = rows.iter().filter(|r| r["label"].as_str().is_some_and(|l| l.starts_with("Line "))).count(); + assert_eq!(lines, 20, "at most 20 rows of one kind, nearest first: {rows:?}"); + assert_eq!(rows[0]["label"], json!("Line 1"), "the nearest still leads"); + let total = d["total"].as_u64().expect("total"); + assert!(total >= 30, "the total counts every candidate, not just the rows: {total}"); + assert!((total as usize) < rows.len() + 30, "the list is truncated: {total} total, {} rows", rows.len()); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3354d63c..bc999691 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -56,7 +56,7 @@ unit regardless of the design, pass an expression with one: `"value": "50 mm"` o | `ui.drag` | `x0`, `y0`, `x1`, `y1`, `button?`, `shift?`, `ctrl?`, `steps?`, `hold?`, `hold_end?` | press, move, release; a left drag on the model is a box selection (left to right: window, right to left: crossing); with OpenSCAD navigation, plain left-drag on empty space orbits, Shift+left-drag box-selects, and drags starting on sketch geometry edit it; `hold` keeps the button down that many frames first (a right press-and-hold opens the marking menu, releasing over a direction picks it), `hold_end` that many frames at the end (a sketch drag shows its snap) | | `ui.menu` | `x?`, `y?`, `target?`, `close?` | with a point: open a context menu there as a right-click would (the viewport's marking menu, or a browser menu with `target: {"type": "body", "name"} \| {"type": "sketch", "id"} \| {"type": "component", "id"}`); without: the open menu (`radial` and `items`: id, label, shortcut, enabled) | | `ui.menuPick` | `item`: id or label | runs an item of the open menu, as clicking it does | -| `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list everything pickable there (as holding the left button does); `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `rows`: label, icon, sel) | +| `ui.selectOther` | `x?`, `y?`, `pick?`, `add?`, `hover?`, `close?` | Select Other: with a point, list everything pickable there (as holding the left button does); `pick`: select row `pick` (`add`: as Shift-click; with a dialog open, into its active input); `hover`: light a row up; without: the open list (`open`, `at`, `total`: everything under the cursor, `rows`: label, icon, sel, at most 20 per kind, nearest first) | | `ui.rename` | `text?`, `commit?` (default true) | finishes the rename box a Rename item opened | | `ui.selection` | | the selection, the open dialog's inputs, the hovered item, and the active sketch's drawn dimensions (`dimensions`: id and text centre, `dimension_selected`, `dimension_editing`) | | `ui.worldToScreen` | `points`: `[[x, y, z]…]` world points | their screen points |