M8: join and round layer hierarchy highlights

This commit is contained in:
Carbocode
2026-11-03 10:38:54 +01:00
parent 0e86f3597a
commit ee6ca15ccf
4 changed files with 140 additions and 8 deletions
@@ -51,6 +51,115 @@ fn sorted(mut v: Vec<NodeId>) -> Vec<NodeId> {
v
}
#[test]
fn selected_hierarchies_have_continuous_rounded_backgrounds() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Selected frame"})).unwrap());
let child = rect(&mut s, "child", 0.0, Some(frame));
let group = id(s.execute("object.group", &json!({"ids": [child]})).unwrap());
rect(&mut s, "sibling", 30.0, Some(frame));
let other = id(s.execute("frame.create", &json!({"name": "Other frame"})).unwrap());
rect(&mut s, "other child", 0.0, Some(other));
s.execute("select.set", &json!({"ids": [frame, group, other]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.expanded.extend([frame, group, other]);
let selected_rows = [frame, group, other].map(|id| u.row_at(id));
let out = u.frame_output(vec![Event::PointerGone]);
let t = crate::theme::Tokens::get(&u.ctx);
for pos in selected_rows {
let rect_at = |fill| {
out.shapes.iter().enumerate().find_map(|(i, s)| match &s.shape {
egui::Shape::Rect(r) if r.fill == fill && r.rect.contains(pos) => Some(i),
_ => None,
})
};
let background = rect_at(t.selected_row_child).expect("the selected row belongs to the hierarchy background");
let highlight = rect_at(t.selected_row).expect("each selected frame or group has its own highlight");
assert!(highlight > background, "the selected highlight is painted above the hierarchy background");
}
let backgrounds: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
_ => None,
})
.collect();
// Other frame + child, then selected frame + sibling + selected group + child.
assert_eq!(backgrounds.len(), 6);
for band in [&backgrounds[..2], &backgrounds[2..]] {
assert_eq!(band.first().unwrap().corner_radius.nw, 5);
assert_eq!(band.last().unwrap().corner_radius.sw, 5);
for pair in band.windows(2) {
assert_eq!(pair[0].rect.bottom(), pair[1].rect.top(), "the highlight fills the inter-row spacing");
assert_eq!(pair[0].corner_radius.sw, 0, "no rounded seams inside the hierarchy");
assert_eq!(pair[1].corner_radius.nw, 0, "a selected nested group stays in the same block");
}
}
assert!(backgrounds[1].rect.bottom() < backgrounds[2].rect.top(), "separate selected hierarchies stay separate");
// A collapsed frame and a filtered leaf each have all four corners rounded.
u.app.ui.expanded.clear();
let out = u.frame_output(vec![]);
let collapsed: Vec<_> = out
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == t.selected_row_child => Some(r),
_ => None,
})
.collect();
assert_eq!(collapsed.len(), 2);
assert!(collapsed.iter().all(|r| r.corner_radius == egui::CornerRadius::same(5)));
u.app.ui.layer_filter = "child".into();
let out = u.frame_output(vec![]);
assert!(out.shapes.iter().any(|s| matches!(&s.shape,
egui::Shape::Rect(r) if r.fill == t.selected_row_child && r.corner_radius.sw == 5
)));
}
#[test]
fn selected_hierarchy_keeps_square_seams_when_scrolled() {
let mut s = Session::new();
let frame = id(s.execute("frame.create", &json!({"name": "Long hierarchy"})).unwrap());
for i in 0..70 {
rect(&mut s, &format!("Child {i}"), 0.0, Some(frame));
}
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
let mut u = Ui::new(s);
u.app.ui.expanded.insert(frame);
let fill = crate::theme::Tokens::get(&u.ctx).selected_row_child;
let corners = |out: &egui::FullOutput| {
out.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Rect(r) if r.fill == fill && r.rect.intersects(s.clip_rect) => Some(r.corner_radius),
_ => None,
})
.collect::<Vec<_>>()
};
let wheel = |y| Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, y),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
};
u.frame(vec![Event::PointerMoved(egui::pos2(150.0, 500.0)), wheel(-700.0)]);
for _ in 0..30 {
u.frame(vec![]);
}
let middle = corners(&u.frame_output(vec![]));
assert!(!middle.is_empty());
assert!(middle.iter().all(|c| *c == egui::CornerRadius::ZERO), "viewport edges inside a subtree are square seams");
u.frame(vec![wheel(-10000.0)]);
for _ in 0..30 {
u.frame(vec![]);
}
let end = corners(&u.frame_output(vec![]));
assert_eq!(end.first().unwrap().nw, 0, "the hierarchy starts above the viewport");
assert_eq!(end.last().unwrap().sw, 5, "the hierarchy's real end stays rounded");
}
#[test]
fn clicking_rows_selects_by_the_documented_rules() {
let (mut u, [a, b, c, d]) = four();
+27 -5
View File
@@ -956,15 +956,33 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI
reveal
}
/// The outermost selected layer owning a row's continuous hierarchy highlight.
fn selection_root(doc: &Document, sel: &[NodeId], id: NodeId) -> Option<NodeId> {
doc.ancestors(id).into_iter().rev().find(|a| sel.contains(a)).or_else(|| sel.contains(&id).then_some(id))
}
fn hierarchy_bg(doc: &Document, sel: &[NodeId], rows: &[Row], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> {
let root = selection_root(doc, sel, rows.get(i)?.id)?;
// Look beyond the viewport too: scrolling must not round an edge in the
// middle of a selected subtree. Separate selected subtrees keep their corners.
let joins = |neighbor: &Row| selection_root(doc, sel, neighbor.id) == Some(root);
let above = i.checked_sub(1).and_then(|j| rows.get(j)).is_some_and(joins);
let below = rows.get(i + 1).is_some_and(joins);
if below {
rr.max.y += gap;
}
let top = if above { 0 } else { 5 };
let bottom = if below { 0 } else { 5 };
Some((rr, CornerRadius { nw: top, ne: top, sw: bottom, se: bottom }))
}
/// A row's background: selected, inside a selected layer, or outlined when hovered.
/// `state` is [selected, in_selected, component].
fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
let [selected, in_selected, is_comp] = state;
if selected {
ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
} else if in_selected {
ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
} else if hovered {
} else if !in_selected && hovered {
ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
}
}
@@ -1070,7 +1088,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
// Stable widget ids for a row wherever the list is scrolled.
band_ui.skip_ahead_auto_ids(first);
let ui = &mut band_ui;
for row in rows.iter().take(last).skip(first) {
for (i, row) in rows.iter().enumerate().take(last).skip(first) {
let Some(n) = doc.node(row.id) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
let selected = sel.contains(&row.id);
@@ -1087,9 +1105,13 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
locked: n.locked,
};
a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info);
let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a));
let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let rr = r.shrink2(vec2(8.0, 0.0));
let bg = hierarchy_bg(&doc, &sel, &rows, i, rr, pitch - ROW);
let in_selected = !selected && bg.is_some();
if let Some((rect, corners)) = bg {
ui.painter().rect_filled(rect, corners, t.selected_row_child);
}
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
let over = resp.contains_pointer();
+1 -1
View File
@@ -493,7 +493,7 @@ mod tests {
let theme = if t.dark { "dark" } else { "light" };
// Disabled text (a disabled button's label, placeholders) on panels, fields and
// selected rows, still dimmer than secondary text.
for bg in [t.panel, t.field, t.hover, t.selected_row] {
for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child] {
let ratio = c(t.text_tertiary).contrast(c(bg));
assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary);
assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text");
+3 -2
View File
File diff suppressed because one or more lines are too long