Fix V visibility shortcut for browser folders (#8800) (#8925)

* claim: #8800

* Fix folder visibility shortcut in browser (#8800)

* retrigger CI [skip ci]

* retrigger CI
This commit is contained in:
Colin Faulkingham authored and GitHub committed 2026-10-29 18:54:34 +00:00
1 parent 31218b1451
commit f08623be3c
7 files changed
+180 -8

No files matched your search

+4 -3
View File
@@ -42,8 +42,8 @@
</p>
<p align="center">
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily" alt="tangency-labs%2Fsolidcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/296178?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-296178" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/296178/daily?language=Rust" alt="tangency-labs%2Fsolidcraft | Trendshift (Rust)" width="250" height="55"/></a>
</p>
![SolidCraft: model a Raspberry Pi 4 case from an empty design: a sketch dimensioned with parameters, extrude, fillets and shell, a rim rounded in one pick, standoffs patterned on the board's mounting holes, port cut-outs, vent slots, "Pi 4" engraved in a font, a PETG material with its mass in Properties, and a height change in Parameters](docs/screenshots/demo.gif)
@@ -98,7 +98,7 @@ More commands, the Windows cross-build and the browser build are in [docs/buildi
runs its own design session, or with `--connect` it drives the running app so you can watch.
Claude Code: `claude mcp add solidcraft -- solidcraft-cli mcp`. If a terminal doesn't find
`solidcraft-cli` (older Windows installers don't put it on `PATH`), give its full path (by default
`"C:\Program Files\SolidCraft\solidcraft-cli.exe"`). Tools, access rules and an example session:
`"C:\\Program Files\\SolidCraft\\solidcraft-cli.exe"`). Tools, access rules and an example session:
[docs/mcp.md](docs/mcp.md).
## Scripts
@@ -150,3 +150,4 @@ Tinkercad are trademarks of their respective owners, named only to describe comp
[linux-appimage]: https://github.com/tangency-labs/solidcraft/releases/latest/download/solidcraft-linux-x86_64.AppImage
[snap-store]: https://snapcraft.io/solidcraft
[web-app]: https://solidcraft.io/app
+43 -1
View File
@@ -71,11 +71,12 @@ pub(crate) fn reveal_in_history(app: &mut SolveApp, id: u64) {
mod model_tests;
use std::collections::BTreeSet;
use std::sync::{Arc, Weak};
use egui::{Color32, FontId, Pos2, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::doc::FeatureKind;
use solidcraft_engine::doc::{Document, FeatureKind};
use crate::context_menu::{Item, Target};
use crate::theme::Tokens;
@@ -120,6 +121,8 @@ pub struct TreeState {
pub redefine_draft: Option<crate::sketch_attachment_ui::Draft>,
/// Components picked in the tree (they have no viewport selection).
pub picked_components: Vec<u64>,
/// The last folder label clicked, for V; independent of the geometry selection.
picked_folder: Option<FolderPick>,
/// Canvases picked in the tree.
pub picked_canvases: Vec<u64>,
/// Joints picked in the tree (a hidden one shows highlighted while it is picked).
@@ -165,7 +168,23 @@ pub struct TreeState {
cache: Option<cache::Cache>,
}
struct FolderPick {
key: String,
session: u64,
doc: Weak<Document>,
}
impl FolderPick {
fn current(&self, app: &SolveApp) -> bool {
self.session == app.session.uid && std::ptr::eq(self.doc.as_ptr(), Arc::as_ptr(&app.session.doc))
}
}
impl TreeState {
pub(crate) fn clear_folder_pick(&mut self) {
self.picked_folder = None;
}
fn remember(&mut self, key: &str) {
if let Some(slot) = self.order.get_mut(self.order_len) {
if slot != key {
@@ -406,6 +425,27 @@ pub fn toggle_folder(app: &mut SolveApp, key: &str) {
let _ = app.run("browser.visibility", json!({ "tree_folders": [key], "visible": app.session.folder_hidden(key) }));
}
/// A folder shortcut target lasts until another click, Escape, or a change of design/model.
/// Visibility changes keep the document snapshot, so V can toggle the same folder again.
pub(crate) fn update_folder_pick(app: &mut SolveApp, ctx: &egui::Context) {
if ctx.input(|i| i.pointer.any_pressed() || i.key_pressed(egui::Key::Escape))
|| app.tree.picked_folder.as_ref().is_some_and(|pick| !pick.current(app))
{
app.tree.clear_folder_pick();
}
}
fn pick_folder(app: &mut SolveApp, key: &str) {
app.tree.picked_folder = Some(FolderPick { key: key.into(), session: app.session.uid, doc: Arc::downgrade(&app.session.doc) });
}
pub(crate) fn toggle_picked_folder(app: &mut SolveApp) -> bool {
let Some(pick) = app.tree.picked_folder.as_ref().filter(|pick| pick.current(app)) else { return false };
let key = pick.key.clone();
toggle_folder(app, &key);
true
}
/// Selected browser items of the folder of a body or sketch, when more than one (for Group
/// Selected in their menus).
pub fn selection_group(app: &SolveApp, target: &Target) -> Option<Value> {
@@ -2438,6 +2478,7 @@ fn folder_rows_at(
// still folds it. With no such command, the label folds, as it always has.
let names: Vec<String> = data.entries().flat_map(|e| e.bodies.clone()).collect();
if !offer_bodies(app, &names, Take::Toggle) {
pick_folder(app, key);
toggle(app, key, default_open);
}
}
@@ -2504,6 +2545,7 @@ fn folder_rows_at(
} else if r.clicked {
let names: Vec<String> = items.iter().flat_map(|e| e.bodies.clone()).collect();
if !offer_bodies(app, &names, Take::Toggle) {
pick_folder(app, gkey);
toggle(app, gkey, true);
}
}
+99
View File
@@ -2,6 +2,105 @@ use super::*;
use crate::scenario::{Harness, handle_at, rect_of};
use crate::shell::{LayoutMode, SHELL_GAP};
#[test]
fn clicked_browser_folders_toggle_visibility_with_v() {
for mode in [LayoutMode::Floating, LayoutMode::Docked] {
let mut h = Harness::new();
h.app.ui.layout = mode;
for (name, x) in [("One", 0), ("Two", 20)] {
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap();
}
let group =
h.app.run("browser.group", json!({"component":0,"folder":"bodies","items":["One"],"name":"Group"})).unwrap()["group"].as_u64().unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.circle.center", json!({"center":[0,0],"radius":5})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("construct.plane.offset", json!({"base":"XY","offset":20})).unwrap();
h.app.run("browser.visibility", json!({"items":["Two"],"visible":false})).unwrap();
h.settle();
for (label, key) in [
("Bodies", "c0/bodies".into()),
("Sketches", "c0/sketches".into()),
("Construction", "c0/construction".into()),
("Group (1)", format!("c0/g{group}")),
] {
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
let before = h.app.session.visibility.clone();
let mut hidden = before.clone();
hidden.hidden_folders.push(key.clone());
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
h.step(&json!({"key":"V"})).unwrap();
assert_eq!(h.app.session.visibility, hidden, "{mode:?}: V hides {label}, leaving individual items alone");
h.step(&json!({"key":"V"})).unwrap();
assert_eq!(h.app.session.visibility, before, "V shows the same folder again");
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.visibility, hidden);
h.app.run("edit.redo", json!({})).unwrap();
assert_eq!(h.app.session.visibility, before);
// Restore expansion so the later group row is still reachable.
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
}
}
}
#[test]
fn folder_visibility_target_clears_when_the_user_moves_on() {
for action in ["body click", "selection command", "empty click", "escape", "design tab", "model edit", "tool"] {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"One"})).unwrap();
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
h.settle();
h.step(&json!({"click":{"handle":"row:Sketches"}})).unwrap();
match action {
"body click" => h.step(&json!({"click":{"handle":"row:One"}})).unwrap(),
"selection command" => {
// Explicitly picking the same body must also leave the folder target.
h.app.run("select.set", json!({"items":[Sel::Body {name:"One".into()}]})).unwrap();
}
"empty click" => h.step(&json!({"click":[950,650]})).unwrap(),
"escape" => h.step(&json!({"key":"Escape"})).unwrap(),
"design tab" => {
crate::documents::new_tab(&mut h.app);
h.settle();
crate::documents::switch(&mut h.app, 0);
h.settle();
}
"model edit" => {
h.app.run("sketch.create", json!({"plane":"XZ"})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
}
"tool" => {
h.app.start("file.save_mesh");
h.settle();
}
_ => panic!("unknown test action: {action}"),
}
h.step(&json!({"key":"V"})).unwrap();
assert!(h.app.session.visibility.hidden_folders.is_empty(), "{action}: V must no longer target Sketches");
if matches!(action, "body click" | "selection command") {
assert!(!h.app.session.body_visible("One"), "{action}: normal selection shortcuts still work");
}
}
}
#[test]
fn folder_visibility_shortcut_does_not_take_dialog_body_picks_or_chevrons() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"One"})).unwrap();
h.settle();
h.step(&json!({"click":{"chevron":"Bodies"}})).unwrap();
h.step(&json!({"key":"V"})).unwrap();
assert!(!h.app.session.folder_hidden("c0/bodies"), "the fold arrow does not pick a visibility target");
h.app.start("file.save_mesh");
h.settle();
h.step(&json!({"click":{"handle":"row:Bodies"}})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![Sel::Body { name: "One".into() }]);
h.step(&json!({"key":"V"})).unwrap();
assert!(!h.app.session.folder_hidden("c0/bodies"), "a body input consumes the folder click");
}
#[test]
fn save_mesh_add_all_and_add_visible_bodies_fill_the_bodies_input() {
let mut h = Harness::new();
+4
View File
@@ -1762,8 +1762,12 @@ pub fn press_shortcut(app: &mut SolveApp, key: &str) -> bool {
/// 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, joints' glyphs). When some are shown and some hidden, all are hidden.
/// A clicked folder takes precedence, and toggles its own visibility without changing its items.
pub fn toggle_visibility(app: &mut SolveApp) {
use solidcraft_engine::doc::FeatureKind;
if crate::browser::toggle_picked_folder(app) {
return;
}
let paths: Vec<Vec<u64>> =
app.session.selection.iter().filter_map(|s| if let Sel::Occurrence { path } = s { Some(path.clone()) } else { None }).collect();
if !paths.is_empty() {
+6 -4
View File
@@ -1081,12 +1081,13 @@ impl SolveApp {
if self.session.revision != revision {
timeline::stop_playback(self);
}
let selection_changed = self.session.selection != selected_before || matches!(id, "select.set" | "select.clear" | "select.same_look");
if done.is_ok() && selection_changed {
self.tree.clear_folder_pick();
}
// Render focus already applies its semantic selection after clearing object picks.
// A tab switch brings in the other design's own Render selection.
if done.is_ok()
&& !matches!(id, "view.render_select" | "view.next_tab" | "view.previous_tab")
&& (self.session.selection != selected_before || matches!(id, "select.set" | "select.clear" | "select.same_look"))
{
if done.is_ok() && !matches!(id, "view.render_select" | "view.next_tab" | "view.previous_tab") && selection_changed {
if self.session.selection.is_empty() && render_workspace::active(self) {
render_workspace::selection_editor(self, &render_workspace::RenderSelection::Nothing);
}
@@ -1421,6 +1422,7 @@ impl SolveApp {
}
fn start_with_variant_memory(&mut self, id: &str, remember_variant: bool, opener: Option<egui::Rect>) {
self.tree.clear_folder_pick();
if id == "timeline.play" {
if let Err(why) = self.run(id, json!({})) {
self.set_status(why, true);
+23
View File
@@ -34,6 +34,29 @@ fn fixture() -> (Harness, u64, u64, u64) {
(h, component, first, second)
}
#[test]
fn folder_visibility_shortcut_targets_only_the_clicked_placement() {
let (mut h, component, first, second) = fixture();
let group = h.app.run("browser.group", json!({"component":component,"folder":"bodies","items":["Body1"],"name":"Group"})).unwrap()["group"]
.as_u64()
.unwrap();
h.app.preferences.browser_bodies_folders = crate::prefs::BodiesFolders::Always;
h.app.tree.collapsed.insert(super::occurrence_key(&[first]));
h.settle();
let first_body = h.app.session.occurrence_bodies(&[first], false).unwrap()[0].clone();
let second_body = h.app.session.occurrence_bodies(&[second], false).unwrap()[0].clone();
for (label, key) in [("Bodies", format!("o:{second}/bodies")), ("Group (1)", format!("o:{second}/g{group}"))] {
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
h.step(&json!({"key":"V"})).unwrap();
assert_eq!(h.app.session.visibility.hidden_folders, vec![key]);
assert!(h.app.session.body_visible(&first_body));
assert!(!h.app.session.body_visible(&second_body));
h.step(&json!({"key":"V"})).unwrap();
assert!(h.app.session.body_visible(&second_body));
h.step(&json!({"click":{"handle":format!("row:{label}")}})).unwrap();
}
}
#[test]
fn browser_reuses_unchanged_rows_and_refreshes_live_state_and_replaced_documents() {
let (mut h, _, first, second) = fixture();
+1
View File
@@ -2204,6 +2204,7 @@ pub const ESCAPE_TWICE_S: f64 = 0.4;
/// Global keyboard shortcuts (when no text field has focus).
pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
crate::browser::update_folder_pick(app, ctx);
if design_shown(app) {
crate::navigation::step_key_hold(app, ctx);
} else {