diff --git a/ROADMAP.md b/ROADMAP.md index 0388391..919317c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,7 +122,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Selection & transform | 8 | 60% | rotated multi-select handles, scale tool polish, measure hover polish | 10–15 | | 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 | +| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 | | Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 | | Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 | | Frames, groups, sections, masks | 5 | 55% | section tooling, mask types (luminance/outline), frame presets UI | 6–10 | @@ -135,7 +135,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 ≈ **49%**. +Weighted feature depth ≈ **50%**. ## Milestones diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 431231c..57b55d3 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -12,6 +12,19 @@ use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ + spec!( + "paint.documentColors", + "Document colours", + "{page?, limit? (default 21)} → solid colours used on the page, most used first", + false, + |s, p| { + let page = id(p, "page").unwrap_or(s.page()?); + let limit = num(p, "limit").map(|l| l.clamp(1.0, 500.0) as usize).unwrap_or(21); + let colors = document_colors(s.doc()?, page); + let out: Vec = colors.into_iter().take(limit).map(|(c, n)| json!({"color": c.to_hex(), "count": n})).collect(); + Ok(json!({"colors": out})) + } + ), spec!( "style.create", "Create style", @@ -461,3 +474,28 @@ fn unique_id<'a>(prefix: &str, existing: impl Iterator) -> Strin let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::().ok()).max().unwrap_or(0); format!("{prefix}:{}", max + 1) } + +/// Solid fill and stroke colours used under `page` (alpha includes the paint's opacity), most used +/// first. +pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { + let mut counts: BTreeMap<[u8; 4], (Color, usize)> = BTreeMap::new(); + let mut stack = vec![page]; + let mut seen = 0usize; + while let Some(id) = stack.pop() { + seen += 1; + if seen > 200_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + stack.extend(n.children.iter().copied()); + for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) { + if let Some(c) = paint.solid_color() { + let c = c.with_alpha(c.a * paint.opacity); + counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1; + } + } + } + let mut out: Vec<(Color, usize)> = counts.into_values().collect(); + out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_hex().cmp(&b.0.to_hex()))); + out +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2cbd260..e85ed43 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -382,3 +382,24 @@ fn arrows_reorder_auto_layout_children_and_cmd_arrows_resize() { let r = s.doc().unwrap().node(row).unwrap().clone(); assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug); } + +#[test] +fn document_colors_are_counted_per_page() { + let mut s = sess(); + for (i, c) in ["#FF0000", "#FF0000", "#00FF00"].iter().enumerate() { + let r = id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("node.set", &json!({"id": r, "props": {"fills": [c], "strokes": ["#0000FF"]}})).unwrap(); + } + let v = s.execute("paint.documentColors", &json!({})).unwrap(); + let list: Vec<(String, u64)> = + v["colors"].as_array().unwrap().iter().map(|c| (c["color"].as_str().unwrap().to_string(), c["count"].as_u64().unwrap())).collect(); + assert_eq!(list.first(), Some(&("0000FF".to_string(), 3))); + assert!(list.contains(&("FF0000".to_string(), 2))); + assert!(list.contains(&("00FF00".to_string(), 1))); + let v = s.execute("paint.documentColors", &json!({"limit": 1})).unwrap(); + assert_eq!(v["colors"].as_array().unwrap().len(), 1); + // Another page is separate. + s.execute("page.add", &json!({})).unwrap(); + let v = s.execute("paint.documentColors", &json!({})).unwrap(); + assert!(v["colors"].as_array().unwrap().is_empty()); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 05bbd19..12d37a2 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -76,6 +76,133 @@ fn finish(app: &mut FigApp) { } } +/// The selected gradient stop in the picker (shared with the on-canvas stops). +pub fn stop_id() -> Id { + Id::new("cp_stop") +} + +/// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's. +fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Color, stop: Id, ui: &Ui) { + match &mut paint.kind { + PaintKind::Solid { color } => { + *color = c.with_alpha(1.0); + paint.opacity = c.a; + } + PaintKind::Gradient(g) => { + let sel: usize = ui.data(|d| d.get_temp(stop)).unwrap_or(0); + if let Some(s) = g.stops.get_mut(sel) { + s.color = c; + } + } + PaintKind::Image(_) => return, + } + write(app, target, paint, false); +} + +/// The page's most used solid colours, recomputed only when the document snapshot changes. +fn page_colors(app: &FigApp, ui: &Ui) -> Vec { + let Ok(ds) = app.session.ds() else { return vec![] }; + let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page); + let cache_id = Id::new("cp_doc_colors"); + if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec)>(cache_id)) + && k == key + { + return colors; + } + let colors: Vec = figcraft_engine::cmd::styles::document_colors(&ds.doc, ds.page).into_iter().take(21).map(|(c, _)| c).collect(); + ui.data_mut(|d| d.insert_temp(cache_id, (key, colors.clone()))); + colors +} + +/// Colour models the value row can show. +const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; + +/// The model dropdown with its fields and the alpha field. Returns a committed colour. +fn model_fields(ui: &mut Ui, c: Color) -> Option { + let model_id = Id::new("cp_model"); + let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); + let mut out: Option = None; + ui.horizontal(|ui| { + ui.add_space(16.0); + ui.spacing_mut().item_spacing.x = 4.0; + let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None); + egui::Popup::menu(&resp).show(|ui| { + for (i, m) in MODELS.iter().enumerate() { + if crate::right::menu_item(ui, m, "") { + ui.data_mut(|d| d.insert_temp(model_id, i)); + ui.close(); + } + } + }); + let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( + ui, + Id::new(("cp_f", id)), + widgets::Lead::None, + Some(v.round()), + "", + w, + 1.0, + ) { + Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x), + None => None, + }; + match MODELS.get(model).copied().unwrap_or("Hex") { + "RGB" => { + let [r, g, b, _] = c.to_rgba8(); + let vals = [f64::from(r), f64::from(g), f64::from(b)]; + for (k, v) in vals.iter().enumerate() { + if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) { + let mut rgb = vals; + if let Some(slot) = rgb.get_mut(k) { + *slot = x.clamp(0.0, 255.0); + } + out = Some(Color::new(rgb[0] as f32 / 255.0, rgb[1] as f32 / 255.0, rgb[2] as f32 / 255.0, c.a)); + } + } + } + "HSL" | "HSB" => { + let hsl = model == 2; + let (h, s2, l2) = if hsl { + let x = figcraft_color::Hsl::from_color(c); + (x.h, x.s, x.l) + } else { + let x = Hsb::from_color(c); + (x.h, x.s, x.b) + }; + let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0]; + for (k, v) in vals.iter().enumerate() { + if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) { + let mut v3 = vals; + if let Some(slot) = v3.get_mut(k) { + *slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) }; + } + let (h, a, b) = (v3[0] as f32, v3[1] as f32 / 100.0, v3[2] as f32 / 100.0); + let col = if hsl { figcraft_color::Hsl { h, s: a, l: b }.to_color() } else { Hsb { h, s: a, b }.to_color() }; + out = Some(col.with_alpha(c.a)); + } + } + } + "CSS" => { + if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") { + out = Color::parse_css(&text); + } + } + _ => { + if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") { + out = Color::from_hex(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); + } + } + } + if MODELS.get(model) != Some(&"CSS") + && let Some(widgets::FieldEdit::Set(v)) = + widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0) + { + out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)); + } + }); + out +} + fn hue_color(h: f32) -> Color32 { c32(Hsb { h, s: 1.0, b: 1.0 }.to_color()) } @@ -265,7 +392,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)), _ => None, }; - let sel_stop_id = Id::new("cp_stop"); + let sel_stop_id = stop_id(); if let PaintKind::Gradient(g) = &paint.kind { let mut g = g.clone(); let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1)); @@ -451,38 +578,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - // Hex + alpha fields. - let mut hex_set = None; - let mut alpha_set = None; - ui.horizontal(|ui| { - ui.add_space(16.0); - widgets::label(ui, "Hex", t.text_secondary); - if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &nc.to_hex(), 96.0, "") { - hex_set = Color::from_hex(&h); - } - if let Some(widgets::FieldEdit::Set(v)) = - widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(nc.a) * 100.0).round()), "%", 56.0, 1.0) - { - alpha_set = Some((v / 100.0).clamp(0.0, 1.0) as f32); - } - }); - if let Some(h) = hex_set { - nc = h.with_alpha(if h.a < 1.0 { h.a } else { nc.a }); + // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha. + if let Some(c) = model_fields(ui, nc) { + nc = c; hsb = Hsb::from_color(nc); changed = true; done = true; } - if let Some(a) = alpha_set { - nc = nc.with_alpha(a); - changed = true; - done = true; - } - // RGB readout. - let [r, g, b, _] = nc.to_rgba8(); - ui.horizontal(|ui| { - ui.add_space(16.0); - widgets::label(ui, &format!("RGB {r} {g} {b}"), t.text_secondary); - }); ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); if changed { match &mut paint.kind { @@ -504,7 +606,23 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { finish(app); } } - // Document / recent colours. + // Colours used on this page. + let doc_colors = page_colors(app, ui); + if !doc_colors.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, "On this page", t.text_secondary); + }); + ui.horizontal_wrapped(|ui| { + ui.add_space(16.0); + for c in &doc_colors { + if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() { + set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + } + } + }); + } + // Recent colours. let recent = app.session.recent_colors.clone(); if !recent.is_empty() { ui.horizontal(|ui| { @@ -515,11 +633,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.add_space(16.0); for c in recent.iter().take(14) { if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() { - if let PaintKind::Solid { color } = &mut paint.kind { - *color = c.with_alpha(1.0); - paint.opacity = c.a; - } - write(app, &target, &paint, false); + set_color(app, &target, &mut paint, *c, sel_stop_id, ui); } } }); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4316b3d..b29b674 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -232,6 +232,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; } + // Colour picker on the selection's paint `{fills?, index?}`; null closes it. + if let Some(v) = p.get("colorPicker") { + app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint { + fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true), + index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize, + ids: app.session.selection(), + }); + } // 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(); diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index 3c26253..6773e48 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -17,7 +17,7 @@ const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); #[derive(Clone, Debug)] enum Drag { - /// Gradient handle 0 = from, 1 = to, 2 = width. + /// Gradient handle 0 = from, 1 = to, 2 = width; 3 + i = stop `i` (slides along the line). Gradient { id: NodeId, fills: bool, index: usize, which: usize }, /// Padding side 0 top, 1 right, 2 bottom, 3 left. Padding { id: NodeId, side: usize, start: Pos2, value: f64 }, @@ -76,6 +76,41 @@ fn gradient_target(app: &FigApp) -> Option<(NodeId, bool, usize, figcraft_color: } } +/// Where `p` projects onto segment a→b: (t in 0..1, distance in points). +fn line_param(a: Pos2, b: Pos2, p: Pos2) -> Option<(f64, f32)> { + let ab = b - a; + let len2 = ab.length_sq(); + if len2 < 1e-6 { + return None; + } + let t = ((p - a).dot(ab) / len2).clamp(0.0, 1.0); + Some((f64::from(t), (a + ab * t - p).length())) +} + +fn set_gradient(app: &mut FigApp, id: NodeId, fills: bool, index: usize, g: figcraft_color::Gradient) { + let Some(n) = app.session.doc().ok().and_then(|d| d.node(id)).cloned() else { return }; + let list = if fills { &n.fills } else { &n.strokes }; + let Some(mut paint) = list.get(index).cloned() else { return }; + paint.kind = PaintKind::Gradient(g); + let cmd = if fills { "fill.set" } else { "stroke.set" }; + let _ = app.session.execute(cmd, &json!({"ids": [id], "index": index, "paint": paint})); +} + +/// Delete/Backspace while a gradient is open: remove its selected stop (two always stay). +pub fn remove_selected_stop(app: &mut FigApp, ctx: &egui::Context) -> bool { + let Some((id, fills, index, mut g)) = gradient_target(app) else { return false }; + let sel: usize = ctx.data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0); + if g.stops.len() <= 2 || sel >= g.stops.len() { + return g.stops.len() <= 2; + } + g.stops.remove(sel); + ctx.data_mut(|d| d.insert_temp(crate::colorpicker::stop_id(), sel.saturating_sub(1))); + let _ = app.session.begin_interaction("Remove gradient stop"); + set_gradient(app, id, fills, index, g); + let _ = app.session.commit_interaction(); + true +} + fn unit_to_screen(app: &FigApp, id: NodeId, u: (f64, f64)) -> Option { let doc = app.session.doc().ok()?; let n = doc.node(id)?; @@ -210,11 +245,15 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if g.kind != figcraft_color::GradientKind::Linear { painter.add(Shape::dashed_line(&[a, c], Stroke::new(1.0, Color32::WHITE), 4.0, 3.0)); } - for s in g.sorted_stops() { + let selected: usize = painter.ctx().data(|d| d.get_temp(crate::colorpicker::stop_id())).unwrap_or(0); + for (i, s) in g.stops.iter().enumerate() { let p = a + (b - a) * s.position; let r = Rect::from_center_size(p, vec2(12.0, 12.0)); painter.rect_filled(r, CornerRadius::same(2), crate::theme::c32(s.color.with_alpha(1.0))); painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); + if i == selected { + painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + } } for (i, p) in [a, b, c].iter().enumerate() { if i == 2 && g.kind == figcraft_color::GradientKind::Linear { @@ -321,7 +360,34 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { return true; } } - return false; + // Stops: select and slide them; a click elsewhere on the line adds one there. + let (Some(a), Some(b)) = (pts[0], pts[1]) else { return false }; + let stop_hit = g.stops.iter().position(|st| (a + (b - a) * st.position - pos).length() <= 8.0); + let t = line_param(a, b, pos); + let on_line = t.is_some_and(|(_, d)| d <= 5.0); + if stop_hit.is_none() && !on_line { + return false; + } + ctx.set_cursor_icon(if stop_hit.is_some() { egui::CursorIcon::Grab } else { egui::CursorIcon::Copy }); + if pressed { + let stop = match stop_hit { + Some(i) => { + let _ = app.session.begin_interaction("Edit gradient"); + i + } + None => { + let pos01 = t.map(|(t, _)| t).unwrap_or(0.5) as f32; + let mut g2 = g.clone(); + g2.stops.push(figcraft_color::ColorStop { position: pos01, color: g.sample(pos01) }); + let _ = app.session.begin_interaction("Add gradient stop"); + set_gradient(app, id, fills, index, g2); + g.stops.len() + } + }; + ctx.data_mut(|d| d.insert_temp(crate::colorpicker::stop_id(), stop)); + ctx.data_mut(|d| d.insert_temp(drag_id(), Drag::Gradient { id, fills, index, which: 3 + stop })); + } + return true; } if let Some((id, fills, index, _, rect)) = crop_target(app) { let Some(c) = crop_corners(app, id, rect) else { return false }; @@ -387,7 +453,13 @@ fn apply(app: &mut FigApp, drag: &Drag, pos: Pos2, shift: bool) { match which { 0 => g.from = u, 1 => g.to = u, - _ => g.width = u, + 2 => g.width = u, + stop => { + let (Some(a), Some(b)) = (unit_to_screen(app, *id, g.from), unit_to_screen(app, *id, g.to)) else { return }; + if let (Some((t, _)), Some(st)) = (line_param(a, b, pos), g.stops.get_mut(stop - 3)) { + st.position = t as f32; + } + } } } let cmd = if *fills { "fill.set" } else { "stroke.set" }; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 715e260..c6b8090 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -77,7 +77,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context) { crate::menus::invoke(app, "edit.paste"); } } - Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat) => { + Event::Key { key, pressed: true, modifiers, repeat, .. } if key_event(app, *key, *modifiers, *repeat, ctx) => { // handled } _ => {} @@ -85,7 +85,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context) { } } -fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool) -> bool { +fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Context) -> bool { + // A gradient open in the colour picker: Delete removes its selected stop. + if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) { + return true; + } // Vector edit mode has its own keys (tools, nudging points, delete, join); the rest fall through. if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) { match figcraft_engine::vectoredit::key(&mut app.session, key.name(), m.shift, m.alt, m.command) { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bf8e4b5..79b88f5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -88,10 +88,11 @@ 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?, layerFilter?, collapseLayers?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, layerFilter?, collapseLayers?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, -`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `layerFilter` (string: +`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker` +(`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `layerFilter` (string: open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the layers tree). Returns `ui.inspect`.