From dc97118c6f0c8e3be88087d6e9a910d069be042e Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 22:58:36 +0300 Subject: [PATCH 1/4] Actions palette rework: Figma-shaped panel over the toolbar Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences (newest first, capped at 5), commands grouped by top-level menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles. Rows carry right-side shortcut/kind hints; Enter runs the first visible row. The panel floats 8 px above the toolbar pill with its exact width and centre (toolbar::main_width, canvas-centred), at a fixed size so tab switches never resize it. Tabs keep their frames when idle so the active pill never moves its neighbours. Engine: Prefs.recent_actions (persisted, skipped when empty). UI: invoke/menu/plugin picks record recents; gone entries drop out. Locales: new keys in en + es, retired the old search hint everywhere. Docs: ui-parity row describes the new surface (palette_tests). --- .gitignore | 2 + crates/engine/src/lib.rs | 9 + crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 5 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 10 +- crates/ui-egui/src/menus.rs | 405 +++++++++++++++++++++++++--- crates/ui-egui/src/palette_tests.rs | 60 +++++ crates/ui-egui/src/plugins_ui.rs | 2 + crates/ui-egui/src/toolbar.rs | 15 +- docs/ui-parity.md | 2 +- 22 files changed, 467 insertions(+), 60 deletions(-) create mode 100644 crates/ui-egui/src/palette_tests.rs diff --git a/.gitignore b/.gitignore index 928d6b49..3709c74a 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ plans/ /log/ .DS_Store *.profraw + +plugins/target/ diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..09b34120 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -652,8 +652,16 @@ pub struct Prefs { pub home_sort: String, /// Which way: `"desc"` (newest first, Z to A) or `"asc"`. pub home_order: String, + /// Recently run actions (the Actions palette's Recents), newest first, at most + /// [`MAX_RECENT_ACTIONS`]. Entries are `command:`, `plugin:/` or + /// `widget:`. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub recent_actions: Vec, } +/// Recently run actions kept for the Actions palette (newest first). +pub const MAX_RECENT_ACTIONS: usize = 5; + impl Default for Prefs { fn default() -> Self { Self { @@ -681,6 +689,7 @@ impl Default for Prefs { home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), + recent_actions: Vec::new(), } } } diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 268ff527..e86da423 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,7 +1133,6 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" -"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..862527e4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,7 +1096,6 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" -"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..703f018c 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -965,8 +965,10 @@ name = "English" "Place a shape or frame (its tool chosen)" = "Place a shape or frame (its tool chosen)" "Place automatically" = "Place automatically" "Place image" = "Place image" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Plus darker" "Plus lighter" = "Plus lighter" "Point" = "Point" @@ -1148,7 +1150,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, layers and tools" = "Search actions, layers and tools" +"Search actions, assets and plugins" = "Search actions, assets and plugins" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" @@ -1468,6 +1470,7 @@ name = "English" "White" = "White" "Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files" "Why it changed" = "Why it changed" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Width" "Width point" = "Width point" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..554d9983 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -966,8 +966,10 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "Colocar forma o marco (herramienta elegida)" "Place automatically" = "Colocar automáticamente" "Place image" = "Colocar imagen" +"Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1149,8 +1151,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" -"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" +"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" @@ -1469,6 +1471,7 @@ status = "draft — needs native review" "White" = "Blanco" "Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos" "Why it changed" = "Por qué cambió" +"Widget" = "Widget" "Widgets" = "Widgets" "Width" = "Ancho" "Width point" = "Punto de ancho" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7fafd3c9..2c1fbc45 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" -"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 5de7634d..53825747 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,7 +1137,6 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" -"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 4d4d6d34..670f84fc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,7 +1140,6 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" -"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index ec732905..1f7bed0c 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,7 +1090,6 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" -"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index c2189de8..2fb61d12 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,7 +1089,6 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" -"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..78a8afd6 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,7 +1134,6 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" -"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..a54c02e7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,7 +1135,6 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" -"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a2be6b99..43603287 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,7 +1137,6 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" -"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index af4cf653..a42360e4 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,7 +1091,6 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" -"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7a196cf2..9dd8e2f4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -415,6 +415,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul } if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; + app.ui.palette_tab = crate::menus::PaletteTab::All; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..46830316 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -136,6 +136,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod palette_tests; +#[cfg(test)] mod panel_remove_tests; #[cfg(test)] mod panel_reorder_tests; @@ -404,6 +406,8 @@ pub struct UiState { pub rename_page: Option<(NodeId, String)>, pub main_menu: bool, pub palette: Option, + /// The Actions palette's tab. + pub palette_tab: menus::PaletteTab, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -533,6 +537,7 @@ impl Default for UiState { rename_page: None, main_menu: false, palette: None, + palette_tab: menus::PaletteTab::All, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -788,7 +793,10 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => self.ui.palette = Some(String::new()), + "palette.actions" => { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..0e079313 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,9 +7,9 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::i18n::{self, tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::i18n::{self, tr, tr_noop, trf}; +use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; /// Commands the UI layer implements itself (dialogs and platform actions). @@ -73,7 +73,28 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } +/// The Actions palette's tab. +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum PaletteTab { + #[default] + All, + Assets, + Plugins, +} + +/// Remember a ran action for the palette's Recents (newest first, capped). +pub fn record_recent(app: &mut FigApp, key: &str) { + let r = &mut app.session.prefs.recent_actions; + if r.first().is_some_and(|f| f == key) { + return; + } + r.retain(|k| k != key); + r.insert(0, key.to_string()); + r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); +} + pub fn invoke(app: &mut FigApp, id: &str) { + record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -612,14 +633,25 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) } } -/// The Actions palette: fuzzy search over every command. +/// The Actions palette's content height (the frame adds its own 8 px margins). +const PALETTE_H: f32 = 450.0; + +/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, +/// recents, grouped sections). Rows run on click; Enter runs the first visible row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); - let screen = ctx.content_rect(); + // Same centre and width as the toolbar pill (it centres on the canvas, not the window), + // floating 8 px above it. Fixed size: switching tabs never resizes the panel. + let bar_w = crate::toolbar::main_width(); + let canvas = app.canvas.rect; + let area = Rect::from_min_size( + pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), + vec2(bar_w, PALETTE_H + 16.0), + ); let mut run: Option = None; let mut close = false; - egui::Area::new(Id::new("palette")).fixed_pos(pos2(screen.center().x - 260.0, screen.min.y + 120.0)).order(egui::Order::Foreground).show( + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { egui::Frame::NONE @@ -629,64 +661,353 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) .inner_margin(egui::Margin::same(8)) .show(ui, |ui| { - ui.set_width(520.0); + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = crate::toolbar::main_width() - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); let r = ui.add( egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, layers and tools")) - .desired_width(500.0) + .hint_text(tr("Search actions, assets and plugins")) + .desired_width(inner - 16.0) .font(regular(14.0)) .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), ); r.request_focus(); crate::widgets::select_all_on_focus(ui, &r, &q); ui.add_space(4.0); - let ql = q.to_lowercase(); - // Labels match in the UI language and in English. - let matches = - |label: &str, english: &str, id: &str| ql.is_empty() || [label, english, id].iter().any(|s| s.to_lowercase().contains(&ql)); - let mut hits: Vec<(&str, std::borrow::Cow, &str)> = Vec::new(); - let menu_items = MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)).map(|m| (m.id, m.label)); - for (id, english) in figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)).chain(menu_items) { - let label = i18n::command(id, english); - if matches(&label, english, id) { - hits.push((id, label, crate::shortcuts::shortcut(id).unwrap_or(""))); - } - } - hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); - hits.dedup_by(|a, b| a.0 == b.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - for (i, (id, label, sc)) in hits.iter().take(60).enumerate() { - let w = ui.available_width(); - let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click()); - if resp.hovered() || i == 0 { - ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover }); - } - let col = if resp.hovered() { Color32::WHITE } else { t.text }; - ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, label.as_ref(), regular(12.0), col); - ui.painter().text( - pos2(r.max.x - 10.0, r.center().y), - Align2::RIGHT_CENTER, - pretty_shortcut(sc), - regular(11.0), - if resp.hovered() { Color32::WHITE } else { t.text_secondary }, + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), ); - if resp.clicked() || (enter && i == 0) { - run = Some(id.to_string()); + if resp.clicked() { + app.ui.palette_tab = tab; } } }); + ui.add_space(4.0); + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let mut first: Option = None; + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let tab = app.ui.palette_tab; + if tab == PaletteTab::All { + let recents: Vec = app + .session + .prefs + .recent_actions + .clone() + .iter() + .filter_map(|key| recent_row(app, key, &q)) + .collect(); + if !recents.is_empty() { + section(ui, &t, tr("Recents"), |ui| { + for row in &recents { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, row) { + run = Some(row.key.clone()); + } + } + }); + } + } + if tab != PaletteTab::Plugins { + for (group, rows) in command_sections(&q) { + if tab == PaletteTab::Assets { + continue; + } + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab != PaletteTab::Assets { + let plugins = plugin_rows(app, &q); + let widgets = widget_rows(app, &q); + if !plugins.is_empty() { + section(ui, &t, tr("Plugins"), |ui| { + for row in plugins { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + if !widgets.is_empty() { + section(ui, &t, tr("Widgets"), |ui| { + for row in widgets { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + if tab == PaletteTab::Assets { + for (group, rows) in asset_sections(app, &q) { + section(ui, &t, &group, |ui| { + for row in rows { + first.get_or_insert_with(|| row.key.clone()); + if draw_row(ui, &t, &row) { + run = Some(row.key); + } + } + }); + } + } + }); + if enter { + if let Some(key) = first { + run = Some(key); + } + } }); }, ); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } - if let Some(id) = run { + if let Some(key) = run { app.ui.palette = None; - invoke(app, &id); + run_action(app, &key); return; } app.ui.palette = if close { None } else { Some(q) }; let _ = StrokeKind::Inside; } + +/// Run a palette row: `command:`, `plugin:/`, `widget:`, +/// `component:` or `style:`. +fn run_action(app: &mut FigApp, key: &str) { + if let Some(id) = key.strip_prefix("command:") { + invoke(app, id); + } else if let Some(rest) = key.strip_prefix("plugin:") { + if let Some((plugin, command)) = rest.split_once('/') { + record_recent(app, key); + crate::plugins_ui::start(app, plugin, command); + } + } else if let Some(plugin) = key.strip_prefix("widget:") { + record_recent(app, key); + app.run("widget.insert", json!({ "plugin": plugin })); + } else if let Some(node) = key.strip_prefix("component:") { + if let Ok(n) = node.parse::() { + let (cx, cy) = app.session.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + app.run("instance.place", json!({ "component": n, "x": cx, "y": cy })); + } + } else if let Some(style) = key.strip_prefix("style:") { + if app.session.selection().is_empty() { + app.toast(tr("Select layers to apply the style to")); + } else { + app.run("style.apply", json!({ "style": style })); + } + } +} + +/// One pickable palette row. +pub(crate) struct ActionRow { + pub(crate) key: String, + pub(crate) label: String, + hint: String, + /// A letter tile instead of an icon (plugins carry no artwork). + tile: Option, +} + +/// A section header with its rows. +fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { + ui.add_space(6.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); + ui.add_space(18.0); + rows(ui); +} + +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. +/// True when clicked. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + } + let mut label_x = r.min.x + 10.0; + if let Some(letter) = row.tile { + let tile = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(18.0, 18.0)); + ui.painter().rect_filled(tile, CornerRadius::same(5), t.hover); + ui.painter().text(tile.center(), Align2::CENTER_CENTER, letter.to_string(), regular(11.0), t.text); + label_x = r.min.x + 38.0; + } + ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); + ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); + resp.clicked() +} + +/// Whether `label` (or `extra`) matches the query, in any case. +fn matches(q: &str, label: &str, extra: &str) -> bool { + q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) +} + +/// Commands grouped by top-level menu, in catalog order, filtered by the query. +pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let mut groups: Vec<(String, Vec)> = Vec::new(); + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for m in figcraft_engine::catalog::MENU.iter() { + if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + continue; + } + let english = m.label; + let label = i18n::menu_item(m.id, english).into_owned(); + if !matches(&ql, &label, &format!("{english} {}", m.id)) { + continue; + } + let group = m.menu.split('/').next().unwrap_or(&m.menu); + let group = tr(group).to_string(); + let row = ActionRow { + key: format!("command:{}", m.id), + label, + hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }; + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, rows)) => rows.push(row), + None => groups.push((group, vec![row])), + } + } + groups +} + +/// Installed plugins' commands, filtered by the query. +fn plugin_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + let mut rows = Vec::new(); + for p in &app.session.prefs.plugins { + let m = &p.manifest; + for c in &m.commands { + let label = if m.commands.len() == 1 { m.name.clone() } else { format!("{} — {}", m.name, c.label) }; + if !matches(&ql, &label, &m.id) { + continue; + } + rows.push(ActionRow { + key: format!("plugin:{}/{}", m.id, c.id), + label, + hint: if c.params.is_empty() { tr("Plugin").to_string() } else { c.label.clone() }, + tile: m.name.chars().next(), + }); + } + } + rows +} + +/// Installed widgets, filtered by the query. +fn widget_rows(app: &FigApp, q: &str) -> Vec { + let ql = q.to_lowercase(); + app.session + .prefs + .plugins + .iter() + .filter(|p| p.manifest.widget) + .filter(|p| matches(&ql, &p.manifest.name, &p.manifest.id)) + .map(|p| ActionRow { + key: format!("widget:{}", p.manifest.id), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }) + .collect() +} + +/// This file's components and styles, filtered by the query. +fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { + let ql = q.to_lowercase(); + let Ok(doc) = app.session.doc() else { return Vec::new() }; + let components: Vec = figcraft_engine::library::local_components(doc) + .iter() + .filter_map(|id| doc.node(*id)) + .filter(|n| matches(&ql, &n.name, "")) + .map(|n| ActionRow { + key: format!("component:{}", n.id.0), + label: n.name.clone(), + hint: tr("Component").to_string(), + tile: None, + }) + .collect(); + let styles: Vec = figcraft_engine::library::local_styles(doc) + .filter(|s| matches(&ql, &s.name, "")) + .map(|s| ActionRow { + key: format!("style:{}", s.id), + label: s.name.clone(), + hint: tr("Style").to_string(), + tile: None, + }) + .collect(); + let mut out = Vec::new(); + if !components.is_empty() { + out.push((tr("Components").to_string(), components)); + } + if !styles.is_empty() { + out.push((tr("Styles").to_string(), styles)); + } + out +} + +/// A recent action back as a row (gone commands, plugins or assets drop out). +pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option { + let ql = q.to_lowercase(); + if let Some(id) = key.strip_prefix("command:") { + let spec = figcraft_engine::find_command(id); + let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); + let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); + if !available(id) || !matches(&ql, english, id) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: i18n::command(id, english).into_owned(), + hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), + tile: None, + }); + } + if let Some(rest) = key.strip_prefix("plugin:") { + let (pid, cid) = rest.split_once('/')?; + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid)?; + let c = p.manifest.commands.iter().find(|c| c.id == cid)?; + let label = if p.manifest.commands.len() == 1 { p.manifest.name.clone() } else { format!("{} — {}", p.manifest.name, c.label) }; + if !matches(&ql, &label, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label, + hint: tr("Plugin").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + if let Some(pid) = key.strip_prefix("widget:") { + let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; + if !matches(&ql, &p.manifest.name, pid) { + return None; + } + return Some(ActionRow { + key: key.to_string(), + label: p.manifest.name.clone(), + hint: tr("Widget").to_string(), + tile: p.manifest.name.chars().next(), + }); + } + None +} diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs new file mode 100644 index 00000000..e496cb77 --- /dev/null +++ b/crates/ui-egui/src/palette_tests.rs @@ -0,0 +1,60 @@ +//! The Actions palette, driven headless: invoking records recents (newest first, capped), +//! sections group commands by menu, the query filters, and gone entries drop out of Recents. + +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; + +#[test] +fn invoke_records_recents_newest_first_and_capped() { + let mut u = Ui::new(Session::new()); + for id in [ + "view.zoomIn", + "view.zoomOut", + "edit.copyAsText", + "style.list", + "edit.findReplace", + "edit.copyAsCss", + "view.zoomIn", + ] { + crate::menus::invoke(&mut u.app, id); + } + let r = &u.app.session.prefs.recent_actions; + assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); + assert_eq!(r[0], "command:view.zoomIn", "a re-run moves to the front: {r:?}"); + assert_eq!(r.iter().filter(|k| k.as_str() == "command:view.zoomIn").count(), 1, "no duplicates: {r:?}"); + assert!(!r.contains(&"command:view.zoomOut".to_string()), "the oldest drops out: {r:?}"); +} + +#[test] +fn sections_group_by_menu_in_catalog_order() { + let groups = crate::menus::command_sections(""); + assert!(!groups.is_empty(), "the catalog has commands"); + assert_eq!(groups[0].0, "File", "catalog order starts at File"); + for (group, rows) in &groups { + assert!(!rows.is_empty(), "{group} has no rows"); + for row in rows { + assert!(row.key.starts_with("command:"), "unexpected key {}", row.key); + } + } + let names: Vec<&str> = groups.iter().map(|(g, _)| g.as_str()).collect(); + assert!(names.contains(&"File") && names.contains(&"Edit"), "menus group: {names:?}"); + assert!(!names.iter().any(|g| g.starts_with("Plugins")), "plugins have their own tab"); +} + +#[test] +fn query_filters_to_matches() { + let groups = crate::menus::command_sections("save"); + let labels: Vec<&str> = groups.iter().flat_map(|(_, rows)| rows.iter().map(|r| r.label.as_str())).collect(); + assert!(!labels.is_empty(), "something matches 'save'"); + assert!(labels.iter().all(|l| l.to_lowercase().contains("save")), "only matches: {labels:?}"); + assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty())); +} + +#[test] +fn gone_entries_drop_out_of_recents() { + let u = Ui::new(Session::new()); + assert!(crate::menus::recent_row(&u.app, "command:nope.nope", "").is_none(), "gone commands drop out"); + assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); + assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..99df5d6f 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,10 +171,12 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { + crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; + crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index a921cc29..0aef7555 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -72,9 +72,18 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { builder_toolbar(app, ui); return; } - let cell = 32.0; - let gap = 4.0; - let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0; + main_toolbar(app, ui, canvas, &t); +} + +/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar. +pub(crate) fn main_width() -> f32 { + let (cell, gap) = (32.0, 4.0); + GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 +} + +fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { + let (cell, gap) = (32.0, 4.0); + let width = main_width(); let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..4a7bf45b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | +| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From 3b5c5d563b18c2d329ed2f4e92b8c3de87071d72 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 23:27:45 +0300 Subject: [PATCH 2/4] Palette follows the bar: toggle, dismiss, no invented icons The Actions button toggles (re-click and Cmd+K close), reads on in accent blue with a white icon while open, and a press anywhere outside closes the panel in one click. The opening press is spared once (the spare expires on press-free frames); presses on the button itself never dismiss, so the release toggles cleanly shut instead of reopening. Rows drop the per-family icons (labels + right-side hints only); plugins and widgets keep their letter tiles. The panel anchors to the canvas centre at the toolbar pill's exact width (toolbar::main_width), at a fixed size, with inset hover pills and larger section titles. Tests: one-click dismiss pinned (palette_tests). --- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++++-- crates/ui-egui/src/menus.rs | 22 +++++++++--- crates/ui-egui/src/palette_tests.rs | 53 ++++++++++++++++++++++++++++- crates/ui-egui/src/toolbar.rs | 20 +++++++++-- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9dd8e2f4..6e55c8a4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -416,6 +416,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if let Some(h) = p.get("palette").and_then(Value::as_bool) { app.ui.palette = if h { Some(String::new()) } else { None }; app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46830316..34f6ac0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,10 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette ignores one outside click after opening (the click that opened it). + pub palette_armed: bool, + /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. + pub actions_rect: Option, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -538,6 +542,8 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_armed: false, + actions_rect: None, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -794,8 +800,13 @@ impl FigApp { "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; + if self.ui.palette.is_some() { + self.ui.palette = None; + } else { + self.ui.palette = Some(String::new()); + self.ui.palette_tab = menus::PaletteTab::All; + self.ui.palette_armed = true; + } } "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e079313..a95fe2fd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -651,6 +651,18 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ); let mut run: Option = None; let mut close = false; + // A press anywhere outside closes the panel. The opening press is spared once, and the + // spare expires the first frame without a press (so the next outside press always closes). + let pressed = ctx.input(|i| i.pointer.any_pressed()); + if pressed && !std::mem::replace(&mut app.ui.palette_armed, false) { + let on_toggle = app.ui.actions_rect.is_some_and(|b| ctx.input(|i| i.pointer.hover_pos().is_some_and(|p| b.contains(p)))); + let outside = !on_toggle && ctx.input(|i| i.pointer.hover_pos().map(|p| !area.contains(p)).unwrap_or(false)); + if outside { + close = true; + } + } else if !pressed { + app.ui.palette_armed = false; + } egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( ctx, |ui| { @@ -830,9 +842,9 @@ pub(crate) struct ActionRow { /// A section header with its rows. fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { - ui.add_space(6.0); - ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary); - ui.add_space(18.0); + ui.add_space(10.0); + ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 8.0), Align2::LEFT_TOP, name, regular(12.5), t.text_secondary); + ui.add_space(22.0); rows(ui); } @@ -841,8 +853,10 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); + // The hover pill stays inside the row: it never touches the panel's edges. + let pill = r.shrink2(vec2(4.0, 1.0)); if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); + ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; if let Some(letter) = row.tile { diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index e496cb77..06e9fdf5 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,7 +1,10 @@ //! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, and gone entries drop out of Recents. +//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used +//! plugin resolves back to a row, and a press outside the panel closes it. +use egui::Pos2; use figcraft_engine::Session; +use serde_json::json; use crate::layers_drag_tests::Ui; @@ -58,3 +61,51 @@ fn gone_entries_drop_out_of_recents() { assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out"); assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out"); } + +/// A used plugin (API 4, several commands) resolves back to a Recents row. +#[test] +fn used_plugin_resolves_to_a_recent_row() { + let mut u = Ui::new(Session::new()); + let plugin: figcraft_engine::plugins::InstalledPlugin = serde_json::from_value(json!({ + "manifest": { + "id": "dev.local.solar-icons", + "name": "Solar icons", + "main": "solar_icons.wasm", + "api": 4, + "commands": [ + {"id": "panel", "label": "Panel"}, + {"id": "grid", "label": "Grid"}, + ], + }, + })) + .unwrap(); + u.app.session.prefs.plugins.push(plugin); + crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") + .expect("a used plugin stays in Recents"); + assert_eq!(row.label, "Solar icons — Panel"); + assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); +} + +/// A press outside the panel closes it; the opening press is spared once. +#[test] +fn outside_press_closes_the_panel() { + let mut u = Ui::new(Session::new()); + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + // The opening press: spared, and disarmed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_some(), "the opening press is spared"); + // The next press outside: closed. + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "an outside press closes the panel"); + // One click is enough: the spare expires on frames without a press. + u.app.ui.palette = Some(String::new()); + u.app.ui.palette_armed = true; + u.frame(vec![]); + u.button(Pos2::new(720.0, 20.0), true); + u.button(Pos2::new(720.0, 20.0), false); + assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0aef7555..962b7497 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -57,6 +57,7 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect; + app.ui.actions_rect = None; // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = app.session.active_read_only() { view_only_toolbar(app, ui, why); @@ -138,13 +139,26 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { ] { let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip); - if resp.hovered() { + if id == "actions" { + app.ui.actions_rect = Some(r); + } + // The Actions button reads on while its panel is open; clicking it again closes it. + let actions_open = id == "actions" && app.ui.palette.is_some(); + if actions_open { + ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, t.icon); + icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - app.ui.palette = Some(String::new()); + if app.ui.palette.is_some() { + app.ui.palette = None; + } else { + app.ui.palette = Some(String::new()); + app.ui.palette_tab = crate::menus::PaletteTab::All; + app.ui.palette_armed = true; + } } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; From 45b154e40b2217af232fe7ce1c5c573a52077784 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 3/4] #707: drop the unrelated .gitignore line and the parity doc edit --- .gitignore | 2 -- docs/ui-parity.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 3709c74a..928d6b49 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,3 @@ plans/ /log/ .DS_Store *.profraw - -plugins/target/ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4a7bf45b..62001715 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -19,7 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | -| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) | +| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity | | Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content | From 3a8e6e2860c082ec32df56718caa9af73ffc75ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:20:08 +0700 Subject: [PATCH 4/4] #707: search every command, ranked; a highlight for Enter; recents from the palette only - A search covers every command again (the engine's and the UI's own), grouped by menu where one lists it and under Other otherwise, best matches first (prefix, then shortest), at most 60. Without a query the menus' items show as before. - Up and Down move a visible highlight (the pointer moves it too); Enter runs the highlighted row, not an invisible first one. - Only runs from the palette are kept as Recents; menus, shortcuts and context menus no longer write the preferences. Recents match the translated label. - The Plugins menu's items (run last, manage, import) show with the plugins. - The search keeps its hint, so every catalog keeps its translation. - The panel sits above the toolbar pill's own rect (toolbar::pill_rect); one set_palette/toggle_palette opens it from every entry point. - Clippy: needless borrows in the toolbar. --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 5 +- crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/control.rs | 4 +- crates/ui-egui/src/lib.rs | 13 +- crates/ui-egui/src/menus.rs | 376 ++++++++++++++-------------- crates/ui-egui/src/palette_tests.rs | 86 +++++-- crates/ui-egui/src/plugins_ui.rs | 2 - crates/ui-egui/src/toolbar.rs | 21 +- 19 files changed, 291 insertions(+), 230 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index e86da423..268ff527 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1133,6 +1133,7 @@ status = "draft — needs native review" "Scroll with parent" = "Posouvat s nadřazeným prvkem" "Search plugins" = "Hledat pluginy" "Search" = "Hledat" +"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje" "Search assets" = "Hledat zdroje" "Search comments" = "Hledat komentáře" "Search fonts" = "Hledat písma" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 862527e4..cd1e3482 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1096,6 +1096,7 @@ status = "draft — needs native review" "Scroll to layer" = "Zur Ebene scrollen" "Scroll with parent" = "Mit übergeordnetem Element scrollen" "Search" = "Suchen" +"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen" "Search assets" = "Assets suchen" "Search comments" = "Kommentare suchen" "Search fonts" = "Schriften suchen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 8894ece1..514b6975 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -913,6 +913,7 @@ name = "English" "Orange" = "Orange" "Order" = "Order" "Other apps may not open every .fig file exported here." = "Other apps may not open every .fig file exported here." +"Other" = "Other" "Others lose access and keep their copy; the file stays on the server for you" = "Others lose access and keep their copy; the file stays on the server for you" "Others type this under Share ▸ Join with a code" = "Others type this under Share ▸ Join with a code" "Outline stroke" = "Outline stroke" @@ -1152,7 +1153,7 @@ name = "English" "Scroll with parent" = "Scroll with parent" "Search plugins" = "Search plugins" "Search" = "Search" -"Search actions, assets and plugins" = "Search actions, assets and plugins" +"Search actions, layers and tools" = "Search actions, layers and tools" "Search assets" = "Search assets" "Search comments" = "Search comments" "Search fonts" = "Search fonts" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d5e353cf..55e228dd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -914,6 +914,7 @@ status = "draft — needs native review" "Orange" = "Naranja" "Order" = "Orden" "Other apps may not open every .fig file exported here." = "Puede que otras aplicaciones no abran todos los archivos .fig exportados aquí." +"Other" = "Otros" "Others lose access and keep their copy; the file stays on the server for you" = "Los demás pierden el acceso y conservan su copia; el archivo se queda en el servidor para ti" "Others type this under Share ▸ Join with a code" = "Los demás lo escriben en Compartir ▸ Unirse con un código" "Outline stroke" = "Contornear trazo" @@ -971,7 +972,7 @@ status = "draft — needs native review" "Plugin" = "Plugin" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza." "Plugins" = "Plugins" -"Plugins & widgets" = "Plugins & widgets" +"Plugins & widgets" = "Plugins y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1153,8 +1154,8 @@ status = "draft — needs native review" "Scroll with parent" = "Desplazar con el padre" "Search plugins" = "Buscar plugins" "Search" = "Buscar" +"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" "Search assets" = "Buscar recursos" -"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins" "Search comments" = "Buscar comentarios" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 2c1fbc45..7fafd3c9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "Défiler jusqu’au calque" "Scroll with parent" = "Défiler avec le parent" "Search" = "Rechercher" +"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils" "Search assets" = "Rechercher des ressources" "Search comments" = "Rechercher des commentaires" "Search fonts" = "Rechercher des polices" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 53825747..5de7634d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1137,6 +1137,7 @@ status = "draft — needs native review" "Scroll with parent" = "Gulir bersama induk" "Search plugins" = "Cari plugin" "Search" = "Cari" +"Search actions, layers and tools" = "Cari tindakan, layer, dan alat" "Search assets" = "Cari aset" "Search comments" = "Cari komentar" "Search fonts" = "Cari font" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 670f84fc..4d4d6d34 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1140,6 +1140,7 @@ status = "draft — needs native review" "Scroll to layer" = "レイヤーまでスクロール" "Scroll with parent" = "親と一緒にスクロール" "Search" = "検索" +"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索" "Search assets" = "アセットを検索" "Search comments" = "コメントを検索" "Search fonts" = "フォントを検索" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 1f7bed0c..ec732905 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1090,6 +1090,7 @@ status = "draft — needs native review" "Scroll to layer" = "레이어로 스크롤" "Scroll with parent" = "부모와 함께 스크롤" "Search" = "검색" +"Search actions, layers and tools" = "액션, 레이어, 도구 검색" "Search assets" = "에셋 검색" "Search comments" = "댓글 검색" "Search fonts" = "글꼴 검색" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 2fb61d12..c2189de8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1089,6 +1089,7 @@ status = "draft — needs native review" "Scroll to layer" = "Rolar até a camada" "Scroll with parent" = "Rolar com o pai" "Search" = "Pesquisar" +"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas" "Search assets" = "Pesquisar recursos" "Search comments" = "Pesquisar comentários" "Search fonts" = "Pesquisar fontes" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 78a8afd6..3ab40757 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1134,6 +1134,7 @@ name = "Русский" "Scroll with parent" = "Прокручивать с родителем" "Search plugins" = "Поиск плагинов" "Search" = "Поиск" +"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов" "Search assets" = "Поиск ресурсов" "Search comments" = "Поиск комментариев" "Search fonts" = "Поиск шрифтов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index a54c02e7..f7b2999d 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1135,6 +1135,7 @@ name = "Svenska" "Scroll with parent" = "Rulla med överordnat element" "Search plugins" = "Sök plugin-program" "Search" = "Sök" +"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg" "Search assets" = "Sök tillgångar" "Search comments" = "Sök i kommentarer" "Search fonts" = "Sök typsnitt" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 43603287..a2be6b99 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1137,6 +1137,7 @@ name = "Türkçe" "Scroll with parent" = "Üst öğeyle birlikte kaydır" "Search plugins" = "Eklentilerde ara" "Search" = "Ara" +"Search actions, layers and tools" = "Eylem, katman ve araç ara" "Search assets" = "Varlıklarda ara" "Search comments" = "Yorumlarda ara" "Search fonts" = "Yazı tiplerinde ara" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a42360e4..af4cf653 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1091,6 +1091,7 @@ status = "draft — needs native review" "Scroll to layer" = "滚动到图层" "Scroll with parent" = "随父级滚动" "Search" = "搜索" +"Search actions, layers and tools" = "搜索操作、图层和工具" "Search assets" = "搜索资源" "Search comments" = "搜索评论" "Search fonts" = "搜索字体" diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 6e55c8a4..9a97081f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,9 +414,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs); } if let Some(h) = p.get("palette").and_then(Value::as_bool) { - app.ui.palette = if h { Some(String::new()) } else { None }; - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; + crate::menus::set_palette(&mut app.ui, h); } // true / false, or a top-level menu to open it at ("Plugins"). match p.get("mainMenu") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 34f6ac0d..4a047c89 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,8 @@ pub struct UiState { pub palette: Option, /// The Actions palette's tab. pub palette_tab: menus::PaletteTab, + /// The palette row Up and Down are on (Enter runs it). + pub palette_hi: usize, /// The palette ignores one outside click after opening (the click that opened it). pub palette_armed: bool, /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. @@ -542,6 +544,7 @@ impl Default for UiState { main_menu: false, palette: None, palette_tab: menus::PaletteTab::All, + palette_hi: 0, palette_armed: false, actions_rect: None, variables: None, @@ -799,15 +802,7 @@ impl FigApp { } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => { - if self.ui.palette.is_some() { - self.ui.palette = None; - } else { - self.ui.palette = Some(String::new()); - self.ui.palette_tab = menus::PaletteTab::All; - self.ui.palette_armed = true; - } - } + "palette.actions" => menus::toggle_palette(&mut self.ui), "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 73bee5bc..e54f0090 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,8 +7,8 @@ use serde_json::json; use crate::FigApp; use crate::a11y; -use crate::icons::{self, Icon}; use crate::i18n::{self, tr, tr_noop, trf}; +use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -82,7 +82,7 @@ pub enum PaletteTab { Plugins, } -/// Remember a ran action for the palette's Recents (newest first, capped). +/// Remember an action run from the palette for its Recents (newest first, capped). pub fn record_recent(app: &mut FigApp, key: &str) { let r = &mut app.session.prefs.recent_actions; if r.first().is_some_and(|f| f == key) { @@ -94,7 +94,6 @@ pub fn record_recent(app: &mut FigApp, key: &str) { } pub fn invoke(app: &mut FigApp, id: &str) { - record_recent(app, &format!("command:{id}")); match id { "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), @@ -434,7 +433,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { t.menu_dim, ); if resp.clicked() { - app.ui.palette = Some(String::new()); + set_palette(&mut app.ui, true); close = true; } ui.add_space(6.0); @@ -657,19 +656,31 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) /// The Actions palette's content height (the frame adds its own 8 px margins). const PALETTE_H: f32 = 450.0; -/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs, -/// recents, grouped sections). Rows run on click; Enter runs the first visible row. +/// Open the Actions palette afresh (empty search, All tab, first row highlighted), or close it. +pub fn set_palette(ui: &mut crate::UiState, open: bool) { + ui.palette = open.then(String::new); + ui.palette_tab = PaletteTab::All; + ui.palette_hi = 0; + ui.palette_armed = open; +} + +/// Open the Actions palette afresh, or close it when it's open (⌘K, the toolbar's Actions button). +pub fn toggle_palette(ui: &mut crate::UiState) { + let open = ui.palette.is_none(); + set_palette(ui, open); +} + +/// The Actions palette: search over commands, assets and plugins, with tabs, recents and grouped +/// sections, floating above the toolbar. Rows run on click; Up and Down move the highlight and +/// Enter runs the highlighted row. fn palette(app: &mut FigApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let t = Tokens::get(ctx); // Same centre and width as the toolbar pill (it centres on the canvas, not the window), // floating 8 px above it. Fixed size: switching tabs never resizes the panel. let bar_w = crate::toolbar::main_width(); - let canvas = app.canvas.rect; - let area = Rect::from_min_size( - pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)), - vec2(bar_w, PALETTE_H + 16.0), - ); + let bar = crate::toolbar::pill_rect(app.canvas.rect, bar_w); + let area = Rect::from_min_max(pos2(bar.min.x, bar.min.y - 8.0 - (PALETTE_H + 16.0)), pos2(bar.max.x, bar.min.y - 8.0)); let mut run: Option = None; let mut close = false; // A press anywhere outside closes the panel. The opening press is spared once, and the @@ -684,135 +695,93 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { } else if !pressed { app.ui.palette_armed = false; } - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show( - ctx, - |ui| { - egui::Frame::NONE - .fill(t.panel) - .corner_radius(CornerRadius::same(13)) - .stroke(Stroke::new(1.0, t.border)) - .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) - .inner_margin(egui::Margin::same(8)) - .show(ui, |ui| { - // Same outer width as the toolbar pill, and a fixed height: switching tabs - // never resizes the panel. - let inner = crate::toolbar::main_width() - 16.0; - ui.set_width(inner); - ui.set_height(PALETTE_H); - let r = ui.add( - egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, assets and plugins")) - .desired_width(inner - 16.0) - .font(regular(14.0)) - .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), - ); - r.request_focus(); - crate::widgets::select_all_on_focus(ui, &r, &q); - ui.add_space(4.0); - // Tabs: All, Assets, Plugins & widgets. - let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 0.0); - for (tab, label) in tabs { - let active = app.ui.palette_tab == tab; - // The frame stays on for every tab (transparent when idle) so the - // active pill never moves its neighbours. - let resp = ui.add( - egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) - .frame(true) - .fill(if active { t.hover } else { Color32::TRANSPARENT }) - .corner_radius(CornerRadius::same(6)), - ); - if resp.clicked() { - app.ui.palette_tab = tab; - } - } - }); - ui.add_space(4.0); - let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); - let mut first: Option = None; - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - let tab = app.ui.palette_tab; - if tab == PaletteTab::All { - let recents: Vec = app - .session - .prefs - .recent_actions - .clone() - .iter() - .filter_map(|key| recent_row(app, key, &q)) - .collect(); - if !recents.is_empty() { - section(ui, &t, tr("Recents"), |ui| { - for row in &recents { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, row) { - run = Some(row.key.clone()); - } - } - }); - } - } - if tab != PaletteTab::Plugins { - for (group, rows) in command_sections(&q) { - if tab == PaletteTab::Assets { - continue; - } - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab != PaletteTab::Assets { - let plugins = plugin_rows(app, &q); - let widgets = widget_rows(app, &q); - if !plugins.is_empty() { - section(ui, &t, tr("Plugins"), |ui| { - for row in plugins { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - if !widgets.is_empty() { - section(ui, &t, tr("Widgets"), |ui| { - for row in widgets { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - if tab == PaletteTab::Assets { - for (group, rows) in asset_sections(app, &q) { - section(ui, &t, &group, |ui| { - for row in rows { - first.get_or_insert_with(|| row.key.clone()); - if draw_row(ui, &t, &row) { - run = Some(row.key); - } - } - }); - } - } - }); - if enter { - if let Some(key) = first { - run = Some(key); + // Taken before the search field sees them (it would move its cursor). + let step = ctx.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); + egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .corner_radius(CornerRadius::same(13)) + .stroke(Stroke::new(1.0, t.border)) + .shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(8)) + .show(ui, |ui| { + // Same outer width as the toolbar pill, and a fixed height: switching tabs + // never resizes the panel. + let inner = bar_w - 16.0; + ui.set_width(inner); + ui.set_height(PALETTE_H); + let r = ui.add( + egui::TextEdit::singleline(&mut q) + .hint_text(tr("Search actions, layers and tools")) + .desired_width(inner - 16.0) + .font(regular(14.0)) + .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), + ); + r.request_focus(); + crate::widgets::select_all_on_focus(ui, &r, &q); + // A new search starts again at the first row. + if r.changed() { + app.ui.palette_hi = 0; + } + ui.add_space(4.0); + // Tabs: All, Assets, Plugins & widgets. + let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))]; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 0.0); + for (tab, label) in tabs { + let active = app.ui.palette_tab == tab; + // The frame stays on for every tab (transparent when idle) so the + // active pill never moves its neighbours. + let resp = ui.add( + egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary })) + .frame(true) + .fill(if active { t.hover } else { Color32::TRANSPARENT }) + .corner_radius(CornerRadius::same(6)), + ); + if resp.clicked() { + app.ui.palette_tab = tab; + app.ui.palette_hi = 0; } } }); - }, - ); + ui.add_space(4.0); + let sections = palette_sections(app, &q, app.ui.palette_tab); + let count = sections.iter().map(|(_, rows)| rows.len()).sum::(); + // At either end it stays where it was. + let hi = (app.ui.palette_hi as i64 + step).clamp(0, count.saturating_sub(1) as i64) as usize; + app.ui.palette_hi = hi; + // The pointer moving over a row moves the highlight there. + let pointer_moved = ui.input(|i| i.pointer.delta() != egui::Vec2::ZERO); + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + let mut i = 0; + for (name, rows) in §ions { + section(ui, &t, name, |ui| { + for row in rows { + let resp = draw_row(ui, &t, row, i == hi); + if resp.clicked() { + run = Some(row.key.clone()); + } + if pointer_moved && resp.hovered() && i != hi { + app.ui.palette_hi = i; + // Drawn there on the next frame, even if the pointer stops now. + ui.ctx().request_repaint(); + } + if step != 0 && i == hi { + resp.scroll_to_me(None); + } + i += 1; + } + }); + } + }); + if run.is_none() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + run = sections.iter().flat_map(|(_, rows)| rows).nth(hi).map(|row| row.key.clone()); + } + }); + }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; } @@ -825,10 +794,32 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { let _ = StrokeKind::Inside; } +/// The rows a palette tab shows for the query, by section: All has Recents, the commands by menu, +/// then plugins and widgets; Assets this file's components and styles; Plugins & widgets those. +pub(crate) fn palette_sections(app: &FigApp, q: &str, tab: PaletteTab) -> Vec<(String, Vec)> { + let mut out = match tab { + PaletteTab::All => { + let recents = app.session.prefs.recent_actions.iter().filter_map(|key| recent_row(app, key, q)).collect(); + let mut out = vec![(tr("Recents").to_string(), recents)]; + out.extend(command_sections(q)); + out + } + PaletteTab::Assets => asset_sections(app, q), + PaletteTab::Plugins => Vec::new(), + }; + if tab != PaletteTab::Assets { + out.push((tr("Plugins").to_string(), plugin_rows(app, q))); + out.push((tr("Widgets").to_string(), widget_rows(app, q))); + } + out.retain(|(_, rows)| !rows.is_empty()); + out +} + /// Run a palette row: `command:`, `plugin:/`, `widget:`, /// `component:` or `style:`. -fn run_action(app: &mut FigApp, key: &str) { +pub(crate) fn run_action(app: &mut FigApp, key: &str) { if let Some(id) = key.strip_prefix("command:") { + record_recent(app, key); invoke(app, id); } else if let Some(rest) = key.strip_prefix("plugin:") { if let Some((plugin, command)) = rest.split_once('/') { @@ -869,14 +860,14 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) { rows(ui); } -/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint. -/// True when clicked. -fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { +/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint, +/// `highlighted` when Enter runs it. +fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui::Response { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click()); - // The hover pill stays inside the row: it never touches the panel's edges. + // The highlight pill stays inside the row: it never touches the panel's edges. let pill = r.shrink2(vec2(4.0, 1.0)); - if resp.hovered() { + if highlighted { ui.painter().rect_filled(pill, CornerRadius::same(6), t.hover); } let mut label_x = r.min.x + 10.0; @@ -888,7 +879,7 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool { } ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text); ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary); - resp.clicked() + resp } /// Whether `label` (or `extra`) matches the query, in any case. @@ -896,28 +887,55 @@ fn matches(q: &str, label: &str, extra: &str) -> bool { q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q) } -/// Commands grouped by top-level menu, in catalog order, filtered by the query. +/// A command's row: its label, and its shortcut as the hint. +fn command_row(id: &str, label: String) -> ActionRow { + ActionRow { key: format!("command:{id}"), label, hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), tile: None } +} + +/// Most rows a search shows (the best matches). +const MAX_HITS: usize = 60; + +/// Commands grouped by top-level menu, filtered by the query. Without one, the menus' items in +/// catalog order; a search covers every command (the engine's and the UI's own, "Other" for those +/// no menu lists), best matches first (labels starting with the query, then the shortest), each +/// group placed by its best row. Labels match in the UI language and in English. The Plugins +/// menu's items come with the plugins ([`plugin_rows`]). pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> { - let ql = q.to_lowercase(); - let mut groups: Vec<(String, Vec)> = Vec::new(); - let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); - for m in figcraft_engine::catalog::MENU.iter() { - if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) { + let ql = q.trim().to_lowercase(); + let mut menu_of = std::collections::HashMap::new(); + for m in MENU.iter().filter(|m| m.label != SEP) { + menu_of.entry(m.id).or_insert(m); + } + let menu_items = MENU.iter().filter(|m| m.label != SEP).map(|m| (m.id, m.label)); + let engine = figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)); + let candidates: Box> = if ql.is_empty() { Box::new(menu_items) } else { Box::new(menu_items.chain(engine)) }; + let mut seen = std::collections::HashSet::new(); + // By top-level menu; `None`: no menu lists it. + let mut hits: Vec<(Option<&str>, ActionRow)> = Vec::new(); + for (id, english) in candidates { + if !available(id) || !seen.insert(id) { continue; } - let english = m.label; - let label = i18n::menu_item(m.id, english).into_owned(); - if !matches(&ql, &label, &format!("{english} {}", m.id)) { + let menu = menu_of.get(id); + let group = menu.map(|m| m.menu.split('/').next().unwrap_or(m.menu)); + if group == Some("Plugins") { continue; } - let group = m.menu.split('/').next().unwrap_or(&m.menu); - let group = tr(group).to_string(); - let row = ActionRow { - key: format!("command:{}", m.id), - label, - hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(), - tile: None, + let label = match menu { + Some(m) => i18n::menu_item(id, m.label), + None => i18n::command(id, english), }; + if matches(&ql, &label, &format!("{english} {id}")) { + hits.push((group, command_row(id, label.into_owned()))); + } + } + if !ql.is_empty() { + hits.sort_by_cached_key(|(_, row)| (!row.label.to_lowercase().starts_with(&ql), row.label.chars().count())); + hits.truncate(MAX_HITS); + } + let mut groups: Vec<(String, Vec)> = Vec::new(); + for (group, row) in hits { + let group = group.map_or_else(|| tr("Other"), tr).to_string(); match groups.iter_mut().find(|(g, _)| *g == group) { Some((_, rows)) => rows.push(row), None => groups.push((group, vec![row])), @@ -945,6 +963,13 @@ fn plugin_rows(app: &FigApp, q: &str) -> Vec { }); } } + // The Plugins menu's own items (run last, manage, import). + for m in MENU.iter().filter(|m| m.label != SEP && m.menu.split('/').next() == Some("Plugins") && available(m.id)) { + let label = i18n::menu_item(m.id, m.label); + if matches(&ql, &label, &format!("{} {}", m.label, m.id)) { + rows.push(command_row(m.id, label.into_owned())); + } + } rows } @@ -974,21 +999,11 @@ fn asset_sections(app: &FigApp, q: &str) -> Vec<(String, Vec)> { .iter() .filter_map(|id| doc.node(*id)) .filter(|n| matches(&ql, &n.name, "")) - .map(|n| ActionRow { - key: format!("component:{}", n.id.0), - label: n.name.clone(), - hint: tr("Component").to_string(), - tile: None, - }) + .map(|n| ActionRow { key: format!("component:{}", n.id.0), label: n.name.clone(), hint: tr("Component").to_string(), tile: None }) .collect(); let styles: Vec = figcraft_engine::library::local_styles(doc) .filter(|s| matches(&ql, &s.name, "")) - .map(|s| ActionRow { - key: format!("style:{}", s.id), - label: s.name.clone(), - hint: tr("Style").to_string(), - tile: None, - }) + .map(|s| ActionRow { key: format!("style:{}", s.id), label: s.name.clone(), hint: tr("Style").to_string(), tile: None }) .collect(); let mut out = Vec::new(); if !components.is_empty() { @@ -1007,15 +1022,11 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option let spec = figcraft_engine::find_command(id); let menu_label = figcraft_engine::catalog::MENU.iter().find(|m| m.id == id); let english = menu_label.map(|m| m.label).or_else(|| spec.map(|s| s.label)).unwrap_or(id); - if !available(id) || !matches(&ql, english, id) { + let label = i18n::command(id, english); + if !available(id) || !matches(&ql, &label, &format!("{english} {id}")) { return None; } - return Some(ActionRow { - key: key.to_string(), - label: i18n::command(id, english).into_owned(), - hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(), - tile: None, - }); + return Some(command_row(id, label.into_owned())); } if let Some(rest) = key.strip_prefix("plugin:") { let (pid, cid) = rest.split_once('/')?; @@ -1025,12 +1036,7 @@ pub(crate) fn recent_row(app: &FigApp, key: &str, q: &str) -> Option if !matches(&ql, &label, pid) { return None; } - return Some(ActionRow { - key: key.to_string(), - label, - hint: tr("Plugin").to_string(), - tile: p.manifest.name.chars().next(), - }); + return Some(ActionRow { key: key.to_string(), label, hint: tr("Plugin").to_string(), tile: p.manifest.name.chars().next() }); } if let Some(pid) = key.strip_prefix("widget:") { let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?; diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index 06e9fdf5..cfe9c1cd 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -1,26 +1,22 @@ -//! The Actions palette, driven headless: invoking records recents (newest first, capped), -//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used -//! plugin resolves back to a row, and a press outside the panel closes it. +//! The Actions palette, driven headless: runs from it record recents (newest first, capped), +//! sections group commands by menu, a search covers every command with the best matches first, +//! Up/Down move the highlight and Enter runs it, gone entries drop out of Recents, a used plugin +//! resolves back to a row, and a press outside the panel closes it. -use egui::Pos2; +use egui::{Event, Key, Modifiers, Pos2}; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; #[test] -fn invoke_records_recents_newest_first_and_capped() { +fn palette_runs_record_recents_newest_first_and_capped() { let mut u = Ui::new(Session::new()); - for id in [ - "view.zoomIn", - "view.zoomOut", - "edit.copyAsText", - "style.list", - "edit.findReplace", - "edit.copyAsCss", - "view.zoomIn", - ] { - crate::menus::invoke(&mut u.app, id); + // Menus, shortcuts and context menus don't touch the palette's Recents. + crate::menus::invoke(&mut u.app, "view.zoomIn"); + assert!(u.app.session.prefs.recent_actions.is_empty(), "only the palette records recents"); + for id in ["view.zoomIn", "view.zoomOut", "edit.copyAsText", "style.list", "edit.findReplace", "edit.copyAsCss", "view.zoomIn"] { + crate::menus::run_action(&mut u.app, &format!("command:{id}")); } let r = &u.app.session.prefs.recent_actions; assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}"); @@ -81,8 +77,7 @@ fn used_plugin_resolves_to_a_recent_row() { .unwrap(); u.app.session.prefs.plugins.push(plugin); crate::menus::record_recent(&mut u.app, "plugin:dev.local.solar-icons/panel"); - let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "") - .expect("a used plugin stays in Recents"); + let row = crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "").expect("a used plugin stays in Recents"); assert_eq!(row.label, "Solar icons — Panel"); assert!(crate::menus::recent_row(&u.app, "plugin:dev.local.solar-icons/panel", "zzz").is_none(), "the query filters recents"); } @@ -109,3 +104,60 @@ fn outside_press_closes_the_panel() { u.button(Pos2::new(720.0, 20.0), false); assert!(u.app.ui.palette.is_none(), "a single outside press closes the panel"); } + +fn rows(groups: &[(String, Vec)]) -> Vec<&crate::menus::ActionRow> { + groups.iter().flat_map(|(_, rows)| rows).collect() +} + +/// A search reaches commands no menu lists (in an "Other" group), not just menu items. +#[test] +fn search_covers_every_command() { + let menu = figcraft_engine::catalog::MENU; + let spec = figcraft_engine::all_commands().iter().find(|c| !menu.iter().any(|m| m.id == c.id)).expect("a command no menu lists"); + let groups = crate::menus::command_sections(spec.id); + let key = format!("command:{}", spec.id); + let group = groups.iter().find(|(_, rows)| rows.iter().any(|r| r.key == key)).map(|(g, _)| g.as_str()); + assert_eq!(group, Some("Other"), "{} is found: {:?}", spec.id, groups.iter().map(|(g, _)| g).collect::>()); + // Without a query the palette lists the menus' items only. + assert!(rows(&crate::menus::command_sections("")).iter().all(|r| r.key != key)); +} + +/// Labels starting with the query come first, the shortest first; the rest are capped. +#[test] +fn search_ranks_prefix_matches_first() { + let groups = crate::menus::command_sections("zoom"); + let hits = rows(&groups); + let first = hits.first().expect("something matches 'zoom'"); + assert!(first.label.to_lowercase().starts_with("zoom"), "a prefix match leads: {}", first.label); + let shortest = hits.iter().filter(|r| r.label.to_lowercase().starts_with("zoom")).map(|r| r.label.chars().count()).min(); + assert_eq!(Some(first.label.chars().count()), shortest, "the shortest prefix match leads"); + assert!(rows(&crate::menus::command_sections("e")).len() <= 60, "a broad search is capped"); +} + +fn key(u: &mut Ui, k: Key) { + u.frame(vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// Up and Down move the highlight (it stops at the ends); Enter runs the highlighted row. +#[test] +fn arrows_move_the_highlight_and_enter_runs_it() { + let mut u = Ui::new(Session::new()); + crate::menus::set_palette(&mut u.app.ui, true); + u.frame(vec![]); + u.frame(vec![Event::Text("zoom".into())]); + assert_eq!(u.app.ui.palette.as_deref(), Some("zoom")); + assert_eq!(u.app.ui.palette_hi, 0, "a search starts at the first row"); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 0, "it stops at the top"); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowDown); + key(&mut u, Key::ArrowUp); + assert_eq!(u.app.ui.palette_hi, 1); + let want = rows(&crate::menus::palette_sections(&u.app, "zoom", crate::menus::PaletteTab::All)).get(1).map(|r| r.key.clone()).expect("two rows"); + key(&mut u, Key::Enter); + assert!(u.app.ui.palette.is_none(), "Enter runs a row and closes the palette"); + assert_eq!(u.app.session.prefs.recent_actions.first(), Some(&want), "the highlighted row ran"); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 99df5d6f..481b446c 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -171,12 +171,10 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool { .flatten(); crate::menus::separator(ui); if let Some(plugin) = widget { - crate::menus::record_recent(app, &format!("widget:{plugin}")); app.run("widget.insert", json!({"plugin": plugin})); return true; } let Some((id, command)) = chosen else { return false }; - crate::menus::record_recent(app, &format!("plugin:{id}/{command}")); start(app, &id, &command); true } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 962b7497..8839b3a9 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -85,7 +85,7 @@ pub(crate) fn main_width() -> f32 { fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); - let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); + let rect = pill(ui, &mut app.ui.nav, canvas, width, t); let current = app.session.tool.tool; let mut x = rect.min.x + 8.0; let cy = rect.center().y; @@ -94,7 +94,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) }; let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move); let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell)); - let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); + let resp = tool_cell(ui, t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut()))); if in_group { a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id); } @@ -152,13 +152,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { - if app.ui.palette.is_some() { - app.ui.palette = None; - } else { - app.ui.palette = Some(String::new()); - app.ui.palette_tab = crate::menus::PaletteTab::All; - app.ui.palette_armed = true; - } + crate::menus::toggle_palette(&mut app.ui); } else { app.ui.left_tab = crate::LeftTab::Assets; app.ui.left_collapsed = false; @@ -166,7 +160,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } x += cell + 4.0; } - divider(ui, &t, x, cy); + divider(ui, t, x, cy); x += 8.0; // Mode switcher: Draw · Design · Prototype · Inspect (code). let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))]; @@ -348,10 +342,15 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) { } } +/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge. +pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect { + Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)) +} + /// The floating pill (shadow, panel, border) `width` wide at the bottom centre of the canvas: the /// toolbar region keyboard navigation moves through. fn pill(ui: &Ui, nav: &mut Nav, canvas: Rect, width: f32, t: &Tokens) -> Rect { - let rect = Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0)); + let rect = pill_rect(canvas, width); // The pill takes the press and the drag over it: its buttons sense clicks only, and egui gives // a drag the canvas under them would sense to the canvas, so the pen placed an anchor there. // Not focusable: the keyboard walks the buttons, not the pill.