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.