From c8da3b78f331e0b627f14069b42bf47d6a8a7e15 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 08:37:33 +0000 Subject: [PATCH] Browser: rows out of view take only their space (400 bodies: 1.95 ms to 0.72 ms per frame, docs/perf.md); V shows/hides what is selected (bodies, sketches, planes and axes, components, canvases), Show/Hide with V in every menu that has it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/browser.rs | 16 +++++---- crates/ui-egui/src/context_menu.rs | 57 ++++++++++++++++++++++++++---- crates/ui-egui/src/menu_tests.rs | 26 ++++++++++++-- crates/ui-egui/src/toolbar.rs | 1 + docs/perf.md | 15 ++++++++ 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index b1c44e30..bb17fcb3 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -110,9 +110,7 @@ pub fn group_items(app: &SolveApp, id: u64) -> Vec { let bodies = group_bodies(app, id); let hidden = !bodies.is_empty() && bodies.iter().all(|b| app.ui.hidden_bodies.contains(b)); v.push(Item::sep()); - v.push( - Item::action(if hidden { "ui.show" } else { "ui.hide" }, if hidden { "Show" } else { "Hide" }, "eye").with(json!({ "bodies": bodies })), - ); + v.push(Item::action(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": bodies }))); v.push(Item::action("ui.isolate", "Isolate", "").with(json!({ "bodies": bodies }))); } v @@ -137,7 +135,7 @@ pub fn folder_items(app: &SolveApp, component: u64, folder: &str) -> Vec { v.push(Item::action("ui.groupSelected", "Group Selected", "folder").with(json!({ "component": component, "folder": folder, "items": sel }))); } v.push(Item::sep()); - v.push(Item::action("ui.folderVisible", "Show/Hide", "eye").with(json!({ "component": component, "folder": folder }))); + v.push(Item::action("ui.folderVisible", "Show/Hide", "eye").key("V").with(json!({ "component": component, "folder": folder }))); v.push(Item::action("ui.showAll", "Show All", "eye")); v } @@ -147,7 +145,7 @@ pub fn origin_items(app: &SolveApp) -> Vec { let planes = ["XY", "XZ", "YZ"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p)); let axes = ["X", "Y", "Z"].iter().all(|p| !app.ui.hidden_origin.iter().any(|h| h == p)); vec![ - Item::action("ui.origin", "Show/Hide", "eye"), + Item::action("ui.origin", "Show/Hide", "eye").key("V"), Item::action("ui.originAll", "Show All", "eye"), Item::action("ui.originPart", if planes { "Hide Planes" } else { "Show Planes" }, "plane") .with(json!({ "keys": ["XY", "XZ", "YZ"], "show": !planes })), @@ -252,6 +250,12 @@ struct RowResp { fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let t = Tokens::get(); + // Rows scrolled out of view only take their space: no widgets, no painting. + let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), ROW_H)); + if !ui.is_rect_visible(r) { + 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()); let mut out = RowResp { rect: Some(r), hovered: resp.hovered(), ..Default::default() }; if row.selected { @@ -678,7 +682,7 @@ pub fn canvas_items(app: &SolveApp, id: u64) -> Vec { Item::action("ui.delete", "Delete", "delete").with(json!({ "canvases": [id] })), Item::action("ui.rename", "Rename", "").with(json!({ "canvas": id })), Item::sep(), - Item::action("ui.canvasVisible", if visible { "Hide" } else { "Show" }, "eye").with(json!({ "canvas": id, "visible": !visible })), + Item::action("ui.canvasVisible", "Show/Hide", "eye").key("V").with(json!({ "canvas": id, "visible": !visible })), ] } diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 963edd63..06b25191 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -89,7 +89,7 @@ impl Item { self.enabled &= enabled; self } - fn key(mut self, k: &str) -> Item { + pub fn key(mut self, k: &str) -> Item { self.shortcut = k.into(); self } @@ -319,7 +319,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] }))); v.push(Item::sep()); v.push(act("ui.delete", "Delete", "delete").key("Del")); - v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": [face] }))); + v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [face] }))); v.push(Item::sep()); v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": face }))); v.push(act("ui.findTimeline", "Find in Timeline", "").with(json!({ "body": face }))); @@ -333,7 +333,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "FusionChamferCommand", "Chamfer")); v.push(Item::sep()); v.push(act("ui.delete", "Delete", "delete").key("Del")); - v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": [body] }))); + v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [body] }))); v.push(Item::sep()); v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": body }))); v.push(Item::sep()); @@ -396,7 +396,7 @@ fn body_items(app: &SolveApp, bodies: &[String], browser: bool) -> Vec { cmd(app, "SoftDeleteCommand", "Remove").with(json!({ "bodies": names })).on(!locked), act("ui.rename", "Rename", "").with(json!({ "body": bodies.first() })).on(one), Item::sep(), - act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": names })), + act(if all_hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })), act("ui.lock", if locked { "Unlock" } else { "Lock" }, "").with(json!({ "bodies": names })), act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })), ]; @@ -430,7 +430,7 @@ fn sketch_items(app: &SolveApp, id: u64) -> Vec { act("ui.lookAt", "Look At", "").with(json!({ "sketch": id })), act("ui.sketchProfile", if profiles { "Hide Profile" } else { "Show Profile" }, "").with(json!({ "sketch": id })), act("ui.sketchDims", if dims { "Hide Dimension" } else { "Show Dimension" }, "dimension").with(json!({ "sketch": id })), - act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, "Show/Hide", "eye").with(json!({ "sketch": id })), + act(if visible { "ui.hideSketch" } else { "ui.showSketch" }, "Show/Hide", "eye").key("V").with(json!({ "sketch": id })), Item::sep(), act("ui.findTimeline", "Find in Timeline", "").with(json!({ "feature": id })), ] @@ -479,7 +479,7 @@ fn component_items(app: &SolveApp, id: u64) -> Vec { } v.push(act("ui.rename", "Rename", "").with(json!({ "component": id }))); v.push(Item::sep()); - v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").with(json!({ "bodies": names })).on(!bodies.is_empty())); + v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": names })).on(!bodies.is_empty())); v.push(act("ui.showAll", "Show All Bodies", "eye")); v.push(act("ui.isolate", "Isolate", "").with(json!({ "bodies": names })).on(!bodies.is_empty() && id != 0)); v.push(act("ui.newGroup", "New Group", "folder").with(json!({ "component": id }))); @@ -807,6 +807,51 @@ pub fn delete(app: &mut SolveApp) { crate::delete::delete_selection(app); } +/// Show/Hide (the V key): what is selected in the viewport or picked in the browser flips its +/// visibility (bodies, faces' and edges' bodies, sketches, construction and origin planes and +/// axes, components' bodies, canvases). When some are shown and some hidden, all are hidden. +pub fn toggle_visibility(app: &mut SolveApp) { + use solvecraft_engine::doc::FeatureKind; + let mut bodies: Vec = touched_bodies(app); + for c in app.tree.picked_components.clone() { + bodies.extend(component_bodies(app, c)); + } + let sketches: Vec = app + .session + .selection + .iter() + .filter_map(|s| if let Sel::Feature { id } = s { Some(*id) } else { None }) + .filter(|id| app.session.doc.feature(*id).is_some_and(|f| matches!(f.kind, FeatureKind::Sketch { .. }))) + .collect(); + let origin: Vec = app + .session + .selection + .iter() + .filter_map(|s| match s { + Sel::Plane { name } | Sel::Axis { name } => Some(name.clone()), + _ => None, + }) + .collect(); + let canvases = app.tree.picked_canvases.clone(); + let any_visible = bodies.iter().any(|b| !app.ui.hidden_bodies.contains(b)) + || sketches.iter().any(|s| crate::browser::sketch_visible(app, *s)) + || origin.iter().any(|o| !app.ui.hidden_origin.contains(o)) + || app.session.doc.canvases.iter().any(|c| canvases.contains(&c.id) && c.visible); + let hide = any_visible; + app.ui.hidden_bodies.retain(|b| !bodies.contains(b)); + app.ui.hidden_origin.retain(|o| !origin.contains(o)); + if hide { + app.ui.hidden_bodies.extend(bodies); + app.ui.hidden_origin.extend(origin); + } + for s in sketches { + crate::browser::set_sketch_visible(app, s, !hide); + } + for c in canvases { + let _ = app.run("canvas.edit", json!({ "canvas": c, "visible": !hide })); + } +} + /// Add the edges that continue the selected edges smoothly to the selection. fn tangent_chain(app: &mut SolveApp) { let st = app.session.model.state(); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 46721750..46f6ff7d 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -183,7 +183,7 @@ fn folder_and_group_menus_make_rename_and_drop_groups() { act(&mut app, &t, "Rename"); assert!(context_menu::finish_rename(&mut app, Some("Parts"), true)); assert_eq!(app.session.doc.browser_groups[0].name, "Parts"); - act(&mut app, &t, "Hide"); + act(&mut app, &t, "Show/Hide"); assert_eq!(app.ui.hidden_bodies.len(), 2); act(&mut app, &t, "Ungroup"); assert!(app.session.doc.browser_groups.is_empty()); @@ -263,7 +263,7 @@ fn canvas_menu_hides_renames_calibrates_and_deletes() { let data = solvecraft_engine::doc::canvas::base64_encode(&png); let id = app.run("FusionAddCanvasCommand", json!({"data": data, "plane": "XY", "width": 80})).unwrap()["canvas"].as_u64().unwrap(); let t = Target::Canvas { id }; - act(&mut app, &t, "Hide"); + act(&mut app, &t, "Show/Hide"); assert!(!app.session.doc.canvases[0].visible); act(&mut app, &t, "Rename"); assert!(context_menu::finish_rename(&mut app, Some("Photo"), true)); @@ -358,3 +358,25 @@ fn menus_follow_fusion_order() { context_menu::run_item(&mut app, &find(&empty, "Pan"), pos2(0.0, 0.0)); assert!(matches!(app.viewport.nav, Some(crate::viewport::NavMode::Pan))); } + +#[test] +fn v_toggles_what_is_selected() { + let mut app = sample_app(); + let b = body(&app); + let sk = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id; + app.run("select.set", json!({"items": [{"type": "body", "name": b}, {"type": "plane", "name": "XY"}, {"type": "feature", "id": sk}]})).unwrap(); + crate::browser::set_sketch_visible(&mut app, sk, true); + context_menu::toggle_visibility(&mut app); + assert!(app.ui.hidden_bodies.contains(&b) && app.ui.hidden_origin.iter().any(|h| h == "XY")); + assert!(!crate::browser::sketch_visible(&app, sk)); + context_menu::toggle_visibility(&mut app); + assert!(app.ui.hidden_bodies.is_empty() && app.ui.hidden_origin.is_empty()); + assert!(crate::browser::sketch_visible(&app, sk)); + // Components picked in the browser hide their bodies. + app.run("FusionCreateComponentsFromBodiesCommand", json!({"bodies": [b.clone()]})).unwrap(); + app.run("select.clear", json!({})).unwrap(); + app.tree.picked_components = vec![app.session.doc.components[0].id]; + context_menu::toggle_visibility(&mut app); + assert_eq!(app.ui.hidden_bodies, vec![b]); + assert_eq!(find(&context_menu::items(&app, &Target::Body { name: body(&app) }), "Show/Hide").shortcut, "V"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 824e8056..69f3e5ac 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -434,6 +434,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } } Key::S => app.ui.palette_open = true, + Key::V => crate::context_menu::toggle_visibility(app), Key::F6 => app.animate_view("fit"), Key::Delete | Key::Backspace => crate::viewport::delete_selection(app), _ => { diff --git a/docs/perf.md b/docs/perf.md index 32f9cb65..228ad33c 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -23,3 +23,18 @@ What changed: With 49 bodies the frame was already 2.8 ms (hover) before these changes. The remaining frame time with the Browser shown is mostly the Browser listing every body row. GPU ID-buffer picking was not needed at this size. + +## Browser + +Same model and method (400 bodies, Bodies folder open, 60 hover moves over the viewport): + +| | median | p90 | +|---|---|---| +| Browser shown, before | 1.95 ms | 2.71 ms | +| Browser shown, after | 0.72 ms | 1.19 ms | +| Browser hidden | 0.30 ms | 0.72 ms | + +What changed: rows scrolled out of view only reserve their height. They register no widgets +and paint nothing (no text layout, icons or eye/fold buttons), so the cost follows the rows on +screen rather than the rows in the tree. Scrolling, Find in Browser and drag and drop still see +every row's place.