* claim: #8800 * Fix folder visibility shortcut in browser (#8800) * retrigger CI [skip ci] * retrigger CI
This commit is contained in:
1 parent
31218b1451
commit
f08623be3c
7 files changed
+180
-8
No files matched your search
@@ -42,8 +42,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<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>
|
||||
<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>
|
||||
|
||||

|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user