Merge pull request #2010 from Carbocode/fix/layer-selection-highlight

M8: draw continuous rounded highlights in the layers tree
This commit is contained in:
BFlat
2026-11-07 10:47:55 +08:00
committed by GitHub
4 changed files with 148 additions and 9 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();
+34 -6
View File
@@ -962,15 +962,34 @@ 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: &BTreeSet<NodeId>, id: NodeId) -> Option<NodeId> {
if sel.is_empty() {
return None;
}
doc.ancestors(id).into_iter().rev().find(|a| sel.contains(a)).or_else(|| sel.contains(&id).then_some(id))
}
fn hierarchy_bg(roots: &[Option<NodeId>], i: usize, mut rr: Rect, gap: f32) -> Option<(Rect, CornerRadius)> {
let root = roots.get(i).copied().flatten()?;
let joins = |neighbor: &Option<NodeId>| *neighbor == Some(root);
let above = i.checked_sub(1).and_then(|j| roots.get(j)).is_some_and(joins);
let below = roots.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);
}
}
@@ -1071,15 +1090,20 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
drag_autoscroll(ui, dragging.is_some());
let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len());
let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len());
// Compute each visible row's root once, plus one neighbor outside each end
// so scrolling never rounds a seam in the middle of a selected subtree.
let selected_ids: BTreeSet<_> = sel.iter().copied().collect();
let roots_start = first.saturating_sub(1);
let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect();
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band));
// 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);
let selected = selected_ids.contains(&row.id);
a11y::layer_row(&mut app.ui.nav, &resp);
if sel.last() == Some(&row.id) {
a11y::prefer(&mut app.ui.nav, Region::Layers, resp.id);
@@ -1093,9 +1117,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(&roots, i - roots_start, 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
@@ -494,7 +494,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");
+4 -2
View File
File diff suppressed because one or more lines are too long