diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 987fe103..5acc306d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -654,8 +654,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 { @@ -683,6 +691,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/en.toml b/crates/ui-egui/locales/en.toml index c9b6df62..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" @@ -967,8 +968,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" @@ -1470,6 +1473,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 ff3e5a79..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" @@ -968,8 +969,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 y widgets" "Plus darker" = "Más oscuro" "Plus lighter" = "Más claro" "Point" = "Punto" @@ -1471,6 +1474,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/src/control.rs b/crates/ui-egui/src/control.rs index 7a196cf2..9a97081f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -414,7 +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 }; + 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 b99a04d1..abe3e8ff 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; @@ -408,6 +410,14 @@ 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 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. + pub actions_rect: Option, /// The Variables editor, when open. pub variables: Option, pub about: bool, @@ -546,6 +556,10 @@ impl Default for UiState { rename_page: None, main_menu: false, palette: None, + palette_tab: menus::PaletteTab::All, + palette_hi: 0, + palette_armed: false, + actions_rect: None, variables: None, about: false, about_tab: dialogs::AboutTab::About, @@ -805,7 +819,7 @@ 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" => 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 62c5f0b0..67b615c6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -9,7 +9,7 @@ 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::theme::{Tokens, regular}; use crate::toolbar::pretty_shortcut; /// Commands the UI layer implements itself (dialogs and platform actions). @@ -74,6 +74,26 @@ 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 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) { + 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) { match id { "file.open" => app.open_dialog(), @@ -414,7 +434,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); @@ -630,81 +650,402 @@ 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; + +/// 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); - 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 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; - egui::Area::new(Id::new("palette")).fixed_pos(pos2(screen.center().x - 260.0, screen.min.y + 120.0)).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| { - ui.set_width(520.0); - let r = ui.add( - egui::TextEdit::singleline(&mut q) - .hint_text(tr("Search actions, layers and tools")) - .desired_width(500.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(""))); + // 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; + } + // 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; } } - 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 }, - ); - if resp.clicked() || (enter && i == 0) { - run = Some(id.to_string()); - } - } - }); }); - }, - ); + 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; } - 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; } + +/// 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:`. +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('/') { + 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(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); +} + +/// 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 highlight pill stays inside the row: it never touches the panel's edges. + let pill = r.shrink2(vec2(4.0, 1.0)); + if highlighted { + 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 { + 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 +} + +/// 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) +} + +/// 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.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 menu = menu_of.get(id); + let group = menu.map(|m| m.menu.split('/').next().unwrap_or(m.menu)); + if group == Some("Plugins") { + continue; + } + 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])), + } + } + 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(), + }); + } + } + // 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 +} + +/// 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); + let label = i18n::command(id, english); + if !available(id) || !matches(&ql, &label, &format!("{english} {id}")) { + return None; + } + return Some(command_row(id, label.into_owned())); + } + 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..cfe9c1cd --- /dev/null +++ b/crates/ui-egui/src/palette_tests.rs @@ -0,0 +1,163 @@ +//! 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::{Event, Key, Modifiers, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +#[test] +fn palette_runs_record_recents_newest_first_and_capped() { + let mut u = Ui::new(Session::new()); + // 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:?}"); + 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"); +} + +/// 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"); +} + +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/toolbar.rs b/crates/ui-egui/src/toolbar.rs index c2f947f8..6da9badb 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); @@ -72,10 +73,19 @@ 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; - let rect = pill(ui, &mut app.ui.nav, canvas, width, &t); + 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; let cy = rect.center().y; @@ -84,7 +94,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { 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); } @@ -129,20 +139,27 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { ] { 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()); + crate::menus::toggle_palette(&mut app.ui); } else { app.run("view.assets", json!({})); } } 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"))]; @@ -324,10 +341,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.