diff --git a/ROADMAP.md b/ROADMAP.md index ec722f65..10c2f1b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 881d3e9e..22937621 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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" => { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 4d915e3a..23e418a5 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -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) { + 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) { + 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 = None; + let mut deep: Option<(NodeId, bool)> = None; + let mut rename_next: Option<(usize, bool)> = None; let mut vis: Option = None; let mut lock: Option = 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 = 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 = 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 = 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() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1aa9947b..e7849c80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,7 +98,12 @@ pub struct UiState { pub present: Option, pub picker: Option, pub context_menu: Option<(egui::Pos2, Vec)>, + /// 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, + pub layers_seen_sel: Vec, 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, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 20fbfe79..44c22a37 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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}`