diff --git a/assets/locales/pending/browser-eye-right.json b/assets/locales/pending/browser-eye-right.json new file mode 100644 index 0000000000..ef220acccb --- /dev/null +++ b/assets/locales/pending/browser-eye-right.json @@ -0,0 +1,34 @@ +{ + "en": { + "prefs.browser_eye_left": "Eye next to the fold arrow", + "prefs.browser_eye_left_note": "Browser rows show their show/hide eye next to the fold arrow, as before, instead of in a column at the right end of the rows." + }, + "ar": { + "prefs.browser_eye_left": "أيقونة الإظهار بجانب سهم الطي", + "prefs.browser_eye_left_note": "تعرض صفوف المستعرض أيقونة الإظهار بجانب سهم الطي كما كانت من قبل، بدلاً من عمود في الطرف الأيمن للصفوف." + }, + "de": { + "prefs.browser_eye_left": "Auge neben dem Aufklapppfeil", + "prefs.browser_eye_left_note": "Browserzeilen zeigen ihr Auge zum Ein- und Ausblenden wie bisher neben dem Aufklapppfeil statt in einer Spalte am rechten Zeilenende." + }, + "he": { + "prefs.browser_eye_left": "סמל העין ליד חץ הקיפול", + "prefs.browser_eye_left_note": "שורות עץ התכן מציגות את סמל העין ליד חץ הקיפול, כמו קודם, במקום בעמודה בקצה הימני של השורות." + }, + "ja": { + "prefs.browser_eye_left": "表示切り替えを折りたたみ矢印の隣に表示", + "prefs.browser_eye_left_note": "ブラウザーの各行の表示切り替えを、行の右端の列ではなく、以前のように折りたたみ矢印の隣に表示します。" + }, + "ko": { + "prefs.browser_eye_left": "표시 아이콘을 접기 화살표 옆에 표시", + "prefs.browser_eye_left_note": "브라우저 행의 표시 아이콘을 행 오른쪽 끝의 열이 아니라 이전처럼 접기 화살표 옆에 표시합니다." + }, + "vi": { + "prefs.browser_eye_left": "Biểu tượng mắt cạnh mũi tên thu gọn", + "prefs.browser_eye_left_note": "Các hàng trong cây thư mục hiển thị biểu tượng mắt bật/tắt cạnh mũi tên thu gọn như trước đây, thay vì trong một cột ở đầu bên phải của các hàng." + }, + "zh": { + "prefs.browser_eye_left": "显示切换位于折叠箭头旁", + "prefs.browser_eye_left_note": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。" + } +} diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 28b7f97088..e363c44b5e 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -511,9 +511,18 @@ fn compact_tree(ui: &egui::Ui) -> bool { ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_compact_tree"))).unwrap_or(false) } +/// Where tree rows put their eye this frame (Preferences › Interface, #8718). +pub(crate) fn publish_row_prefs(app: &SolveApp, ctx: &egui::Context) { + ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_tree_eye_left"), app.preferences.browser_eye_left)); +} + +fn eye_left(ui: &egui::Ui) -> bool { + ui.ctx().data(|d| d.get_temp::(egui::Id::new("sc_tree_eye_left"))).unwrap_or(false) +} + pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row<'_>) -> RowResp { let compact = compact_tree(ui); - let out = crate::ui_kit::tree_row(ui, id, &Row { compact, ..*row }); + let out = crate::ui_kit::tree_row(ui, id, &Row { compact, eye_left: eye_left(ui), ..*row }); if let Some(r) = out.rect.filter(|r| ui.is_rect_visible(*r)) { let x = r.left() + if compact { Tokens::get().space_8 } else { 2.0 } + row.depth as f32 * INDENT; let fold = row.fold.map(|_| Rect::from_min_max(pos2(x - 4.0, r.top()), pos2(x + 15.0, r.bottom()))); @@ -1583,10 +1592,13 @@ fn occurrence_rows_inner( ); if let Some(rect) = r.rect.filter(|_| r.resp.is_some()) { OCCURRENCES.with(|d| d.borrow_mut().push((path.to_vec(), placement.name.clone(), rect, open))); - crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(rect.right() - 12.0, rect.center().y)); - let x = rect.left() + 2.0 + depth as f32 * INDENT; - for (control, offset) in [("fold", 6.0), ("eye", 22.0), ("activate", 38.0)] { - crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), pos2(x + offset, rect.center().y)); + // Near the row's end, clear of an eye there. + let end = r.eye_at.filter(|e| e.x > rect.center().x).map_or(rect.right() - 12.0, |e| e.x - 24.0); + crate::scenario::publish_handle(&format!("row:occurrence:{}", key.trim_start_matches("o:")), pos2(end, rect.center().y)); + for (control, at) in [("fold", r.fold_at), ("eye", r.eye_at), ("activate", r.radio_at)] { + if let Some(at) = at { + crate::scenario::publish_handle(&format!("{control}:occurrence:{}", key.trim_start_matches("o:")), at); + } } } if let Some(resp) = &r.resp { @@ -2582,15 +2594,13 @@ fn folder_rows_at( /// The body's name edited in its row (started by a double-click): Enter or leaving the box keeps /// the name, Escape cancels. -fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, e: &Entry, depth: usize) { +fn inline_rename(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect, label_left: f32, e: &Entry) { let editing = app.menu.rename.as_ref().is_some_and(|r| r.inline && matches!(&r.what, crate::context_menu::RenameWhat::Body(b) if *b == e.key)); if !editing { return; } let Some(mut rename) = app.menu.rename.take() else { return }; - // Where the label starts: the fold arrow, eye and icon come first. - let left = row.left() + 2.0 + depth as f32 * INDENT + 14.0 + 18.0 + 21.0; - let edit_rect = Rect::from_min_max(pos2(left, row.top() + 1.0), pos2(row.right() - 3.0, row.bottom() - 1.0)); + let edit_rect = Rect::from_min_max(pos2(label_left, row.top() + 1.0), pos2(row.right() - 3.0, row.bottom() - 1.0)); ui.painter().rect_filled(edit_rect, 3.0, Tokens::get().field); let id = ui.id().with(("browser_rename", &e.row_key)); let response = crate::ui_kit::inline_name(ui, edit_rect, id, &mut rename.text); @@ -2730,9 +2740,9 @@ fn entry_row( } } if folder == "bodies" - && let Some(rect) = r.rect + && let (Some(rect), Some(label_left)) = (r.rect, r.label_left) { - inline_rename(app, ui, rect, e, depth); + inline_rename(app, ui, rect, label_left, e); } if r.hovered && !e.bodies.is_empty() { acts.hover(e.bodies.iter()); diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 6114065d6f..1d2f923c71 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -696,3 +696,104 @@ fn browser_occurrence_clicks_fill_align_components_input() { h.step(&json!({"click": {"handle": &handle}})).unwrap(); assert!(h.app.dialog.as_ref().unwrap().inputs[0].items.is_empty()); } + +/// #8718: the eyes form one column at the rows' right end, right of each name and radio at every +/// depth, and a click on one hides without activating; the preference puts them back by the fold arrow. +#[test] +fn row_eyes_line_up_at_the_right_end_unless_the_preference_keeps_them_left() { + let mut h = Harness::new(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Top"})).unwrap(); + let part = h.app.run("component.create", json!({"name":"Part"})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[20,0,0],"body_name":"Inner"})).unwrap(); + h.app.run("component.activate", json!({"component":0})).unwrap(); + h.settle(); + let placement = format!("occurrence:{part}"); + let eye = |name: &str| handle_at(&format!("eye:{name}")).unwrap_or_else(|| panic!("eye:{name}")); + let label = |name: &str| rect_of(&format!("row_label:{name}")).unwrap_or_else(|| panic!("row_label:{name}")); + let activate = || handle_at(&format!("activate:{placement}")).unwrap(); + let card = rect_of("browser:card").unwrap(); + let column = eye(&placement).x; + for name in ["Bodies", "Top", "Inner", placement.as_str()] { + assert!((eye(name).x - column).abs() < 0.5, "{name}: {} is off the column at {column}", eye(name).x); + } + for name in ["Bodies", "Top", "Inner"] { + assert!(eye(name).x > label(name).right(), "{name}: the eye is right of the name"); + } + assert!(column > activate().x + 40.0 && column > card.right() - 40.0, "the eye sits at the row's end, apart from the radio"); + assert!(eye("Inner").y > eye(&placement).y + 10.0, "the body sits under its placement"); + let indent = |name: &str| handle_at(&format!("indent:{name}")).unwrap_or_else(|| panic!("indent:{name}")).x; + assert!((indent("Inner") - indent("Part:1") - super::INDENT).abs() < 0.5, "one level deeper, one indent further right"); + assert!((label("Inner").left() - label("Top").left()).abs() < 0.5, "bodies at one depth start their names together"); + // Its tip shows by the eye, not under the name. + h.step(&json!({"move":{"handle":"eye:Top"}})).unwrap(); + h.frames(60); + h.frame(); + let mut tip = None; + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |s| { + if let egui::Shape::Text(t) = s + && t.galley.text() == "Hide" + { + tip = Some(t.pos.x + t.galley.rect.center().x); + } + }); + } + let tip = tip.expect("the eye's Hide tip"); + assert!(tip > label("Top").right(), "the tip at {tip} is by the eye at {}", eye("Top").x); + + // Between the fold arrow and the radio, a click neither folds nor activates. + let (fold, radio) = (handle_at(&format!("fold:{placement}")).unwrap(), activate()); + assert!(radio.x - fold.x >= 24.0, "the radio keeps clear of the fold arrow: {fold:?} {radio:?}"); + for dx in [11.0, 14.0] { + h.frames(40); + h.step(&json!({"click":[fold.x + dx, fold.y]})).unwrap(); + h.settle(); + assert!(handle_at("indent:Inner").is_some(), "{dx}: the placement stays open"); + assert_eq!(h.app.session.active_component, 0, "{dx}: nothing is activated"); + } + h.frames(40); + h.step(&json!({"click":{"handle":format!("eye:{placement}")}})).unwrap(); + assert!(h.app.session.occurrence_hidden(&[part]), "the placement's eye hides it"); + h.step(&json!({"click":{"handle":"eye:Top"}})).unwrap(); + assert!(h.app.session.visibility.hidden_bodies.iter().any(|b| b == "Top"), "the body's eye hides it"); + assert_eq!(h.app.session.active_component, 0, "an eye click activates nothing"); + assert!(h.app.session.active_occurrence_path.is_none()); + + h.call("ui.prefs", json!({"browser_eye_left": true})); + h.settle(); + assert!(eye("Top").x < label("Top").left() && eye(&placement).x < activate().x, "beside the fold arrow, as before"); + assert!((eye("Inner").x - eye(&placement).x - super::INDENT).abs() < 0.5, "one level deeper, one indent further right"); + h.step(&json!({"click":{"handle":"eye:Top"}})).unwrap(); + assert!(!h.app.session.visibility.hidden_bodies.iter().any(|b| b == "Top"), "the eye still shows it"); + assert_eq!(h.app.session.active_component, 0); +} + +/// #8718: with the rows overflowing the card, an eye at the rows' end stays clear of the scroll +/// bar laid over them: a click on it hides the sketch and scrolls nothing. +#[test] +fn an_eye_at_the_row_end_takes_its_click_from_the_scroll_bar() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1400,"height":900})); + for i in 0..35 { + h.app.run("sketch.create", json!({"plane":"XY", "name":format!("Sketch{i}")})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + } + h.settle(); + assert!(handle_at("row:Sketch34").is_none(), "the rows overflow the card"); + let card = rect_of("browser:card").unwrap(); + let eye = handle_at("eye:Sketch3").unwrap(); + let row = handle_at("row:Sketch3").unwrap(); + let bar = h.ctx.global_style().spacing.scroll.bar_width; + let row_rect = drawn_rows().into_iter().find(|r| r.0 == "Sketch3").unwrap().1; + assert!(card.contains_rect(row_rect)); + let half = (Tokens::get().space_16 + Tokens::get().space_3) / 2.0; + assert!(eye.x + half <= row_rect.right() - bar, "the eye at {} is left of the bar along {}", eye.x, row_rect.right()); + // The bar widens under the pointer before the click. + h.step(&json!({"move":[row_rect.right() - 3.0, eye.y]})).unwrap(); + h.frames(30); + // The eye's edge nearest the bar. + h.step(&json!({"click":[eye.x + half - 1.0, eye.y]})).unwrap(); + h.settle(); + assert_eq!(h.app.session.visibility.hidden_sketches.len(), 1, "the eye hides its sketch"); + assert_eq!(handle_at("row:Sketch3"), Some(row), "the list did not scroll"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1c8c2c4473..4b645b46f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2698,6 +2698,7 @@ impl SolveApp { toolbar::toolbar(self, ui); } theme::TabAccent::publish(self, ui.ctx()); + browser::publish_row_prefs(self, ui.ctx()); if !floating { agent_chat::show(self, ui); } diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs index d4bda9b9f7..165634eb5b 100644 --- a/crates/ui-egui/src/occurrence_ui_tests.rs +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -432,11 +432,10 @@ fn a_folded_body_sits_one_level_under_its_nested_placement() { h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Pin"})).unwrap(); h.app.run("component.activate", json!({"component":0})).unwrap(); h.app.tree.collapsed.insert(format!("{}/bodies", super::occurrence_key(&[parent, child]))); - // How many levels the body's row sits under its placement's (each eye sits at the same offset - // in its row). + // How many levels the body's row sits under its placement's. let levels = || -> Option { - let placement = crate::scenario::handle_at(&format!("eye:occurrence:{parent}/{child}"))?; - let body = crate::scenario::handle_at("eye:Pin")?; + let placement = crate::scenario::handle_at("indent:Child:1")?; + let body = crate::scenario::handle_at("indent:Pin")?; Some(((body.x - placement.x) / super::INDENT).round() as i32) }; for (mode, want) in [(BodiesFolders::Several, Some(1)), (BodiesFolders::Never, Some(1)), (BodiesFolders::Always, None)] { diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index c3c3ac3974..db633a8257 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -1147,6 +1147,9 @@ preference_settings! { origin_plane_names: OriginPlaneNames = OriginPlaneNames::default(), PAGE_INTERFACE; /// When components' bodies sit in a Bodies folder in the browser (#4217). browser_bodies_folders: BodiesFolders = BodiesFolders::Several, PAGE_INTERFACE; + /// Each tree row's eye between its fold arrow and its radio, as before #8718, instead of in a + /// column at the rows' right end. Off by default. + browser_eye_left: bool = false, PAGE_INTERFACE => check_id("prefs.browser_eye_left"); /// The wheel zooms toward the point under the cursor (else toward the view's centre). zoom_to_cursor: bool = true, PAGE_NAVIGATION => check("Zoom to the cursor", "The wheel zooms toward the point under the cursor (off: toward the view's center)"); /// Speed multiplier for wheel zoom, independent of the 3D mouse. @@ -4984,6 +4987,7 @@ mod tests { ("viewport_background", json!([255, 255, 255]), PAGE_INTERFACE), ("view_confirm_corner", json!(true), PAGE_INTERFACE), ("status_bar", json!(true), PAGE_INTERFACE), + ("browser_eye_left", json!(true), PAGE_INTERFACE), ("nav", json!("blender"), PAGE_NAVIGATION), ("look_at_anim", json!(false), PAGE_NAVIGATION), ("selection_shift_add_only", json!(true), PAGE_SKETCH), diff --git a/crates/ui-egui/src/prefs/interface.rs b/crates/ui-egui/src/prefs/interface.rs index 030f279b0f..ca655d90ff 100644 --- a/crates/ui-egui/src/prefs/interface.rs +++ b/crates/ui-egui/src/prefs/interface.rs @@ -269,6 +269,11 @@ pub(super) fn interface(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { app.preferences.browser_bodies_folders = *mode; } }); + // The eye beside the fold arrow, where it was before #8718. + ui_kit::row(ui, "", |ui| { + let check = setting::browser_eye_left(app, ui).on_hover_text(app.tr("prefs.browser_eye_left_note")); + crate::scenario::publish_target("prefs:browser_eye_left", check.rect); + }); // What is being edited, the sketch's state and the units, along the bottom (#7243). ui_kit::row(ui, "", |ui| { let check = setting::status_bar(app, ui).on_hover_text(app.tr("prefs.status_bar_note")); diff --git a/crates/ui-egui/src/sketch_pattern_tree.rs b/crates/ui-egui/src/sketch_pattern_tree.rs index 5fd7767f33..8991f684f8 100644 --- a/crates/ui-egui/src/sketch_pattern_tree.rs +++ b/crates/ui-egui/src/sketch_pattern_tree.rs @@ -136,4 +136,36 @@ mod tests { h.frames(3); assert!(crate::scenario::handle_at(&handle).is_some()); } + + /// Where a text starts, drawn inside `within`. + fn text_left(h: &Harness, text: &str, within: egui::Rect) -> f32 { + let mut at = None; + for clipped in &h.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |s| { + if let egui::Shape::Text(t) = s + && t.galley.text() == text + && within.contains(t.pos + t.galley.rect.center().to_vec2()) + { + at = Some(t.pos.x + t.galley.rect.left()); + } + }); + } + at.unwrap_or_else(|| panic!("{text} in {within:?}")) + } + + /// #8718: in the vertical History a sketch's pattern rows step in from the sketch's name as far + /// as they do in the browser, with the eye at the rows' end. + #[test] + fn left_history_patterns_step_in_from_their_sketch_like_the_browser() { + let (mut h, sketch, rectangular, _) = harness(true); + let label = format!("{} {}", crate::i18n::tr_label("Rectangular Pattern"), rectangular.strip_prefix("pat").unwrap_or(&rectangular)); + h.frame(); + let browser = crate::scenario::rect_of("browser:card").unwrap(); + let in_browser = text_left(&h, &label, browser) - text_left(&h, "Patterned Sketch", browser); + let sketch_row = crate::scenario::rect_of("history:feature:Patterned Sketch").unwrap(); + let pattern_y = crate::scenario::handle_at(&format!("timeline:sketch-pattern:{sketch}:{rectangular}")).unwrap().y; + let pattern_row = egui::Rect::from_x_y_ranges(sketch_row.x_range(), pattern_y - 8.0..=pattern_y + 8.0); + let in_history = text_left(&h, &label, pattern_row) - text_left(&h, "Patterned Sketch", sketch_row); + assert!((in_history - in_browser).abs() <= 2.0, "History steps in {in_history}, the browser {in_browser}"); + } } diff --git a/crates/ui-egui/src/ui_kit/tree.rs b/crates/ui-egui/src/ui_kit/tree.rs index 731abd856a..f89b2061bb 100644 --- a/crates/ui-egui/src/ui_kit/tree.rs +++ b/crates/ui-egui/src/ui_kit/tree.rs @@ -24,6 +24,8 @@ pub(crate) struct Row<'a> { pub(crate) fold: Option, /// Some(visible) for rows with an eye. pub(crate) eye: Option, + /// The eye between the fold arrow and the radio, as before #8718, instead of at the row's end. + pub(crate) eye_left: bool, pub(crate) icon: &'a str, pub(crate) label: &'a str, /// The label is a name someone gave (a body, sketch, component, …): it is shown as typed, @@ -107,6 +109,12 @@ pub(crate) struct RowResp { pub(crate) resp: Option, /// The press began on the fold arrow, eye or radio (not a drag of the row). pub(crate) on_control: bool, + /// Centres of the fold arrow, eye and radio, for rows that publish them under other handles. + pub(crate) fold_at: Option, + pub(crate) eye_at: Option, + pub(crate) radio_at: Option, + /// Where the label starts. + pub(crate) label_left: Option, pub(crate) actions: Vec, } @@ -153,6 +161,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { } let mut x = r.left() + if row.compact { t.space_8 + row.depth as f32 * t.space_14 } else { 2.0 + row.depth as f32 * INDENT }; let cy = r.top() + row_height / 2.0; + // Where the row's depth puts it, whatever controls it has. + crate::scenario::publish_handle(&format!("indent:{}", row.label), pos2(x, cy)); let action_width = if row.compact { t.space_16 } else { t.form_close_size }; let action_left = (r.right() - if row.compact { t.space_8 } else { t.space_4 } - action_width * row.actions.len() as f32).max(r.left()); for (index, action) in row.actions.iter().enumerate() { @@ -186,11 +196,23 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // 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(if row.compact { action_left - t.space_6 } else { x + 14.0 + 8.0 }, cy), - vec2(t.space_16 + t.space_3, r.height()), - ); - let radio_r = Rect::from_center_size(pos2(x + 32.0 + 6.0, cy), vec2(t.space_12 + t.space_3, r.height())); + // At the row's end the eyes form one column, clear of a floating scroll bar laid over the rows. + let eye_end = !row.compact && !row.eye_left; + let scroll = &ui.spacing().scroll; + let bar = if scroll.floating { (scroll.bar_width + scroll.bar_outer_margin - scroll.floating_allocated_width).max(0.0) } else { 0.0 }; + let eye_width = t.space_16 + t.space_3; + let eye_x = if row.compact { + action_left - t.space_6 + } else if eye_end { + action_left.min(r.right() - bar) - eye_width / 2.0 + } else { + x + 14.0 + 8.0 + }; + let eye_r = Rect::from_center_size(pos2(eye_x, cy), vec2(eye_width, r.height())); + // After the fold arrow: the eye's slot, or a gap that keeps the arrow and the radio apart. + let slot = if eye_end { t.space_10 } else { 18.0 }; + let radio_x = x + 14.0 + slot + 6.0; + let radio_r = Rect::from_center_size(pos2(radio_x, cy), vec2(t.space_12 + t.space_3, r.height())); // The button right after the name takes room from the label; it is placed once the label is laid out. let name_action_width = if row.action.is_some() { 22.0 } else { 0.0 }; let at = |p: Pos2| -> u8 { @@ -213,6 +235,7 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { if let Some(open) = row.fold { crate::ui_kit::disclosure(ui.painter(), pos2(x + 6.0, cy), open, if hover_on == 1 { t.text } else { t.text_dim }); out.fold = resp.clicked() && click_on == 1; + out.fold_at = Some(fold_r.center()); crate::scenario::publish_handle(&format!("fold:{}", row.label), fold_r.center()); } x += if row.compact { t.space_16 } else { 14.0 }; @@ -224,10 +247,11 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { icons::paint_icon(ui.painter(), er, if vis { "eye" } else { "suppress" }, if vis && !row.dim { 0.85 } else { DIM_ICON_OPACITY }); out.eye = resp.clicked() && click_on == 2; out.eye_rect = Some(eye_r.intersect(ui.clip_rect())); + out.eye_at = Some(er.center()); crate::scenario::publish_handle(&format!("eye:{}", row.label), er.center()); } if !row.compact { - x += 18.0; + x += slot; } // Activation radio (components). if let Some(active) = row.radio { @@ -237,6 +261,7 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { ui.painter().circle_filled(rc, 2.8, t.accent); } out.radio = resp.clicked() && click_on == 3; + out.radio_at = Some(radio_r.center()); x += 15.0; } if !row.icon.is_empty() && !(row.compact && row.swatch.is_some()) { @@ -268,7 +293,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { // The label stops before the badges and the card's edge; a longer name ends in an ellipsis // and its full text is in the tooltip. let badges_width = row.badges.len() as f32 * 15.0; - let mut right = if row.compact && row.eye.is_some() { eye_r.left() - t.space_4 } else { action_left } - badges_width; + let end = if (row.compact || eye_end) && row.eye.is_some() { eye_r.left() - t.space_4 } else { action_left }; + let mut right = end - badges_width; if let Some(status) = row.status { right -= t.space_16; let sr = Rect::from_center_size(pos2(right + t.space_8, cy), vec2(t.space_14, t.space_14)); @@ -315,7 +341,8 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let corners = [inside.left_top(), inside.right_top(), inside.right_bottom(), inside.left_bottom(), inside.left_top()]; ui.painter().add(egui::Shape::dashed_line(&corners, Stroke::new(1.2, t.text_dim), 4.0, 3.0)); } - let mut bx = (x + lw + 6.0 + name_action_width).min(r.right() - 16.0); + out.label_left = Some(x); + let mut bx = (x + lw + 6.0 + name_action_width).min(if eye_end && row.eye.is_some() { end } else { r.right() } - 16.0); for b in row.badges { let br = Rect::from_center_size(pos2(bx + 6.0, cy), vec2(t.space_12, t.space_12)); badge(ui.painter(), br, b, t.text_dim); @@ -346,6 +373,13 @@ pub(crate) fn tree_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { }; let state = row.kind_tip.map(crate::i18n::tr).or(row.tip); let resp = match (tip, state) { + // The eye at the row's end shows its tip there, not under the name. + (Some(t), _) if hover_on == 2 && eye_end => { + let mut at_eye = resp.clone(); + (at_eye.rect, at_eye.interact_rect) = (eye_r, eye_r); + at_eye.on_hover_text(t); + resp + } (Some(t), _) => resp.on_hover_text(t), (None, Some(state)) if elided => resp.on_hover_text(format!("{label_display}\n{state}")), (None, Some(state)) => resp.on_hover_text(state), diff --git a/crates/ui-egui/tests/scenarios/browser_bodies_folders.json b/crates/ui-egui/tests/scenarios/browser_bodies_folders.json index df3c8c128d..b45898baa2 100644 --- a/crates/ui-egui/tests/scenarios/browser_bodies_folders.json +++ b/crates/ui-egui/tests/scenarios/browser_bodies_folders.json @@ -1,5 +1,6 @@ [ {"note": "Bodies folders (#4217, Preferences › Interface): a nested assembly of plain boxes. Frame:1 holds the body Plate and the placement Pin:1, which holds the body Shaft. The design itself has no bodies, so every Bodies row is a component's."}, + {"note": "Each row's indent handle is where its depth puts it, so their order shows each row's depth."}, {"call": "engine.execute", "params": {"command": "component.create", "params": {"name": "Frame"}}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 40, "width": 20, "height": 5, "body_name": "Plate"}}}, {"call": "engine.execute", "params": {"command": "component.create", "params": {"name": "Pin"}}}, @@ -8,14 +9,14 @@ {"expect": {"bodies": 2, "errors": 0, "prefs": {"browser_bodies_folders": "several"}}}, {"note": "Several bodies only (the default): each lone body sits straight under its placement, one level in, with no Bodies row."}, - {"expect": {"browser_has": ["Frame:1", "Plate", "Pin:1", "Shaft"], "browser_lacks": ["Bodies"], "handle_order": ["eye:Frame:1", "eye:Plate"]}}, - {"expect": {"handle_order": ["eye:Pin:1", "eye:Shaft"]}}, + {"expect": {"browser_has": ["Frame:1", "Plate", "Pin:1", "Shaft"], "browser_lacks": ["Bodies"], "handle_order": ["indent:Frame:1", "indent:Plate"]}}, + {"expect": {"handle_order": ["indent:Pin:1", "indent:Shaft"]}}, {"note": "A second body in Frame brings Frame's folder back; Pin's lone body stays folded."}, {"call": "engine.execute", "params": {"command": "component.activate", "params": {"component": "Frame"}}}, {"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 5, "width": 20, "height": 15, "corner": [60, 0, 0], "body_name": "Rib"}}}, {"call": "engine.execute", "params": {"command": "component.activate", "params": {"component": "root"}}}, - {"expect": {"bodies": 3, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["eye:Bodies", "eye:Plate"]}}, + {"expect": {"bodies": 3, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["indent:Bodies", "indent:Plate"]}}, {"note": "Never: every component's bodies sit straight under it, two of them too."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "never"}}, @@ -23,7 +24,7 @@ {"note": "Always: every component keeps its folder, Pin's too (the last Bodies row drawn, between Pin:1 and Shaft)."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "always"}}, - {"expect": {"prefs": {"browser_bodies_folders": "always"}, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["eye:Pin:1", "eye:Bodies", "eye:Shaft"]}}, + {"expect": {"prefs": {"browser_bodies_folders": "always"}, "browser_has": ["Bodies", "Plate", "Rib", "Shaft"], "handle_order": ["indent:Pin:1", "indent:Bodies", "indent:Shaft"]}}, {"note": "A value this version doesn't know is refused and changes nothing."}, {"call": "ui.prefs", "params": {"browser_bodies_folders": "sometimes"}, "fail": true}, diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index ae07f200e5..d18b21e658 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -20,9 +20,10 @@ design until you show them again. Hidden tabs keep their customised buttons, commands in search and shortcuts; with all tabs hidden, **+** remains. - **Browser** (left). The design as a tree: document settings and units, named views, the - origin, bodies, sketches, construction planes, canvases, joints and components. The eye shows or - hides an item, a component's radio button makes it the active component, and right-clicking any - row gives that item's menu. Right-click the design or a component and choose **Expand All** or + origin, bodies, sketches, construction planes, canvases, joints and components. The eye at the + row's right end shows or hides an item (**Preferences › Interface › Eye next to the fold arrow** + puts it back beside the fold arrow), a component's radio button makes it the active component, + and right-clicking any row gives that item's menu. Right-click the design or a component and choose **Expand All** or **Collapse All** to open or close every folder under that row (the design row stays visible when the whole tree is collapsed). Rows drag into groups and other components. **Preferences › Interface › Bodies folders** sets when a component's bodies sit in a Bodies folder of their own: diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 53971e63ba..d07a4bac12 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -363,7 +363,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | | `ui.pick` | `pixels?: [[x,y]…]`, `rays?: [{origin, dir}…]`, `frame?: "window"\|"model"`, `width?`, `height?`, `edges?` (default true), `edge_px?` (default 6), `all?`, `include_ghosted?` | read-only geometry under each pixel or ray (#6304): one entry per input, `null` where nothing is hit — body, face index, exact world `point` and outward `normal`, `depth`, `occurrence`/`component` for placed parts, `edge`/`vertex` within `edge_px` of a pixel, `section_cap: true` on a cut face. `window` pixels are physical pixels of a window screenshot; `model` pixels are pixels of a `ui.render` of `width`×`height` (default 960×600), so it works headless. As `view.pick` through `engine.execute`/`engine.batch`. See `crates/ui-egui/src/view_pick.rs` | -| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), ribbon tabs `tab:` (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW, SHEET METAL, WELDMENTS; contextual SKETCH; workspace RENDER), the toolbar menu button `ribbon:add_tab`, the hint when every tab is hidden `ribbon:tabs_hidden`, Preferences tab check boxes `prefs:toolbar_tab:`, panel labels `panel::` (also `ribbon:section::
`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row: