mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge pull request #2010 from Carbocode/fix/layer-selection-highlight
M8: draw continuous rounded highlights in the layers tree
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user