List menus open below-right of the cursor and flip to end at the cursor where they don't fit (shared context_menu::place / show for viewport, browser, toolbar and Select Other). Right-clicking a plane in the viewport, a construction plane row or an origin plane row (new menu with Create Sketch and Show/Hide) offers Create Sketch on that plane. Fixes #1354.
566 lines
24 KiB
Rust
566 lines
24 KiB
Rust
//! Select Other: everything under the cursor, for picking what lies under something else (two
|
|
//! lines drawn on top of each other, coincident points, an edge in front of a face, overlapping
|
|
//! bodies).
|
|
//!
|
|
//! - 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. 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.
|
|
//! - Clicking again on the same spot with nothing changed selects the next thing in that list
|
|
//! (click cycling). The second click of a double-click keeps its own actions.
|
|
//!
|
|
//! The control channel's `ui.selectOther` opens, describes and picks from the list.
|
|
|
|
use egui::{Align2, FontId, Pos2, Rect, Sense, pos2, vec2};
|
|
use serde_json::{Value, json};
|
|
use solidcraft_engine::Sel;
|
|
use solidcraft_engine::sketch::CurveKind;
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
use crate::SolveApp;
|
|
use crate::theme::Tokens;
|
|
use crate::viewport::{Hit, Proj};
|
|
|
|
/// How long the left button stays down, without moving, before the list opens.
|
|
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.
|
|
pub(crate) const MAX_PER_KIND: usize = 20;
|
|
|
|
/// One row of the list.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Row {
|
|
pub hit: Hit,
|
|
pub sel: Sel,
|
|
pub label: String,
|
|
pub icon: &'static str,
|
|
}
|
|
|
|
/// The open list.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Popup {
|
|
pub at: Pos2,
|
|
pub rows: Vec<Row>,
|
|
/// 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<usize>,
|
|
/// The model and dialog it was made for: it closes when they change.
|
|
revision: u64,
|
|
dialog: bool,
|
|
/// The browser's body highlight is ours to clear.
|
|
lit_body: bool,
|
|
}
|
|
|
|
/// The last plain click, for cycling.
|
|
#[derive(Clone, Debug)]
|
|
struct Cycle {
|
|
at: Pos2,
|
|
chosen: Sel,
|
|
selection: Vec<Sel>,
|
|
key: u64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct State {
|
|
pub popup: Option<Popup>,
|
|
/// A left press that may become a hold: where and when.
|
|
hold: Option<(Pos2, f64)>,
|
|
/// The current press belongs to Select Other (it opened or closed the list): the viewport
|
|
/// doesn't also click, drag or box-select with it.
|
|
held: bool,
|
|
cycle: Option<Cycle>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// What is under the cursor
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// 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<Row> {
|
|
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<Row>, 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());
|
|
// A body behind each face (body picking already turns faces into bodies).
|
|
if !dialog && !app.ui.pick_bodies {
|
|
for h in &hits {
|
|
if let Hit::Face { body, .. } = h {
|
|
found.push((h.clone(), Sel::Body { name: body.clone() }));
|
|
}
|
|
}
|
|
}
|
|
// A face place a sketch point snapped (a Single hole's) is its own row: beside the face as
|
|
// picked, and beside other points on that face.
|
|
let snapped = |h: &Hit| matches!(h, Hit::SketchPoint { .. });
|
|
let mut out: Vec<(Hit, Sel)> = Vec::new();
|
|
for (h, s) in found {
|
|
if !out.iter().any(|(hx, x)| if snapped(hx) || snapped(&h) { *x == s } else { crate::selection::same_item(x, &s) }) {
|
|
out.push((h, s));
|
|
}
|
|
}
|
|
let sketching = app.session.active_sketch.is_some();
|
|
out.sort_by_key(|(h, s)| rank(&shown_as(h, s), sketching));
|
|
let total = out.len();
|
|
// Nearest first within a kind, so capping drops the farther ones.
|
|
let mut per_kind: std::collections::HashMap<u8, usize> = std::collections::HashMap::new();
|
|
let rows: Vec<Row> = out
|
|
.into_iter()
|
|
.filter(|(h, s)| {
|
|
let n = per_kind.entry(rank(&shown_as(h, s), sketching).1).or_default();
|
|
if *n >= MAX_PER_KIND {
|
|
return false;
|
|
}
|
|
*n += 1;
|
|
true
|
|
})
|
|
.map(|(hit, sel)| {
|
|
let shown = shown_as(&hit, &sel);
|
|
let label = label(app, &shown);
|
|
Row { icon: icon(app, &shown), hit, sel, label }
|
|
})
|
|
.collect();
|
|
(rows, total)
|
|
}
|
|
|
|
/// What a row shows itself as: a face place a sketch point snapped is that point (ranked, capped
|
|
/// and named with the points).
|
|
fn shown_as(hit: &Hit, sel: &Sel) -> Sel {
|
|
match (hit, sel) {
|
|
(Hit::SketchPoint { sketch, id, .. }, Sel::Face { .. }) => Sel::ExternalSketchPoint { sketch: *sketch, id: id.clone() },
|
|
_ => sel.clone(),
|
|
}
|
|
}
|
|
|
|
/// Sort key: in a sketch its own points and curves come first; then by kind.
|
|
fn rank(s: &Sel, sketching: bool) -> (u8, u8) {
|
|
let kind = match s {
|
|
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } => 0,
|
|
Sel::SketchCurve { .. } | Sel::ExternalSketchCurve { .. } => 1,
|
|
Sel::SketchConstraint { .. } => 2,
|
|
Sel::Vertex { .. } => 3,
|
|
Sel::Edge { .. } => 4,
|
|
Sel::Face { .. } | Sel::Feature { .. } => 5,
|
|
Sel::Body { .. } | Sel::Occurrence { .. } => 6,
|
|
Sel::Profile { .. } => 7,
|
|
Sel::Plane { .. } => 8,
|
|
Sel::Axis { .. } => 9,
|
|
};
|
|
let own = matches!(s, Sel::SketchPoint { .. } | Sel::SketchCurve { .. });
|
|
(if sketching && !own { 1 } else { 0 }, kind)
|
|
}
|
|
|
|
/// The number at the end of an id (`l3` → 3).
|
|
fn id_number(id: &str) -> Option<u64> {
|
|
let digits: String = id.chars().rev().take_while(char::is_ascii_digit).collect::<Vec<_>>().into_iter().rev().collect();
|
|
digits.parse().ok()
|
|
}
|
|
|
|
fn curve_kind_name(k: &CurveKind) -> &'static str {
|
|
match k {
|
|
CurveKind::Line { .. } => "Line",
|
|
CurveKind::Circle { .. } => "Circle",
|
|
CurveKind::Arc { .. } => "Arc",
|
|
CurveKind::Ellipse { .. } => "Ellipse",
|
|
CurveKind::Spline { .. } => "Spline",
|
|
CurveKind::Conic { .. } => "Conic",
|
|
}
|
|
}
|
|
|
|
fn sketch_name(app: &SolveApp, sketch: u64) -> String {
|
|
app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_else(|| format!("Sketch {sketch}"))
|
|
}
|
|
|
|
/// "Line 3" (its kind and number), with what kind of geometry it is when that matters.
|
|
fn curve_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
|
if let Some((i, c)) = sk.curves.iter().enumerate().find(|(_, c)| c.id == id) {
|
|
let kind = curve_kind_name(&c.kind);
|
|
let n = id_number(id).unwrap_or_else(|| sk.curves.iter().take(i + 1).filter(|x| curve_kind_name(&x.kind) == kind).count() as u64);
|
|
let mut s = format!("{kind} {n}");
|
|
if c.link.is_some() {
|
|
s.push_str(" (projected)");
|
|
} else if c.construction {
|
|
s.push_str(" (construction)");
|
|
}
|
|
return s;
|
|
}
|
|
if sk.wires.iter().any(|w| w.id == id) {
|
|
return format!("3D Curve {}", id_number(id).unwrap_or(0));
|
|
}
|
|
id.to_string()
|
|
}
|
|
|
|
/// "Point (arc 2 centre)": the role it plays in the first curve that uses it.
|
|
fn point_label(sk: &solidcraft_engine::sketch::Sketch, id: &str) -> String {
|
|
let Some(pi) = sk.points.iter().position(|p| p.id == id) else { return id.to_string() };
|
|
let role = sk.curves.iter().find_map(|c| {
|
|
let name = format!("{} {}", curve_kind_name(&c.kind).to_lowercase(), id_number(&c.id).unwrap_or(0));
|
|
let (start, end) = if c.reversed { ("end", "start") } else { ("start", "end") };
|
|
let role = match &c.kind {
|
|
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } | CurveKind::Conic { a, b, .. } if *a == pi => Some(start.to_string()),
|
|
CurveKind::Line { b, .. } | CurveKind::Arc { b, .. } | CurveKind::Conic { b, .. } if *b == pi => Some(end.to_string()),
|
|
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } | CurveKind::Ellipse { c, .. } if *c == pi => Some("centre".to_string()),
|
|
CurveKind::Ellipse { m, .. } if *m == pi => Some("major axis end".to_string()),
|
|
CurveKind::Conic { apex, .. } if *apex == pi => Some("apex".to_string()),
|
|
CurveKind::Spline { pts, .. } => pts.iter().position(|p| *p == pi).map(|k| format!("point {}", k + 1)),
|
|
_ => None,
|
|
}?;
|
|
Some(format!("{name} {role}"))
|
|
});
|
|
let fixed = if sk.point_locked(pi) { ", fixed" } else { "" };
|
|
match role {
|
|
Some(r) => format!("Point ({r}{fixed})"),
|
|
None if fixed.is_empty() => format!("Point {}", id_number(id).unwrap_or(pi as u64 + 1)),
|
|
None => format!("Point {} (fixed)", id_number(id).unwrap_or(pi as u64 + 1)),
|
|
}
|
|
}
|
|
|
|
/// A row's name: "Line 3", "Point (arc 2 centre)", "Edge 4 · Body1", "XY Plane"…
|
|
pub fn label(app: &SolveApp, sel: &Sel) -> String {
|
|
let st = app.session.world_state();
|
|
let in_sketch =
|
|
|sketch: u64, f: &dyn Fn(&solidcraft_engine::sketch::Sketch) -> String| st.sketch(sketch).map(|ss| f(&ss.sketch)).unwrap_or_default();
|
|
match sel {
|
|
Sel::SketchPoint { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| point_label(sk, id))).unwrap_or_else(|| id.clone()),
|
|
Sel::SketchCurve { id } => app.session.active_sketch.map(|s| in_sketch(s, &|sk| curve_label(sk, id))).unwrap_or_else(|| id.clone()),
|
|
Sel::ExternalSketchPoint { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| point_label(sk, id)), sketch_name(app, *sketch)),
|
|
Sel::ExternalSketchCurve { sketch, id } => format!("{} · {}", in_sketch(*sketch, &|sk| curve_label(sk, id)), sketch_name(app, *sketch)),
|
|
Sel::SketchConstraint { id } => format!("Constraint {id}"),
|
|
Sel::Vertex { body, .. } => format!("Vertex · {body}"),
|
|
Sel::Edge { body, index, .. } => format!("Edge {} · {body}", index + 1),
|
|
Sel::Face { body, index, .. } => format!("Face {} · {body}", index + 1),
|
|
Sel::Body { name } => format!("Body · {name}"),
|
|
Sel::Occurrence { path } => format!("Occurrence {path:?}"),
|
|
Sel::Feature { id } => app.session.doc.feature(*id).map(|f| f.name.clone()).unwrap_or_else(|| format!("Feature {id}")),
|
|
Sel::Profile { sketch, index } => format!("Profile {} · {}", index + 1, sketch_name(app, *sketch)),
|
|
Sel::Plane { name } if matches!(name.as_str(), "XY" | "XZ" | "YZ") => format!("{name} Plane"),
|
|
Sel::Axis { name } if matches!(name.as_str(), "X" | "Y" | "Z") => format!("{name} Axis"),
|
|
Sel::Plane { name } | Sel::Axis { name } => name.clone(),
|
|
}
|
|
}
|
|
|
|
fn icon(app: &SolveApp, sel: &Sel) -> &'static str {
|
|
let st = app.session.world_state();
|
|
let curve = |sketch: Option<u64>, id: &str| {
|
|
let kind = sketch.and_then(|s| st.sketch(s)).and_then(|ss| ss.sketch.curves.iter().find(|c| c.id == id).map(|c| c.kind.clone()));
|
|
match kind {
|
|
Some(CurveKind::Circle { .. }) => "circle",
|
|
Some(CurveKind::Arc { .. }) => "arc_tangent",
|
|
Some(CurveKind::Ellipse { .. }) => "ellipse",
|
|
Some(CurveKind::Spline { .. }) => "spline",
|
|
Some(CurveKind::Conic { .. }) => "conic",
|
|
_ => "line",
|
|
}
|
|
};
|
|
match sel {
|
|
Sel::SketchPoint { .. } | Sel::ExternalSketchPoint { .. } | Sel::Vertex { .. } => "point",
|
|
Sel::SketchCurve { id } => curve(app.session.active_sketch, id),
|
|
Sel::ExternalSketchCurve { sketch, id } => curve(Some(*sketch), id),
|
|
Sel::SketchConstraint { .. } => "dimension",
|
|
Sel::Edge { .. } => "fillet",
|
|
Sel::Face { .. } | Sel::Feature { .. } => "presspull",
|
|
Sel::Body { .. } => "body",
|
|
Sel::Occurrence { .. } => "component",
|
|
Sel::Profile { .. } => "sketch",
|
|
Sel::Plane { .. } => "plane",
|
|
Sel::Axis { .. } => "axis",
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Opening, picking, closing
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
fn projection_now(app: &SolveApp) -> Option<Proj> {
|
|
app.viewport.rect.map(|r| crate::viewport::projection(app, r))
|
|
}
|
|
|
|
/// Open the list at a screen point; false (with a status message) when nothing is there.
|
|
pub fn open(app: &mut SolveApp, at: Pos2) -> bool {
|
|
let Some(proj) = projection_now(app) else { return false };
|
|
open_with(app, &proj, at)
|
|
}
|
|
|
|
fn open_with(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
|
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, total, hovered: None, revision: app.session.revision, dialog: app.dialog.is_some(), lit_body: false });
|
|
true
|
|
}
|
|
|
|
pub fn close(app: &mut SolveApp) {
|
|
if let Some(p) = app.viewport.select_other.popup.take()
|
|
&& p.lit_body
|
|
{
|
|
app.viewport.hover_bodies.clear();
|
|
}
|
|
}
|
|
|
|
pub fn is_open(app: &SolveApp) -> bool {
|
|
app.viewport.select_other.popup.is_some()
|
|
}
|
|
|
|
/// Pick row `i` of the open list (`add`: Shift/Ctrl, add to the selection) and close it.
|
|
pub fn choose(app: &mut SolveApp, i: usize, add: bool) -> Result<Row, String> {
|
|
let Some(p) = app.viewport.select_other.popup.as_ref() else { return Err("Select Other is not open".into()) };
|
|
let at = p.at;
|
|
let row = p.rows.get(i).cloned().ok_or_else(|| format!("no row {i} (the list has {})", p.rows.len()))?;
|
|
close(app);
|
|
apply(app, &row, add);
|
|
remember(app, at, if add { None } else { Some(row.sel.clone()) });
|
|
Ok(row)
|
|
}
|
|
|
|
/// Select a row's entity: into the dialog's active input, or as a click does.
|
|
fn apply(app: &mut SolveApp, row: &Row, add: bool) {
|
|
if app.dialog.as_ref().is_some_and(|d| d.wants_picks()) {
|
|
crate::viewport::dialog_pick(app, &row.hit, row.sel.clone());
|
|
} else {
|
|
crate::viewport::select(app, Some(row.sel.clone()), add);
|
|
}
|
|
}
|
|
|
|
/// The open list as JSON (for `ui.selectOther`).
|
|
pub fn describe(app: &SolveApp) -> Value {
|
|
match &app.viewport.select_other.popup {
|
|
None => json!({ "open": false }),
|
|
Some(p) => json!({
|
|
"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::<Vec<_>>(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// The control channel's `ui.selectOther`: `{x, y}` opens the list there, `{pick: i, add?}`
|
|
/// picks a row, `{hover: i}` lights one up, `{close: true}` closes it; it answers with the list.
|
|
pub fn control(app: &mut SolveApp, p: &Value) -> Result<Value, String> {
|
|
if p.get("close").and_then(Value::as_bool) == Some(true) {
|
|
close(app);
|
|
return Ok(describe(app));
|
|
}
|
|
if let (Some(x), Some(y)) = (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) {
|
|
if !(x.is_finite() && y.is_finite()) {
|
|
return Err("x and y must be numbers".into());
|
|
}
|
|
if app.viewport.rect.is_none() {
|
|
return Err("no viewport".into());
|
|
}
|
|
open(app, pos2(x as f32, y as f32));
|
|
return Ok(describe(app));
|
|
}
|
|
if let Some(i) = p.get("hover").and_then(Value::as_u64) {
|
|
let Some(pop) = app.viewport.select_other.popup.as_mut() else { return Err("Select Other is not open".into()) };
|
|
pop.hovered = Some(i as usize).filter(|i| *i < pop.rows.len());
|
|
hover_override(app);
|
|
return Ok(describe(app));
|
|
}
|
|
if let Some(i) = p.get("pick").and_then(Value::as_u64) {
|
|
let row = choose(app, i as usize, p.get("add").and_then(Value::as_bool).unwrap_or(false))?;
|
|
return Ok(json!({ "picked": row.label, "sel": row.sel, "selection": app.session.selection }));
|
|
}
|
|
Ok(describe(app))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Press and hold, and click cycling (called by the viewport)
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// Follow a left press held still in the viewport; open the list when it has been held long
|
|
/// enough. True while the press belongs to Select Other (the viewport must not also click,
|
|
/// drag or box-select with it).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn track_hold(
|
|
app: &mut SolveApp,
|
|
ctx: &egui::Context,
|
|
proj: &Proj,
|
|
inside: bool,
|
|
pressed: bool,
|
|
down: bool,
|
|
hover: Option<Pos2>,
|
|
navigating: bool,
|
|
) -> bool {
|
|
let consumed = app.viewport.select_other.held;
|
|
let now = ctx.input(|i| i.time);
|
|
let free = app.tool.is_none()
|
|
&& app.viewport.context_menu.is_none()
|
|
&& !is_open(app)
|
|
&& app.dialog.as_ref().is_none_or(|d| d.wants_picks())
|
|
&& app.viewport.point_drag.is_none();
|
|
if pressed && inside && free && !navigating {
|
|
app.viewport.select_other.hold = hover.map(|p| (p, now));
|
|
}
|
|
let mut opened = false;
|
|
if let Some((p0, t0)) = app.viewport.select_other.hold {
|
|
let slop = ctx.options(|o| o.input_options.max_click_dist);
|
|
if !down || navigating || !free || hover.is_some_and(|p| p.distance(p0) > slop) {
|
|
app.viewport.select_other.hold = None;
|
|
} else if now - t0 >= HOLD_SECS {
|
|
app.viewport.select_other.hold = None;
|
|
opened = open_with(app, proj, p0);
|
|
app.viewport.select_other.held = true;
|
|
} else {
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
|
}
|
|
}
|
|
if !down {
|
|
app.viewport.select_other.held = false;
|
|
}
|
|
consumed || opened
|
|
}
|
|
|
|
/// While the list is open, the hovered row is what the viewport lights up.
|
|
pub fn hover_override(app: &mut SolveApp) {
|
|
let Some(p) = app.viewport.select_other.popup.as_mut() else { return };
|
|
let row = p.hovered.and_then(|i| p.rows.get(i));
|
|
let body = match row.map(|r| &r.sel) {
|
|
Some(Sel::Body { name }) => Some(name.clone()),
|
|
_ => None,
|
|
};
|
|
app.viewport.hover = if body.is_some() { None } else { row.map(|r| r.hit.clone()) };
|
|
match body {
|
|
Some(b) => {
|
|
app.viewport.hover_bodies = vec![b];
|
|
p.lit_body = true;
|
|
}
|
|
None if p.lit_body => {
|
|
app.viewport.hover_bodies.clear();
|
|
p.lit_body = false;
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
/// What a repeat click must find unchanged: the model, the view, the sketch and the filter.
|
|
fn cycle_key(app: &SolveApp) -> u64 {
|
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
app.session.revision.hash(&mut h);
|
|
for v in [app.cam.yaw, app.cam.pitch, app.cam.roll, app.cam.distance, app.cam.target.x, app.cam.target.y, app.cam.target.z] {
|
|
v.to_bits().hash(&mut h);
|
|
}
|
|
app.session.active_sketch.hash(&mut h);
|
|
app.ui.pick_off.hash(&mut h);
|
|
app.ui.pick_bodies.hash(&mut h);
|
|
app.dialog.is_some().hash(&mut h);
|
|
h.finish()
|
|
}
|
|
|
|
fn remember(app: &mut SolveApp, at: Pos2, chosen: Option<Sel>) {
|
|
app.viewport.select_other.cycle = chosen.map(|chosen| Cycle { at, chosen, selection: app.session.selection.clone(), key: cycle_key(app) });
|
|
}
|
|
|
|
/// A plain click selected `sel` at `at`: a repeat click there may cycle from it.
|
|
pub fn note_click(app: &mut SolveApp, at: Pos2, sel: Option<Sel>) {
|
|
remember(app, at, sel);
|
|
}
|
|
|
|
/// A plain click at `at`: when it repeats the last one (same spot, nothing changed since), select
|
|
/// the next thing under the cursor and return true.
|
|
pub fn cycle_click(app: &mut SolveApp, proj: &Proj, at: Pos2) -> bool {
|
|
let Some(c) = app.viewport.select_other.cycle.clone() else { return false };
|
|
if c.at.distance(at) > CYCLE_SLOP || c.key != cycle_key(app) || c.selection != app.session.selection {
|
|
return false;
|
|
}
|
|
let rows = rows(app, proj, at);
|
|
if rows.len() < 2 {
|
|
return false;
|
|
}
|
|
let next = rows.iter().position(|r| crate::selection::same_item(&r.sel, &c.chosen)).map_or(0, |k| (k + 1) % rows.len());
|
|
let Some(row) = rows.get(next).cloned() else { return false };
|
|
apply(app, &row, false);
|
|
app.status = Some((format!("{} ({} of {})", row.label, next + 1, rows.len()), crate::now_ms(), false));
|
|
remember(app, c.at, Some(row.sel));
|
|
true
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Drawing
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// Draw the open list; handle its hover, clicks, and a press elsewhere (which closes it).
|
|
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|
let Some(p) = app.viewport.select_other.popup.clone() else { return };
|
|
if p.revision != app.session.revision || p.dialog != app.dialog.is_some() || app.tool.is_some() {
|
|
close(app);
|
|
return;
|
|
}
|
|
let t = Tokens::get();
|
|
let mut hovered = None;
|
|
let mut picked = None;
|
|
let w = crate::context_menu::LIST_W - 10.0;
|
|
// Title, rows up to the scroll height, margins and border.
|
|
let size = vec2(w + 10.0, 30.0 + (p.rows.len() as f32 * 24.0).min(360.0));
|
|
let resp = crate::frame::area(egui::Id::new("sc_select_other"))
|
|
.fixed_pos(crate::context_menu::place(p.at, vec2(12.0, 12.0), size, ctx.content_rect()))
|
|
.constrain(true)
|
|
.order(egui::Order::Foreground)
|
|
.show(ctx, |ui| {
|
|
crate::context_menu::menu_frame(ui, |ui| {
|
|
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
|
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());
|
|
crate::scenario::publish_handle(&format!("select_other:{i}"), r.center());
|
|
if resp.hovered() {
|
|
hovered = Some(i);
|
|
ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover);
|
|
}
|
|
crate::icons::paint(
|
|
ui.painter(),
|
|
Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(16.0, 16.0)),
|
|
row.icon,
|
|
t.icon,
|
|
t.icon_fill,
|
|
t.accent,
|
|
);
|
|
ui.painter().text(pos2(r.left() + 32.0, r.center().y), Align2::LEFT_CENTER, &row.label, FontId::proportional(13.0), t.text);
|
|
if resp.clicked() {
|
|
picked = Some(i);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
if let Some(pop) = app.viewport.select_other.popup.as_mut()
|
|
&& pop.hovered != hovered
|
|
{
|
|
pop.hovered = hovered;
|
|
ctx.request_repaint();
|
|
}
|
|
let (pressed, pointer, mods) = ctx.input(|i| (i.pointer.any_pressed(), i.pointer.interact_pos(), i.modifiers));
|
|
if let Some(i) = picked {
|
|
let _ = choose(app, i, mods.shift || mods.ctrl || mods.command);
|
|
} else if pressed && !pointer.is_some_and(|q| resp.response.rect.contains(q)) {
|
|
// A press elsewhere only closes the list.
|
|
close(app);
|
|
app.viewport.select_other.held = true;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "select_other_tests.rs"]
|
|
mod tests;
|