Compare commits

..
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 a4eb3f8b53 Toolbar drop-downs highlight the row under the pointer and the keyboard focus (#288)
A panel drop-down's command rows are full-width rows that take the theme's hover background
(icon, label and shortcut on it) when hovered or focused. Up/Down move the focus through the
enabled rows, Enter starts the command, Escape closes the drop-down. Rows publish
`dropdown:<id>` and the highlighted one `dropdown_hot:<id>`; scenario s46 checks pointer and
keyboard highlight.

Fixes #288

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT
2026-10-12 13:30:53 +00:00
10 changed files with 172 additions and 450 deletions

No files matched your search

-4
View File
@@ -138,10 +138,6 @@ press keys, then assert on the result.
A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed, A new `.json` file in that folder is picked up automatically. To record a bug before it's fixed,
put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug. put `{"pending": "why"}` in the scenario, and remove it in the PR that fixes the bug.
**A failing test without its fix can't merge as-is:** it would turn `main`'s CI red for everyone.
Either include the fix in the same PR, or mark the test `#[ignore = "fails until #123 is fixed"]`
(it can then merge right away), and remove that line in the PR that fixes the bug.
**Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables, **Use the `ci` profile.** CI runs the tests with `--profile ci`: light optimisation, line tables,
no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It no LTO, and debug assertions off (truck has debug-only assertions that reject valid geometry). It
builds several times faster than `--release`. If a test fails only in a plain debug build, say so builds several times faster than `--release`. If a test fails only in a plain debug build, say so
-6
View File
@@ -63,9 +63,6 @@ headline counts in-scope tabs only and lists the deferred ones separately.
## Dimensioning and error reporting ## Dimensioning and error reporting
- Repeating the initial point of a planar multi-point sketch drawing tool is neutral: the tool
waits for a distinct point without a degenerate-shape error or history entry. Typed input
and programmatic commands retain their validation.
- Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints. - Single-line and rectangle-side dimensioning supports selecting a line or its two endpoints.
Type a value and press Enter to commit one undo step; rejected values remain editable and Type a value and press Enter to commit one undo step; rejected values remain editable and
cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette cancellation leaves the sketch unchanged. Fit keeps geometry clear of the Sketch Palette
@@ -295,9 +292,6 @@ headline counts in-scope tabs only and lists the deferred ones separately.
continuous collision detection; local thin walls and small details remain limitations. continuous collision detection; local thin walls and small details remain limitations.
See [contact-set limits and recovery](docs/contact-sets.md). See [contact-set limits and recovery](docs/contact-sets.md).
Feature dialog header chevrons collapse and restore the form without discarding selections
or edited values, including the Appearance panel.
## Document-opening performance ## Document-opening performance
- Cold rebuild profiling separates parsing, geometry, placement, display meshing and - Cold rebuild profiling separates parsing, geometry, placement, display meshing and
-3
View File
@@ -21,9 +21,6 @@ mod fuzz;
mod oracle; mod oracle;
mod recipe; mod recipe;
mod seams; mod seams;
// Geometry comparison helpers; not called from the CLI commands yet.
#[cfg_attr(not(test), allow(dead_code))]
mod spatial;
use std::process::ExitCode; use std::process::ExitCode;
-333
View File
@@ -1,333 +0,0 @@
//! Geometry comparison helpers: an STL reader, bounding boxes, volume, centre of mass and principal
//! second moments of a closed triangle mesh, and a sampled symmetric Hausdorff distance between two
//! triangle meshes.
use std::collections::HashMap;
/// A triangle soup in mm.
pub type Tri = [[f64; 3]; 3];
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn add(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
fn scale(a: [f64; 3], s: f64) -> [f64; 3] {
[a[0] * s, a[1] * s, a[2] * s]
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
}
/// Read a binary (or ASCII) STL file.
pub fn read_stl(path: &str) -> Result<Vec<Tri>, String> {
let b = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
if b.len() >= 84 {
let n = u32::from_le_bytes([b[80], b[81], b[82], b[83]]) as usize;
if b.len() == 84 + 50 * n {
let f = |o: usize| f64::from(f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]));
return Ok((0..n)
.map(|i| {
let o = 84 + 50 * i + 12;
let v = |k: usize| [f(o + 12 * k), f(o + 12 * k + 4), f(o + 12 * k + 8)];
[v(0), v(1), v(2)]
})
.collect());
}
}
let text = String::from_utf8_lossy(&b);
let mut verts = Vec::new();
for line in text.lines() {
let mut it = line.split_whitespace();
if it.next() == Some("vertex") {
let c: Vec<f64> = it.filter_map(|x| x.parse().ok()).collect();
if c.len() == 3 {
verts.push([c[0], c[1], c[2]]);
}
}
}
if verts.is_empty() || verts.len() % 3 != 0 {
return Err(format!("{path}: not an STL file"));
}
Ok(verts.chunks(3).map(|c| [c[0], c[1], c[2]]).collect())
}
/// Volume, centre of mass and the principal second moments of volume (mm^5, sorted ascending) of a
/// closed, outward-oriented triangle soup, by signed tetrahedra from the origin.
pub fn mass_properties(tris: &[Tri]) -> (f64, [f64; 3], [f64; 3]) {
let (mut v6, mut m1) = (0.0, [0.0; 3]);
let mut c = [[0.0; 3]; 3]; // ∫ x xᵀ dV
for t in tris {
let d = dot(t[0], cross(t[1], t[2]));
v6 += d;
let s = add(add(t[0], t[1]), t[2]);
m1 = add(m1, scale(s, d / 24.0));
for (i, row) in c.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
let vv: f64 = t.iter().map(|p| p[i] * p[j]).sum::<f64>() + s[i] * s[j];
*cell += d / 120.0 * vv;
}
}
}
let vol = v6 / 6.0;
if vol.abs() < 1e-12 {
return (0.0, [0.0; 3], [0.0; 3]);
}
let com = scale(m1, 1.0 / vol);
// Central covariance and inertia tensor I = tr(C)·1 − C.
let mut cc = c;
for (i, row) in cc.iter_mut().enumerate() {
for (j, cell) in row.iter_mut().enumerate() {
*cell -= vol * com[i] * com[j];
}
}
let tr = cc[0][0] + cc[1][1] + cc[2][2];
let mut inertia = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
inertia[i][j] = if i == j { tr - cc[i][j] } else { -cc[i][j] };
}
}
let mut ev = eigen_sym3(inertia);
ev.sort_by(f64::total_cmp);
(vol, com, ev)
}
/// Eigenvalues of a symmetric 3×3 matrix (Jacobi rotations).
fn eigen_sym3(mut a: [[f64; 3]; 3]) -> [f64; 3] {
for _ in 0..50 {
let (mut p, mut q, mut big) = (0, 1, 0.0);
for (i, j) in [(0, 1), (0, 2), (1, 2)] {
if a[i][j].abs() > big {
(p, q, big) = (i, j, a[i][j].abs());
}
}
if big <= 1e-15 * (a[0][0].abs() + a[1][1].abs() + a[2][2].abs()).max(1e-300) {
break;
}
let theta = (a[q][q] - a[p][p]) / (2.0 * a[p][q]);
let t = theta.signum() / (theta.abs() + (theta * theta + 1.0).sqrt());
let t = if theta == 0.0 { 1.0 } else { t };
let (cs, sn) = (1.0 / (t * t + 1.0).sqrt(), t / (t * t + 1.0).sqrt());
let mut b = a;
for k in 0..3 {
b[k][p] = cs * a[k][p] - sn * a[k][q];
b[k][q] = sn * a[k][p] + cs * a[k][q];
}
let mut c2 = b;
for k in 0..3 {
c2[p][k] = cs * b[p][k] - sn * b[q][k];
c2[q][k] = sn * b[p][k] + cs * b[q][k];
}
a = c2;
}
[a[0][0], a[1][1], a[2][2]]
}
/// Axis-aligned bounds of a triangle soup.
pub fn bounds(tris: &[Tri]) -> ([f64; 3], [f64; 3]) {
let (mut lo, mut hi) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
for p in tris.iter().flatten() {
for k in 0..3 {
lo[k] = lo[k].min(p[k]);
hi[k] = hi[k].max(p[k]);
}
}
(lo, hi)
}
/// Distance from a point to a triangle (Ericson, Real-Time Collision Detection 5.1.5).
fn point_tri(p: [f64; 3], t: &Tri) -> f64 {
let (a, b, c) = (t[0], t[1], t[2]);
let (ab, ac, ap) = (sub(b, a), sub(c, a), sub(p, a));
let (d1, d2) = (dot(ab, ap), dot(ac, ap));
let q = if d1 <= 0.0 && d2 <= 0.0 {
a
} else {
let bp = sub(p, b);
let (d3, d4) = (dot(ab, bp), dot(ac, bp));
if d3 >= 0.0 && d4 <= d3 {
b
} else {
let vc = d1 * d4 - d3 * d2;
if vc <= 0.0 && d1 >= 0.0 && d3 <= 0.0 {
add(a, scale(ab, d1 / (d1 - d3)))
} else {
let cp = sub(p, c);
let (d5, d6) = (dot(ab, cp), dot(ac, cp));
if d6 >= 0.0 && d5 <= d6 {
c
} else {
let vb = d5 * d2 - d1 * d6;
if vb <= 0.0 && d2 >= 0.0 && d6 <= 0.0 {
add(a, scale(ac, d2 / (d2 - d6)))
} else {
let va = d3 * d6 - d5 * d4;
if va <= 0.0 && (d4 - d3) >= 0.0 && (d5 - d6) >= 0.0 {
add(b, scale(sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))))
} else {
let den = va + vb + vc;
if den.abs() < 1e-300 { a } else { add(a, add(scale(ab, vb / den), scale(ac, vc / den))) }
}
}
}
}
}
};
let d = sub(p, q);
dot(d, d).sqrt()
}
/// Uniform grid of triangles for distance queries up to `cap`.
struct Grid<'a> {
tris: &'a [Tri],
cell: f64,
cells: HashMap<(i64, i64, i64), Vec<u32>>,
}
impl<'a> Grid<'a> {
fn new(tris: &'a [Tri], cell: f64) -> Self {
let mut cells: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::new();
let key = |x: f64| (x / cell).floor() as i64;
for (i, t) in tris.iter().enumerate() {
let (lo, hi) = bounds(std::slice::from_ref(t));
for x in key(lo[0])..=key(hi[0]) {
for y in key(lo[1])..=key(hi[1]) {
for z in key(lo[2])..=key(hi[2]) {
cells.entry((x, y, z)).or_default().push(i as u32);
}
}
}
}
Grid { tris, cell, cells }
}
/// Distance from `p` to the nearest triangle, or `cap` if none is that close. Searches shells
/// of cells outwards and stops once the nearest hit is closer than the unsearched shells.
fn distance(&self, p: [f64; 3], cap: f64, seen: &mut [u32], stamp: u32) -> f64 {
let rmax = (cap / self.cell).ceil() as i64;
let k = |x: f64| (x / self.cell).floor() as i64;
let (cx, cy, cz) = (k(p[0]), k(p[1]), k(p[2]));
let mut best = cap;
for r in 0..=rmax {
for x in cx - r..=cx + r {
for y in cy - r..=cy + r {
for z in cz - r..=cz + r {
// only the shell at Chebyshev distance r
if (x - cx).abs() != r && (y - cy).abs() != r && (z - cz).abs() != r {
continue;
}
let Some(list) = self.cells.get(&(x, y, z)) else { continue };
for &i in list {
let Some(slot) = seen.get_mut(i as usize) else { continue };
if *slot == stamp {
continue;
}
*slot = stamp;
if let Some(t) = self.tris.get(i as usize) {
best = best.min(point_tri(p, t));
}
}
}
}
}
// Everything not yet searched is at least r·cell away from p.
if best <= r as f64 * self.cell {
break;
}
}
best
}
}
/// At most this many sample points per side (larger meshes are sampled with an even stride).
const MAX_SAMPLES: usize = 250_000;
/// Sample points of a triangle soup: its vertices and triangle centroids (every k-th triangle for
/// very large meshes).
fn samples(tris: &[Tri]) -> Vec<[f64; 3]> {
let stride = (tris.len() * 4).div_ceil(MAX_SAMPLES).max(1);
let mut out: Vec<[f64; 3]> = Vec::with_capacity(tris.len() * 4 / stride + 4);
for t in tris.iter().step_by(stride) {
out.extend_from_slice(t);
out.push(scale(add(add(t[0], t[1]), t[2]), 1.0 / 3.0));
}
out
}
/// One-sided sampled distance from `from` to `to`: (max, mean, worst sample point). Distances are
/// capped at `cap`.
fn one_sided(from: &[Tri], to: &[Tri], cap: f64) -> (f64, f64, [f64; 3]) {
let (lo, hi) = bounds(to);
let diag = dot(sub(hi, lo), sub(hi, lo)).sqrt();
// Cells about twice the typical triangle size (so a cell holds a handful of triangles), but no
// finer than diag/400 (so big flat triangles don't fill too many cells).
let area: f64 = to.iter().map(|t| dot(cross(sub(t[1], t[0]), sub(t[2], t[0])), cross(sub(t[1], t[0]), sub(t[2], t[0]))).sqrt() / 2.0).sum();
let typical = (area / to.len().max(1) as f64).sqrt();
let grid = Grid::new(to, (2.0 * typical).max(diag / 400.0).max(1e-3));
let mut seen = vec![0u32; to.len()];
let (mut max, mut sum, mut worst, mut n) = (0.0f64, 0.0, [0.0; 3], 0usize);
for (i, p) in samples(from).into_iter().enumerate() {
let d = grid.distance(p, cap, &mut seen, i as u32 + 1);
sum += d;
n += 1;
if d > max {
max = d;
worst = p;
}
}
(max, if n > 0 { sum / n as f64 } else { 0.0 }, worst)
}
/// Sampled symmetric Hausdorff distance between two triangle soups (distances capped at `cap`).
pub struct Hausdorff {
pub a_to_b: f64,
pub b_to_a: f64,
pub mean: f64,
/// Where the larger one-sided distance was found (mm, world).
pub worst_at: [f64; 3],
}
pub fn hausdorff(a_mesh: &[Tri], b_mesh: &[Tri], cap: f64) -> Hausdorff {
let (a, am, ap) = one_sided(a_mesh, b_mesh, cap);
let (b, bm, bp) = one_sided(b_mesh, a_mesh, cap);
Hausdorff { a_to_b: a, b_to_a: b, mean: (am + bm) / 2.0, worst_at: if a >= b { ap } else { bp } }
}
#[cfg(test)]
mod tests {
use super::*;
fn cube(s: f64, o: [f64; 3]) -> Vec<Tri> {
let v = |x: f64, y: f64, z: f64| [o[0] + x * s, o[1] + y * s, o[2] + z * s];
let q = |a: [f64; 3], b: [f64; 3], c: [f64; 3], d: [f64; 3]| vec![[a, b, c], [a, c, d]];
let mut t = Vec::new();
t.extend(q(v(0., 0., 0.), v(0., 1., 0.), v(1., 1., 0.), v(1., 0., 0.)));
t.extend(q(v(0., 0., 1.), v(1., 0., 1.), v(1., 1., 1.), v(0., 1., 1.)));
t.extend(q(v(0., 0., 0.), v(1., 0., 0.), v(1., 0., 1.), v(0., 0., 1.)));
t.extend(q(v(0., 1., 0.), v(0., 1., 1.), v(1., 1., 1.), v(1., 1., 0.)));
t.extend(q(v(0., 0., 0.), v(0., 0., 1.), v(0., 1., 1.), v(0., 1., 0.)));
t.extend(q(v(1., 0., 0.), v(1., 1., 0.), v(1., 1., 1.), v(1., 0., 1.)));
t
}
#[test]
fn box_moments_and_distance() {
let c = cube(10.0, [5.0, -3.0, 2.0]);
let (v, com, pm) = mass_properties(&c);
assert!((v - 1000.0).abs() < 1e-9 && (com[0] - 10.0).abs() < 1e-9 && (com[2] - 7.0).abs() < 1e-9);
// V·(a²+b²)/12 for a cube of side 10: 1000·200/12.
for m in pm {
assert!((m - 1000.0 * 200.0 / 12.0).abs() < 1e-6, "{pm:?}");
}
let moved = cube(10.0, [5.0, -3.0, 2.3]);
let h = hausdorff(&c, &moved, 1.0);
assert!((h.a_to_b - 0.3).abs() < 1e-9 && (h.b_to_a - 0.3).abs() < 1e-9);
let same = hausdorff(&c, &c, 1.0);
assert!(same.a_to_b < 1e-12);
}
}
+1 -11
View File
@@ -1326,17 +1326,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
d.collapsed = !d.collapsed; d.collapsed = !d.collapsed;
} }
ui.label(RichText::new(heading.as_str()).strong().size(12.5)); ui.label(RichText::new(heading.as_str()).strong().size(12.5));
let chevron_width = ui.spacing().interact_size.x; crate::frame::right_aligned(ui, total, 8.0, |ui| ui.label(RichText::new("»").color(t.text_dim)));
crate::frame::right_aligned(ui, total, chevron_width, |ui| {
let chevron = if d.collapsed { "«" } else { "»" };
if ui
.add(egui::Button::new(RichText::new(chevron).color(t.text_dim)).frame(false).min_size(vec2(chevron_width, 0.0)))
.on_hover_text(if d.collapsed { "Expand" } else { "Collapse" })
.clicked()
{
d.collapsed = !d.collapsed;
}
});
}); });
if d.collapsed { if d.collapsed {
return; return;
-21
View File
@@ -594,27 +594,6 @@ mod tests {
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2")); assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
} }
#[test]
fn header_chevron_folds_and_restores_appearance_inputs() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
h.app.start("appearance.assign");
h.frames(3);
let before = h.app.dialog.clone().unwrap();
h.step(&json!({"widget":"»"})).unwrap();
assert!(h.app.dialog.as_ref().unwrap().collapsed);
assert!(h.widget("Appearance name").is_none());
h.step(&json!({"widget":"«"})).unwrap();
let after = h.app.dialog.as_ref().unwrap();
assert!(!after.collapsed);
assert_eq!(format!("{:?}", after.inputs), format!("{:?}", before.inputs));
let (Kind::Appearance(before), Kind::Appearance(after)) = (&before.kind, &after.kind) else { panic!("appearance dialog") };
assert_eq!(before.color, after.color);
assert_eq!(before.opacity, after.opacity);
assert_eq!(before.search, after.search);
}
#[test] #[test]
fn real_frames_and_unchanged_apply_keep_precise_opacity() { fn real_frames_and_unchanged_apply_keep_precise_opacity() {
let mut h = crate::scenario::Harness::new(); let mut h = crate::scenario::Harness::new();
@@ -1,44 +0,0 @@
use crate::scenario::Harness;
use serde_json::json;
#[test]
fn repeated_first_point_is_neutral_and_drawing_can_continue() {
for (command, rest) in [
("sketch.rectangle.two_point", vec![[10, 5, 0]]),
("sketch.rectangle.center", vec![[10, 5, 0]]),
("sketch.rectangle.three_point", vec![[10, 0, 0], [10, 5, 0]]),
("sketch.circle.center", vec![[5, 0, 0]]),
("sketch.circle.three_point", vec![[10, 0, 0], [5, 5, 0]]),
("sketch.line", vec![[10, 5, 0]]),
("sketch.polygon.inscribed", vec![[10, 5, 0]]),
("sketch.slot.center_to_center", vec![[10, 0, 0], [10, 3, 0]]),
] {
let mut h = Harness::new();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.cam.distance = 50.0;
h.step(&json!({"start":command})).unwrap();
let before = h.app.session.doc.to_json();
let undo = h.app.session.undo.len();
let p = h.call("ui.at", json!({"sketch":[0,0]}))["result"].clone();
h.call("ui.click", json!({"x":p[0],"y":p[1],"double":true}));
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
assert_eq!(h.app.tool.as_ref().unwrap().pts.len(), 1, "{command}");
assert_eq!(h.app.session.doc.to_json(), before);
assert_eq!(h.app.session.undo.len(), undo);
for point in rest {
h.step(&json!({"move":{"world":point}})).unwrap();
if command == "sketch.slot.center_to_center" {
// The live width editor is placed at the cursor; Enter accepts it.
h.step(&json!({"key":"Enter"})).unwrap();
} else {
h.step(&json!({"click":{"world":point}})).unwrap();
}
}
assert!(!h.app.status.as_ref().is_some_and(|s| s.2), "{command}: {:?}", h.app.status);
let state = h.app.session.world_state();
assert!(!state.sketches[0].sketch.curves.is_empty(), "{command}: {:?}; {:?}", h.app.tool, h.app.status);
assert_eq!(h.app.session.undo.len(), undo + 1, "{command}");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc.to_json(), before, "{command}");
}
}
+95 -17
View File
@@ -542,29 +542,41 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
} else if cmds.is_empty() { } else if cmds.is_empty() {
ui.label(egui::RichText::new("Not available yet").color(t.text_dim)); ui.label(egui::RichText::new("Not available yet").color(t.text_dim));
} }
let row_w = dropdown_width(app, ui, &cmds);
// Up and Down move the keyboard focus through the enabled rows (from none
// focused: the first or the last), instead of egui's own spatial move.
let (down, up) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp)));
if down || up {
let enabled: Vec<&str> = cmds.iter().filter(|c| c.info(&app.session).enabled).map(|c| c.id).collect();
let focused = ui.ctx().memory(|m| m.focused());
let at = enabled.iter().position(|id| focused == Some(dropdown_row_id(ui, id)));
let n = enabled.len();
let next = match (at, down) {
(None, true) => Some(0),
(None, false) => n.checked_sub(1),
(Some(i), true) => Some((i + 1) % n.max(1)),
(Some(i), false) => Some((i + n.max(1) - 1) % n.max(1)),
};
if let Some(id) = next.and_then(|i| enabled.get(i)) {
ui.ctx().memory_mut(|m| {
m.request_focus(dropdown_row_id(ui, id));
m.move_focus(egui::FocusDirection::None);
});
}
}
for c in &cmds { for c in &cmds {
let info = c.info(&app.session); match dropdown_row(app, ui, c, row_w) {
ui.horizontal(|ui| { RowAction::None => {}
let (ir, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); RowAction::Menu(at) => menu = Some((at, c.id)),
icons::paint(ui.painter(), ir, c.icon, t.icon, t.icon_fill, t.accent); RowAction::Start => {
let b = ui.add_enabled( app.start(c.id);
info.enabled,
egui::Button::new(app.tr_label(c.label)).frame(false).shortcut_text(c.shortcut.unwrap_or("")),
);
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), b.rect.center());
if b.secondary_clicked() {
menu = Some((b.interact_pointer_pos().unwrap_or(b.rect.left_bottom()), c.id));
}
if b.clicked() {
let id = c.id;
app.start(id);
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open"))); ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
} }
}); }
} }
}); });
}); });
if area.response.clicked_elsewhere() && !lresp.clicked() { if (area.response.clicked_elsewhere() && !lresp.clicked()) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open"))); ui.ctx().data_mut(|d| d.remove::<egui::Id>(egui::Id::new("sc_panel_open")));
} }
} }
@@ -608,6 +620,72 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
}); });
} }
/// What a click on a panel drop-down row asks for.
enum RowAction {
None,
Start,
Menu(egui::Pos2),
}
const ROW_H: f32 = 24.0;
/// The id of a drop-down row (for keyboard focus).
fn dropdown_row_id(ui: &egui::Ui, id: &str) -> egui::Id {
ui.id().with(("dropdown_row", id))
}
/// How wide a panel drop-down's rows are: the widest label and shortcut, at least 200.
fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solvecraft_engine::CommandSpec]) -> f32 {
let t = Tokens::get();
let font = FontId::proportional(13.0);
cmds.iter()
.map(|c| {
let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x;
let key = c.shortcut.map(|k| ui.painter().layout_no_wrap(k.to_string(), font.clone(), t.text).size().x + 24.0).unwrap_or(0.0);
6.0 + 18.0 + 8.0 + label + key + 30.0
})
.fold(200.0, f32::max)
}
/// One command row of a panel drop-down: icon, label and shortcut, with the hover background
/// under the pointer or keyboard focus (like every menu).
fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solvecraft_engine::CommandSpec, width: f32) -> RowAction {
let t = Tokens::get();
let info = c.info(&app.session);
let (rect, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
let sense = if info.enabled { Sense::click() } else { Sense::hover() };
let resp = ui.interact(rect, dropdown_row_id(ui, c.id), sense);
let label = app.tr_label(c.label).to_string();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled() && info.enabled, &label));
crate::scenario::publish_handle(&format!("toolbar:{}", c.id), rect.center());
crate::scenario::publish_handle(&format!("dropdown:{}", c.id), rect.center());
let hot = info.enabled && (resp.hovered() || resp.has_focus());
let p = ui.painter();
if hot {
p.rect_filled(rect, 3.0, t.hover);
crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center());
}
let (ink, fill, acc, text) =
if info.enabled { (t.icon, t.icon_fill, t.accent, t.text) } else { (t.border, t.panel_header, t.border, t.text_dim) };
let ir = Rect::from_center_size(pos2(rect.left() + 6.0 + 9.0, rect.center().y), vec2(18.0, 18.0));
icons::paint(p, ir, c.icon, ink, fill, acc);
p.text(pos2(ir.right() + 8.0, rect.center().y), Align2::LEFT_CENTER, &label, FontId::proportional(13.0), text);
if let Some(k) = c.shortcut {
p.text(pos2(rect.right() - 8.0, rect.center().y), Align2::RIGHT_CENTER, k, FontId::proportional(13.0), t.text_dim);
}
let resp = match &info.disabled_reason {
Some(why) if !info.enabled => resp.on_hover_text(why),
_ => resp,
};
if resp.secondary_clicked() {
return RowAction::Menu(resp.interact_pointer_pos().unwrap_or(rect.left_bottom()));
}
if resp.clicked() && info.enabled {
return RowAction::Start;
}
RowAction::None
}
/// A toolbar button being dragged (its command id). /// A toolbar button being dragged (its command id).
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
struct ToolbarDrag(&'static str); struct ToolbarDrag(&'static str);
+1 -11
View File
@@ -12,10 +12,6 @@ use crate::viewport::{Hit, Proj, pick, sketch_point_at};
#[path = "sketch_display_preview_tests.rs"] #[path = "sketch_display_preview_tests.rs"]
mod display_preview_tests; mod display_preview_tests;
#[cfg(test)]
#[path = "sketch_neutral_click_tests.rs"]
mod neutral_click_tests;
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind { pub enum Kind {
/// Click points; `n` points complete the shape (0 = chain until finished). /// Click points; `n` points complete the shape (0 = chain until finished).
@@ -148,18 +144,12 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
return; return;
} }
match tool.kind { match tool.kind {
Kind::Draw(n) => { Kind::Draw(_) => {
if let Some(mut p) = sketch_point_at(app, proj, pos) { if let Some(mut p) = sketch_point_at(app, proj, pos) {
// Typed values win over the cursor; lines snap to horizontal/vertical. // Typed values win over the cursor; lines snap to horizontal/vertical.
if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) { if tool.dims.iter().any(|b| b.locked) || (tool.cmd == "sketch.line" && p.1.is_none()) {
p = (crate::sketch_dims::effective(app, &tool, p.0), None); p = (crate::sketch_dims::effective(app, &tool, p.0), None);
} }
// A repeated initial point supplies no extent. Keep waiting rather than
// attempting a degenerate shape; explicit typed values still validate.
if n != 1 && tool.pts.len() == 1 && tool.pts.first().is_some_and(|first| first.0 == p.0) && !tool.dims.iter().any(|b| b.locked) {
app.tool = Some(tool);
return;
}
app.tool = Some(tool); app.tool = Some(tool);
place(app, p); place(app, p);
return; return;
@@ -0,0 +1,75 @@
[
{
"note": "#288: a panel drop-down highlights the row under the pointer with the hover background, and the row with keyboard focus. Drop-down rows publish `dropdown:<command id>`, the highlighted one also `dropdown_hot:<command id>`."
},
{
"click": {
"handle": "panel:SOLID:CREATE"
}
},
{
"expect": {
"shown_handles": ["dropdown:solid.box", "dropdown:solid.sphere"],
"hidden_handles": ["dropdown_hot:solid.box", "dropdown_hot:solid.sphere"]
}
},
{
"move": {
"handle": "dropdown:solid.box"
}
},
{
"expect": {
"shown_handles": ["dropdown_hot:solid.box"],
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.cylinder"]
}
},
{
"move": {
"handle": "dropdown:solid.sphere"
}
},
{
"expect": {
"shown_handles": ["dropdown_hot:solid.sphere"],
"hidden_handles": ["dropdown_hot:solid.box"]
}
},
{
"move": [700, 600]
},
{
"expect": {
"shown_handles": ["dropdown:solid.box"],
"hidden_handles": ["dropdown_hot:solid.sphere", "dropdown_hot:solid.box"]
}
},
{
"note": "The keyboard: Down focuses the first row, Down again the next; the highlight follows."
},
{
"key": "ArrowDown"
},
{
"expect": {
"shown_handles": ["dropdown_hot:sketch.create"]
}
},
{
"key": "ArrowDown"
},
{
"expect": {
"shown_handles": ["dropdown_hot:solid.extrude"],
"hidden_handles": ["dropdown_hot:sketch.create"]
}
},
{
"key": "Enter"
},
{
"expect": {
"hidden_handles": ["dropdown:solid.box"]
}
}
]