diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index d3591bdd..a7e3beb2 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -442,7 +442,6 @@ mod tests { } #[test] - #[ignore = "waits for unit-less parameters used as lengths to read in the design's units (solvecraft-params)"] fn a_bare_width_keeps_its_inches_when_the_design_goes_metric() { let mut s = Session::default(); run(&mut s, "document.units", json!({"units": "in"})); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index ca93c781..ab45a983 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -17,6 +17,17 @@ use crate::context_menu::{Item, Target}; use crate::theme::Tokens; use crate::{SolveApp, icons}; +thread_local! { + /// The rows drawn this frame: (label, row, fold arrow area, open). For automation + /// (`ui.at {browser | chevron}`, `ui.browser`). + static DRAWN: std::cell::RefCell, Option)>> = const { std::cell::RefCell::new(Vec::new()) }; +} + +/// The rows on screen: (label, row rect, fold arrow area, open). +pub fn drawn_rows() -> Vec<(String, Rect, Option, Option)> { + DRAWN.with(|d| d.borrow().clone()) +} + /// Browser state kept between frames. #[derive(Default)] pub struct TreeState { @@ -246,6 +257,8 @@ struct RowResp { hovered: bool, /// The row's response (drag and drop). resp: Option, + /// The press began on the fold arrow, eye or radio (not a drag of the row). + on_control: bool, } fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { @@ -256,7 +269,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { ui.allocate_space(r.size()); return RowResp { rect: Some(r), ..Default::default() }; } - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + // A stable id per row (not the layout position), so a press and its release stay on the + // same row while rows above open or close. + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::hover()); + let resp = ui.interact(r, id, Sense::click_and_drag()); // While something is dragged (an appearance swatch, drawn under the pointer) `hovered` is // off; the row under the pointer still counts, so a drop lands on it. let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); @@ -269,6 +285,27 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let mut x = r.left() + 2.0 + row.depth as f32 * INDENT; let cy = r.center().y; let ink = if row.dim { t.text_dim } else { t.icon }; + // The row is one widget: where a click lands decides what it does (the fold arrow with + // padding, the eye, the radio, or the row itself), so a click on the arrow never also + // selects the row or starts a drag. + let fold_r = Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom())); + let eye_r = Rect::from_center_size(pos2(x + 14.0 + 8.0, cy), vec2(19.0, r.height())); + let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(15.0, r.height())); + let at = |p: Pos2| -> u8 { + if row.fold.is_some() && fold_r.contains(p) { + 1 + } else if row.eye.is_some() && eye_r.contains(p) { + 2 + } else if row.radio.is_some() && radio_r.contains(p) { + 3 + } else { + 0 + } + }; + DRAWN.with(|d| d.borrow_mut().push((row.label.to_string(), r, row.fold.map(|_| fold_r), row.fold))); + let press_on = ui.input(|i| i.pointer.press_origin()).map_or(0, at); + let click_on = resp.interact_pointer_pos().map_or(0, at); + let hover_on = resp.hover_pos().map_or(0, at); // Fold arrow. if let Some(open) = row.fold { let ar = Rect::from_center_size(pos2(x + 6.0, cy), vec2(12.0, 12.0)); @@ -277,9 +314,10 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { } else { vec![pos2(ar.left() + 4.0, ar.top() + 2.0), pos2(ar.right() - 3.0, ar.center().y), pos2(ar.left() + 4.0, ar.bottom() - 2.0)] }; - ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE)); - out.fold = ui.interact(ar.expand(3.0), id.with("fold"), Sense::click()).clicked(); - crate::scenario::publish_handle(&format!("fold:{}", row.label), ar.center()); + let ink = if hover_on == 1 { t.text } else { t.text_dim }; + ui.painter().add(egui::Shape::convex_polygon(pts, ink, Stroke::NONE)); + out.fold = resp.clicked() && click_on == 1; + crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center()); } x += 14.0; // Eye. @@ -289,9 +327,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { if !vis { ui.painter().line_segment([er.left_bottom() + vec2(2.0, -2.0), er.right_top() + vec2(-2.0, 2.0)], Stroke::new(1.4, t.text_dim)); } - let e = ui.interact(er.expand(2.0), id.with("eye"), Sense::click()); - out.eye = e.clicked(); - e.on_hover_text(if vis { "Hide" } else { "Show" }); + out.eye = resp.clicked() && click_on == 2; } x += 18.0; // Activation radio (components). @@ -301,9 +337,7 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { if active { ui.painter().circle_filled(rc, 2.8, t.accent); } - let rr = ui.interact(Rect::from_center_size(rc, vec2(14.0, 14.0)), id.with("radio"), Sense::click()); - out.radio = rr.clicked(); - rr.on_hover_text(if active { "Active component" } else { "Activate" }); + out.radio = resp.clicked() && click_on == 3; x += 15.0; } if !row.icon.is_empty() { @@ -322,9 +356,20 @@ fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { bx += 15.0; } crate::scenario::publish_handle(&format!("row:{}", row.label), r.center()); + let tip = match hover_on { + 1 => Some(if row.fold == Some(true) { "Collapse" } else { "Expand" }), + 2 => Some(if row.eye == Some(true) { "Hide" } else { "Show" }), + 3 => Some(if row.radio == Some(true) { "Active component" } else { "Activate" }), + _ => None, + }; + let resp = match tip { + Some(t) => resp.on_hover_text(t), + None => resp, + }; + out.on_control = press_on != 0; + out.clicked = resp.clicked() && click_on == 0; + out.double = resp.double_clicked() && click_on == 0; out.resp = Some(resp.clone()); - out.clicked = resp.clicked(); - out.double = resp.double_clicked(); if resp.secondary_clicked() { out.secondary = resp.interact_pointer_pos().or(Some(r.left_bottom())); } @@ -424,6 +469,7 @@ struct Actions { pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) { let t = Tokens::get(); app.tree.order.clear(); + DRAWN.with(|d| d.borrow_mut().clear()); let mut acts = Actions::default(); egui::Panel::left("sc_browser") .exact_size(250.0) @@ -1227,7 +1273,7 @@ fn entry_row( } let Some(resp) = &r.resp else { return }; // Drag: the selected items of this folder when this one is selected, else just this one. - if resp.drag_started() { + if resp.drag_started() && !r.on_control { let keys: Vec = if e.selected { selected_keys(app, comp, folder) } else { vec![e.key.clone()] }; resp.dnd_set_drag_payload(Drag { comp, folder, keys }); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3031d003..4b03fe61 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -287,6 +287,20 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> None => err(format!("handle {h} is not shown")), }; } + // A browser row by its label: the row itself, or its fold arrow. + for (k, chevron) in [("browser", false), ("chevron", true)] { + if let Some(label) = p.get(k).and_then(Value::as_str) { + let rows = crate::browser::drawn_rows(); + let found = rows + .iter() + .find(|r| r.0 == label) + .and_then(|r| if chevron { r.2.map(|f| f.center()) } else { Some(r.1.left_center() + egui::vec2(120.0, 0.0)) }); + return match found { + Some(q) => ok(json!([q.x, q.y])), + None => err(format!("no browser row `{label}`{} on screen", if chevron { " with a fold arrow" } else { "" })), + }; + } + } if let Some(d) = p.get("dimension").and_then(Value::as_str) { return match crate::dim_view::text_at(d) { Some(q) => ok(json!([q.x, q.y])), @@ -426,6 +440,11 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> None => ok(json!({"command": id, "key": crate::keymap::effective(app, id)})), } } + "ui.browser" => ok(json!({ + "rows": crate::browser::drawn_rows().iter().map(|(label, r, _, open)| json!({"label": label, "y": r.center().y, "open": open})).collect::>(), + "selection": app.session.selection, + "picked_components": app.tree.picked_components, + })), "ui.capture" => match s("action") { Some(a) => match crate::browser::answer_capture(app, a) { Ok(()) => ok(json!({"pending_moves": app.session.pending_moves.len()})), diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 87a848bf..183c1c8a 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -24,7 +24,8 @@ //! - `{"camera": {"yaw": rad, "pitch": rad}}` puts the camera at an orbit; `{"view_anim": "top", //! "max_deg": 10}` animates to a view and fails if any frame turns the view more than that //! - AT may also be `{"plane": "XY"}` (the middle of an origin plane's square), `{"axis": "Z"}`, -//! `{"dimension": "d1"}` or `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]) +//! `{"dimension": "d1"}`, `{"handle": "arrow"}` (a manipulator handle: see [`publish_handle`]), +//! `{"browser": "Bodies"}` (a browser row by its label) or `{"chevron": "Bodies"}` (its fold arrow) //! - `{"autosave": true}`: autosave into the scenario's recovery folder; `{"restart": "crash" | //! "close"}`: the app dies (or closes) and a new one starts on the same folders //! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a @@ -864,6 +865,27 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("design name: got {}, want {v}", doc["name"])); } } + "browser_has" | "browser_lacks" => { + // Browser rows on screen, by label. + let r = h.call("ui.browser", json!({})); + let labels: Vec = + r["result"]["rows"].as_array().into_iter().flatten().filter_map(|x| x["label"].as_str().map(str::to_string)).collect(); + for want in v.as_array().into_iter().flatten().filter_map(Value::as_str) { + let there = labels.iter().any(|l| l == want); + if there != (k.as_str() == "browser_has") { + return Err(format!("browser row `{want}` {}: rows {labels:?}", if there { "still shown" } else { "missing" })); + } + } + } + "browser_selected" => { + // Nothing selected and no component picked (a fold click selects nothing). + let r = h.call("ui.browser", json!({})); + let none = r["result"]["selection"].as_array().is_none_or(|a| a.is_empty()) + && r["result"]["picked_components"].as_array().is_none_or(|a| a.is_empty()); + if none == v.as_bool().unwrap_or(false) { + return Err(format!("browser selection: {}", r["result"])); + } + } "query" => { // {command, params?, path: [keys or indices], value}: a command's result at path. let cmd = v["command"].as_str().ok_or("query needs `command`")?; diff --git a/crates/ui-egui/tests/scenarios/browser_chevrons.json b/crates/ui-egui/tests/scenarios/browser_chevrons.json new file mode 100644 index 00000000..5a6b36a8 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/browser_chevrons.json @@ -0,0 +1,505 @@ +[ + { + "note": "A design with every kind of browser folder: bodies in a group, a component, a sketch, a construction plane, a canvas and named views." + }, + { + "call": "engine.execute", + "params": { + "command": "solid.box", + "params": { + "length": 10, + "width": 10, + "height": 10 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "solid.box", + "params": { + "length": 10, + "width": 10, + "height": 10, + "corner": [ + 30, + 0, + 0 + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "solid.box", + "params": { + "length": 10, + "width": 10, + "height": 10, + "corner": [ + 60, + 0, + 0 + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "component.from_bodies", + "params": { + "bodies": [ + "Body3" + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "browser.group", + "params": { + "folder": "bodies", + "items": [ + "Body1" + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "construct.plane.offset", + "params": { + "base": "XY", + "offset": 20 + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.create", + "params": { + "plane": "XZ" + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.rectangle.two_point", + "params": { + "p0": [ + 0, + 0 + ], + "p1": [ + 5, + 5 + ] + } + } + }, + { + "call": "engine.execute", + "params": { + "command": "sketch.finish", + "params": {} + } + }, + { + "make_image": "pic.png" + }, + { + "choose_file": "pic.png" + }, + { + "start": "canvas.insert" + }, + { + "click": { + "plane": "XY" + } + }, + { + "key": "Escape" + }, + { + "call": "ui.set", + "params": { + "dark": true + } + }, + { + "note": "Folders that start closed open with their arrow, and close again; the click selects nothing." + }, + { + "expect": { + "browser_lacks": [ + "Units: mm" + ] + } + }, + { + "click": { + "chevron": "Document Settings" + } + }, + { + "expect": { + "browser_has": [ + "Units: mm" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Document Settings" + } + }, + { + "expect": { + "browser_lacks": [ + "Units: mm" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_lacks": [ + "Top" + ] + } + }, + { + "click": { + "chevron": "Named Views" + } + }, + { + "expect": { + "browser_has": [ + "Top" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Named Views" + } + }, + { + "expect": { + "browser_lacks": [ + "Top" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_lacks": [ + "XY" + ] + } + }, + { + "click": { + "chevron": "Origin" + } + }, + { + "expect": { + "browser_has": [ + "XY" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Origin" + } + }, + { + "expect": { + "browser_lacks": [ + "XY" + ], + "browser_selected": false + } + }, + { + "note": "Folders that start open close with their arrow, and open again." + }, + { + "expect": { + "browser_has": [ + "Body1" + ] + } + }, + { + "click": { + "chevron": "Group1 (1)" + } + }, + { + "expect": { + "browser_lacks": [ + "Body1" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Group1 (1)" + } + }, + { + "expect": { + "browser_has": [ + "Body1" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_has": [ + "Sketch1" + ] + } + }, + { + "click": { + "chevron": "Sketches" + } + }, + { + "expect": { + "browser_lacks": [ + "Sketch1" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Sketches" + } + }, + { + "expect": { + "browser_has": [ + "Sketch1" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_has": [ + "Plane1" + ] + } + }, + { + "click": { + "chevron": "Construction" + } + }, + { + "expect": { + "browser_lacks": [ + "Plane1" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Construction" + } + }, + { + "expect": { + "browser_has": [ + "Plane1" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_has": [ + "Canvas1" + ] + } + }, + { + "click": { + "chevron": "Canvases" + } + }, + { + "expect": { + "browser_lacks": [ + "Canvas1" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Canvases" + } + }, + { + "expect": { + "browser_has": [ + "Canvas1" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_has": [ + "Body3" + ] + } + }, + { + "click": { + "chevron": "Body3:1" + } + }, + { + "expect": { + "browser_lacks": [ + "Body3" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Body3:1" + } + }, + { + "expect": { + "browser_has": [ + "Body3" + ], + "browser_selected": false + } + }, + { + "expect": { + "browser_has": [ + "Body2" + ] + } + }, + { + "click": { + "chevron": "Bodies" + } + }, + { + "expect": { + "browser_lacks": [ + "Body2" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Bodies" + } + }, + { + "expect": { + "browser_has": [ + "Body2" + ], + "browser_selected": false + } + }, + { + "note": "The design's own row closes the whole tree." + }, + { + "expect": { + "browser_has": [ + "Bodies" + ] + } + }, + { + "click": { + "chevron": "Untitled" + } + }, + { + "expect": { + "browser_lacks": [ + "Bodies" + ], + "browser_selected": false + } + }, + { + "click": { + "chevron": "Untitled" + } + }, + { + "expect": { + "browser_has": [ + "Bodies" + ], + "browser_selected": false + } + }, + { + "note": "Clicking the row of a folder (not its arrow) toggles it too; clicking a component's arrow doesn't pick it." + }, + { + "click": { + "browser": "Sketches" + } + }, + { + "expect": { + "browser_lacks": [ + "Sketch1" + ] + } + }, + { + "click": { + "browser": "Sketches" + } + }, + { + "expect": { + "browser_has": [ + "Sketch1" + ] + } + }, + { + "shot": "browser_folds" + } +] \ No newline at end of file