M1: layers panel depth: search, shift-click ranges, sticky collapse, reveal selection, Tab rename (#4)

- Search toggle in the Layers header: a live filter that lists matching layers across the page.
- Shift-click selects the visible rows from the anchor to the clicked row (Cmd adds them to the
  selection); Cmd-click toggles.
- The tree reveals a new selection once (expands its ancestors and scrolls its row into view),
  so Collapse layers (Alt+L) and manual collapsing stick.
- Alt-click a disclosure triangle expands or collapses everything inside.
- Tab / Shift+Tab while renaming commits and renames the next / previous layer.
- Control channel: `ui.set {layerFilter, collapseLayers}`.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 02:34:55 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 4286a3b74b
commit 6610966921
5 changed files with 165 additions and 15 deletions
+3 -3
View File
@@ -9,7 +9,7 @@ continuous Claude Opus 5.5 agent work (including builds and the CI gate), for **
**4–6 parallel agents** on disjoint crates. They are counted from the remaining work in the parity
table below and updated as work lands.
_Last updated: 2026-10-10 (vector edit mode tools, auto layout depth)._
_Last updated: 2026-10-10 (vector edit mode tools, auto layout depth, layers panel depth)._
## Where we are
@@ -118,7 +118,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
|---|---|---|---|---|
| Canvas & navigation | 6 | 60% | rulers/guides dragging, pixel preview, minimap-free perf at scale | 8–12 |
| Selection & transform | 8 | 55% | rotated multi-select handles, scale tool polish, measure hover polish, nudge-resize | 12–18 |
| Layers panel | 5 | 55% | search/filter, multi-select ranges, collapse all, thumbnails, drag polish | 8–12 |
| Layers panel | 5 | 70% | drag polish (auto-scroll while dragging, drop into collapsed groups), keyboard row navigation | 4–6 |
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Paint | 7 | 55% | gradient handles on canvas, image crop mode, document colours, colour models | 12–18 |
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
@@ -133,7 +133,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Dev mode / inspect | 2 | 35% | measurements on canvas, code variants (Swift/Compose), assets | 6–10 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |
Weighted feature depth ≈ **47%**.
Weighted feature depth ≈ **48%**.
## Milestones
+8
View File
@@ -227,6 +227,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
app.ui.comments.show_resolved = v;
}
// Layers panel search: a string opens it with that filter, null closes it.
if let Some(v) = p.get("layerFilter") {
app.ui.layer_search = v.is_string();
app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
}
if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
app.ui.expanded.clear();
}
Ok(app.inspect())
}
"ui.render" => {
+142 -10
View File
@@ -253,28 +253,114 @@ fn collect(doc: &Document, id: NodeId, depth: usize, expanded: &std::collections
}
}
/// Layers whose name contains `q` (lowercase), top of the stack first, as a flat list.
fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec<Row>) {
let Some(n) = doc.node(id) else { return };
for c in n.children.iter().rev() {
let Some(cn) = doc.node(*c) else { continue };
let name = if cn.name.is_empty() { cn.kind.label() } else { cn.name.as_str() };
if name.to_lowercase().contains(q) {
out.push(Row { id: *c, depth: 0, has_kids: false });
}
if out.len() < 5000 {
matching(doc, *c, q, out);
}
}
}
/// Expand or collapse `id` and everything inside it (Alt-click on a disclosure triangle).
fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut std::collections::BTreeSet<NodeId>) {
let mut stack = vec![id];
while let Some(n) = stack.pop() {
if open {
expanded.insert(n);
} else {
expanded.remove(&n);
}
if let Some(node) = doc.node(n) {
stack.extend(node.children.iter().copied());
}
}
}
/// The search row under the Layers header (live filter; Escape closes it).
fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) {
let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover());
let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0));
ui.painter().rect_filled(field, CornerRadius::same(5), t.field);
icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 12.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary);
let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect));
let r = child.add(
egui::TextEdit::singleline(&mut app.ui.layer_filter)
.id(id)
.frame(egui::Frame::NONE)
.font(regular(11.0))
.hint_text("Find layers")
.vertical_align(egui::Align::Center)
.desired_width(te_rect.width()),
);
if r.has_focus() {
ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
}
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.layer_search = false;
app.ui.layer_filter.clear();
}
}
fn layers(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Layers", semibold(11.0), t.text);
let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0));
let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).on_hover_text("Search layers");
if sresp.hovered() || app.ui.layer_search {
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon);
let search_id = Id::new("layer_search_field");
if sresp.clicked() {
app.ui.layer_search = !app.ui.layer_search;
app.ui.layer_filter.clear();
if app.ui.layer_search {
ui.memory_mut(|m| m.request_focus(search_id));
}
}
if app.ui.layer_search {
search_field(app, ui, &t, w, search_id);
}
let Ok(ds) = app.session.ds() else { return };
let doc = ds.doc.clone();
let page = ds.page;
let sel = ds.selection.clone();
let hover = ds.hover;
// Auto-expand ancestors of the selection.
for s in &sel {
for a in doc.ancestors(*s) {
if a != page {
app.ui.expanded.insert(a);
// Reveal a new selection: expand its ancestors and scroll its row into view, once, so
// "Collapse layers" and manual collapsing stick.
let reveal = sel != app.ui.layers_seen_sel;
if reveal {
for s in &sel {
for a in doc.ancestors(*s) {
if a != page {
app.ui.expanded.insert(a);
}
}
}
app.ui.layers_seen_sel = sel.clone();
}
let filter = app.ui.layer_filter.trim().to_lowercase();
let mut rows = Vec::new();
collect(&doc, page, 0, &app.ui.expanded, &mut rows);
if filter.is_empty() {
collect(&doc, page, 0, &app.ui.expanded, &mut rows);
} else {
matching(&doc, page, &filter, &mut rows);
}
let scroll_to = if reveal { sel.first().copied() } else { None };
let mut clicked: Option<(NodeId, egui::Modifiers)> = None;
let mut toggle: Option<NodeId> = None;
let mut deep: Option<(NodeId, bool)> = None;
let mut rename_next: Option<(usize, bool)> = None;
let mut vis: Option<NodeId> = None;
let mut lock: Option<NodeId> = None;
let mut rename_done: Option<(NodeId, String)> = None;
@@ -286,13 +372,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(16.0);
crate::widgets::label(ui, "Add a frame (F) or a shape (R) to get started", t.text_secondary);
let hint = if filter.is_empty() { "Add a frame (F) or a shape (R) to get started" } else { "No matching layers" };
crate::widgets::label(ui, hint, t.text_secondary);
});
}
let dragging: Option<NodeId> = ui.data(|d| d.get_temp(drag_id));
for row in &rows {
for (ri, row) in rows.iter().enumerate() {
let Some(n) = doc.node(row.id) else { continue };
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
if scroll_to == Some(row.id) {
ui.scroll_to_rect(r, None);
}
let selected = sel.contains(&row.id);
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 { .. });
@@ -320,7 +410,11 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let open = app.ui.expanded.contains(&row.id);
icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
if tresp.clicked() {
toggle = Some(row.id);
if ui.input(|i| i.modifiers.alt) {
deep = Some((row.id, !open));
} else {
toggle = Some(row.id);
}
}
}
let icon_col = if is_comp {
@@ -334,6 +428,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let name_x = x0 + 36.0;
let editing = app.ui.rename.as_ref().is_some_and(|(i, _)| *i == row.id);
if editing {
// Tab / Shift+Tab: commit and rename the next / previous layer.
if let Some(back) = ui.input(|i| i.key_pressed(egui::Key::Tab).then_some(i.modifiers.shift)) {
rename_next = Some((ri, back));
}
let tr = Rect::from_min_max(pos2(name_x - 4.0, r.min.y + 4.0), pos2(rr.max.x - 4.0, r.max.y - 4.0));
if let Some(new) = crate::widgets::text_field_inline(ui, Id::new(("rename", row.id)), &n.name, tr, regular(11.0)) {
rename_done = Some((row.id, new));
@@ -468,6 +566,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
{
app.ui.expanded.insert(id);
}
if let Some((id, open)) = deep {
set_expanded_deep(&doc, id, open, &mut app.ui.expanded);
}
if let Some(id) = vis {
app.run("object.toggleVisible", json!({"ids": [id]}));
}
@@ -478,18 +579,49 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
app.ui.rename = None;
app.run("object.rename", json!({"ids": [id], "name": new}));
}
if let Some((i, back)) = rename_next {
app.ui.rename = None;
let next = if back { i.checked_sub(1) } else { i.checked_add(1) }.and_then(|j| rows.get(j));
if let Some(row) = next
&& let Some(n) = doc.node(row.id)
{
app.ui.rename = Some((row.id, n.name.clone()));
app.run("select.set", json!({"ids": [row.id]}));
app.ui.layers_seen_sel = app.session.selection();
ui.memory_mut(|m| m.request_focus(Id::new(("rename", row.id)).with("te")));
}
}
if let Some((id, m)) = clicked {
if m.shift || m.command {
let order: Vec<NodeId> = rows.iter().map(|r| r.id).collect();
let pos = |x: NodeId| order.iter().position(|o| *o == x);
let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
if m.shift
&& let (Some(ai), Some(bi)) = (anchor, pos(id))
{
// Shift: the visible rows from the anchor to here (added to the selection with Cmd).
let (lo, hi) = (ai.min(bi), ai.max(bi));
let mut s: Vec<NodeId> = if m.command { sel.clone() } else { vec![] };
for x in order.get(lo..=hi).unwrap_or_default() {
if !s.contains(x) {
s.push(*x);
}
}
app.run("select.set", json!({"ids": s}));
} else if m.shift || m.command {
let mut s = sel.clone();
if let Some(pos) = s.iter().position(|x| *x == id) {
s.remove(pos);
} else {
s.push(id);
}
app.ui.layer_anchor = Some(id);
app.run("select.set", json!({"ids": s}));
} else {
app.ui.layer_anchor = Some(id);
app.run("select.set", json!({"ids": [id]}));
}
// A click in the panel needs no reveal scroll.
app.ui.layers_seen_sel = app.session.selection();
if let Ok(ds) = app.session.ds_mut()
&& ds.text_edit.is_some()
{
+8
View File
@@ -98,7 +98,12 @@ pub struct UiState {
pub present: Option<present::Presenter>,
pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// Layers panel search: open, the filter text, the shift-click range anchor and the selection
/// the tree last revealed (ancestors expand and the row scrolls into view when it changes).
pub layer_search: bool,
pub layer_filter: String,
pub layer_anchor: Option<NodeId>,
pub layers_seen_sel: Vec<NodeId>,
pub dark: bool,
pub follow_system: bool,
pub tool_flyout: Option<&'static str>,
@@ -135,7 +140,10 @@ impl Default for UiState {
present: None,
picker: None,
context_menu: None,
layer_search: false,
layer_filter: String::new(),
layer_anchor: None,
layers_seen_sel: Vec::new(),
dark: false,
follow_system: false,
tool_flyout: None,
+4 -2
View File
@@ -88,10 +88,12 @@ text tool).
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, layerFilter?, collapseLayers?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool). Returns `ui.inspect`.
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `layerFilter` (string:
open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the
layers tree). Returns `ui.inspect`.
### `ui.screenshot {path}`