Browser: the visibility eye at the right end of each row (#8718) (#8720)

* claim: #8718

* Browser: the eye sits in a column at each row's right end (#8718)

Tree rows draw the visibility eye right-aligned at the row's end, clear of
the fold arrow and the radio and of a floating scroll bar; names stop short
of it. Preferences > Interface > Eye next to the fold arrow
(browser_eye_left) keeps the old position. Rows report their control
positions so placement handles and inline rename follow the layout.

* Docs: the browser's eye column and browser_eye_left (#8718)

* Browser: the eye's tip shows by the eye at the row's end (#8718)

* Browser: a gap keeps the fold arrow and the radio apart; the eye clears a floating scroll bar (#8718)

The eye's old slot leaves a 10 pt gap instead of 4 pt, so the radio's hit
area is 7.5 pt from the fold arrow's and History pattern rows step in from
their sketch as far as browser children do. The eye's inset follows the
scroll style: only a floating bar laid over the rows takes room. Rows
publish indent:<label> where their depth puts them.

* Tests: browser depth checks run on the default layout, by indent handles (#8718)
This commit is contained in:
Kai Evans
2026-10-30 13:24:22 +00:00
committed by GitHub
parent f273195144
commit 1754318df7
13 changed files with 255 additions and 32 deletions
@@ -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": "浏览器各行的显示切换像以前一样位于折叠箭头旁,而不是在行右端的一列中。"
}
}
+21 -11
View File
@@ -511,9 +511,18 @@ fn compact_tree(ui: &egui::Ui) -> bool {
ui.ctx().data(|d| d.get_temp::<bool>(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::<bool>(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());
+101
View File
@@ -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");
}
+1
View File
@@ -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);
}
+3 -4
View File
@@ -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<i32> {
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)] {
+4
View File
@@ -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),
+5
View File
@@ -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"));
+32
View File
@@ -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}");
}
}
+42 -8
View File
@@ -24,6 +24,8 @@ pub(crate) struct Row<'a> {
pub(crate) fold: Option<bool>,
/// Some(visible) for rows with an eye.
pub(crate) eye: Option<bool>,
/// 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<egui::Response>,
/// 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<Pos2>,
pub(crate) eye_at: Option<Pos2>,
pub(crate) radio_at: Option<Pos2>,
/// Where the label starts.
pub(crate) label_left: Option<f32>,
pub(crate) actions: Vec<egui::Response>,
}
@@ -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),
@@ -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},
+4 -3
View File
@@ -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:
+2 -1
View File
@@ -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:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), ribbon tabs `tab:<name>` (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:<NAME>`, panel labels `panel:<tab>:<panel>` (also `ribbon:section:<TAB>:<SECTION>`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row:<label>` and fold arrows `fold:<label>`, manipulator handles; signed-axis tips `axis_view:z`, `axis_view:-y`; optional cube facets `navigation_cube:top`, `navigation_cube:top_front_right`, controls `navigation_cube:control:home` (and other actions), plus signed-axis aliases for visible cube faces; Preferences navigator picker `prefs:navigator`, options `prefs:navigator:Signed axes` and `prefs:navigator:Cube`; in the Motion Study dialog, where the chart puts its first and last step `motion:chart_start` and `motion:chart_end`, the Check collisions button `motion:check` and each collision found `motion:collision:<n>` |
| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:<command id>` (the button alone: `button:<command id>`; a drop-down row: `dropdown:<command id>`, highlighted `dropdown_hot:<command id>`, its pin `dropdown_pin:<command id>`; Customize Toolbar's check boxes `customize:<command id>`), ribbon tabs `tab:<name>` (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:<NAME>`, panel labels `panel:<tab>:<panel>` (also `ribbon:section:<TAB>:<SECTION>`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row:<label>`, where each row's depth puts it `indent:<label>`, and fold arrows `fold:<label>`, manipulator handles; signed-axis tips `axis_view:z`, `axis_view:-y`; optional cube facets `navigation_cube:top`, `navigation_cube:top_front_right`, controls `navigation_cube:control:home` (and other actions), plus signed-axis aliases for visible cube faces; Preferences navigator picker `prefs:navigator`, options `prefs:navigator:Signed axes` and `prefs:navigator:Cube`; in the Motion Study dialog, where the chart puts its first and last step `motion:chart_start` and `motion:chart_end`, the Check collisions button `motion:check` and each collision found `motion:collision:<n>` |
| `ui.editFeature` | `feature` (id or name) | what double-clicking a timeline item does: rolls back to it and opens its dialog filled in (sketches open in sketch mode) |
| `ui.scroll` | `x`, `y`, `delta?`, `ctrl?`, `unit?`: `point` \| `line` | wheel zoom at the cursor. `ctrl: true` replays a Ctrl+wheel pinch (Ctrl held and the wheel in points); `unit` selects the wheel unit and defaults to points with `ctrl` and lines without it. `delta` is in points either way. Invalid units or coordinates outside the finite screen-coordinate range are rejected before queuing input |
| `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `ctrl?`, `shift?`, `alt?`, `pressed?` | By default, press and release, then release modifiers. With `pressed: true` or `false`, send only that edge; modifiers remain as given. `cmd` retains the legacy primary-modifier alias unless `ctrl` is explicit: `cmd:true, ctrl:false` sends physical Command, and both true sends Control+Command. `ui.move` can change modifiers between edges. |
@@ -400,6 +400,7 @@ One setting per line, sorted by name. A change that adds a setting adds its own
- `assembly_move_handles` (default true): explicit Assembly Move uses the permitted joint-motion handles; false keeps the previous Move panel.
- `auto_operation` (default true: feature windows choose Join, New Body or Cut from what their material touches until one is picked; false: each keeps the operation it opens with, Extrude its own Join or Cut)
- `browser_bodies_folders` (`several` (default): a component with one body and no groups lists it straight under the component in the browser, without a Bodies folder; `always`; `never`: components list their bodies and groups straight under them, and the folder's menu items are on the component's menu; the design's own Bodies folder, and one hidden with its eye, always show; #4217)
- `browser_eye_left` (default false: each browser and History tree row's eye, handle `eye:<name>`, sits in a column at the rows' right end, clear of the fold arrow and the radio; true: between them, as before #8718)
- `check_updates` (default true; [updates](book/src/guide/updates.md))
- `color_vision` (`off` (default), `protan`, `deutan`, `tritan`: colour vision modes, #778)
- `default_units`
+1 -1
View File
@@ -155,7 +155,7 @@ table. Panel/Window/Confirm compositions also have dedicated geometry and intera
| --- | --- | --- |
| assistant: `assistant_header`, `assistant_controls`, `assistant_badge`, `assistant_bubble`, `assistant_prose`, `assistant_tool_frame/row/detail/status`, `AssistantToolState`, `assistant_approval/actions`, `assistant_composer/footer`, `assistant_input`, `assistant_send`, `assistant_empty`, `assistant_banner`/`banner`, `AssistantTone` | Shared Assistant presentation: 40 pt header, 22 pt state pill, right-aligned user messages limited to 86% width, unboxed assistant prose, bordered tool groups and command detail, tinted approval heading, rounded focused composer with one unframed growing editor and pinned footer, centred setup copy and semantic notices. Closures preserve existing permission choices, protocol routing, queue actions and scenario handles. | `form_header_height/close_size`, `font_label/small/body`, `dialog_body/footer/rule`, `control_fill/border/radius`, `form_ink/dim/primary/success/error`, `accent_soft/selected_ink`, `warning`, radii and spacing |
| history: `StripHeading`, `HistoryChip`, `HistoryStyle`, `history_drop_bar`, `history_marker`, `HistorySeam`, `history_seam` | Shared strip headings use semibold `font_heading`, `text`, a centred baseline and `space_12` trailing gap. One painter family for bottom chips, 38 pt vertical icon chips and single-line 26 pt name rows (the browser's shared `TREE_ROW_HEIGHT`). Selected items use the active tab tint/ink/border; all variants retain multi-tone feature artwork using one shared kind-to-accent rule (Sketch green, Surface orange, Mesh yellow, Form purple, solids base blue); rolled-back items have 42% opacity; a reference row (a sketch listed under another feature, #6489) has its name in secondary text. Error/warning badges, suppression strikes, horizontal/vertical drop bars and rollback line/hollow circle are shared. No connector lines. | `control_height`, `space_2/3/4/6/8/10/12/14/16/24`, radii, typography, `TabAccent`, `timeline_item`, `control_border`, `text/dim`, `form_error/fill` |
| tree: `tree_row`, `Row`, `TreeAction` | Shared Browser rows with disclosure, visibility, activation, selection, appearance swatches, optional subtitle and trailing actions. Compact scene rows are 28 pt, with inline right-aligned summaries, circular swatches, trailing eyes and full-width selection tint. Actions use quiet icons and can appear on hover. Disabled actions retain their reasons and cannot select or fold the row. Browser automation records the same row geometry. | `TREE_ROW_HEIGHT`, `font_label/small`, spacing, control radius/border, `text/dim`, workspace selection tint |
| tree: `tree_row`, `Row`, `TreeAction` | Shared Browser rows with disclosure, visibility (an eye column at the row's end, clear of a floating scroll bar, with a gap between the disclosure and the activation radio; or beside the disclosure with `eye_left`), activation, selection, appearance swatches, optional subtitle and trailing actions. Compact scene rows are 28 pt, with inline right-aligned summaries, circular swatches, trailing eyes and full-width selection tint. Actions use quiet icons and can appear on hover. Disabled actions retain their reasons and cannot select or fold the row. Browser automation records the same row geometry. | `TREE_ROW_HEIGHT`, `font_label/small`, spacing, control radius/border, `text/dim`, workspace selection tint |
| search: `CommandSearch`, `MenuItem::search_row` | Ribbon search composition: one persistent ribbon editor expands from 28 to 40 pt and to 520 pt wide with its right edge anchored, magnifier and S/Esc keycap, 38 pt two-line menu row with 26 pt icon tile, accent bold substring, path, shortcut/kind keycap, counts, JSON detail box and footer. The ordinary 30 pt menu row cannot fit a second line; this variant keeps its enabled/selected states and scenario IDs. Ranking and dispatch stay with the caller. | `form_card_width × 2 − space_8` (520 pt), `control_height`, `space_2/3/4/6/8/10/12/14/16/24`, `radius_xl/control_radius`, `control_fill`, `dialog_body/footer/rule/border`, `form_ink/dim/primary/error/error_fill/success/success_fill`, `selected_ink`, `accent_soft`, typography |
| menu: `Menu`, `MenuItem` | Shared popup, 30 pt rows, 28 pt icon column, clipped labels, right-aligned mono shortcuts, submenu chevrons, checked/unchecked and document swatches, inert 40% disabled rows and danger ink; headings and separators; ribbon drag/pin targets retained | `dialog_body/border/rule`, `radius_xl`, `control_height/radius`, `form_ink/dim/primary/error`, `accent_soft`, typography and spacing |
| radial: `radial_menu` | Eight rounded annular sectors, parallel 4 pt gaps, 9 pt circular corner joins, tab-accent artwork and hover, centre label/key; existing hold/flick/release dispatch | `dialog_body/border`, `form_ink/dim`, `TabAccent`, icon palette, font/spacing tokens |