Merge fix/707

This commit is contained in:
bflatastic
2026-10-17 15:20:40 +07:00
8 changed files with 632 additions and 75 deletions
+9
View File
@@ -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:<id>`, `plugin:<plugin>/<command>` or
/// `widget:<plugin>`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recent_actions: Vec<String>,
}
/// 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(),
}
}
}
+4
View File
@@ -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"
+4
View File
@@ -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"
+1 -1
View File
@@ -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") {
+15 -1
View File
@@ -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<String>,
/// 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<egui::Rect>,
/// The Variables editor, when open.
pub variables: Option<variables_panel::Editor>,
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" => {
+404 -63
View File
@@ -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<u8>, 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<String> = 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>, &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::<usize>();
// 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 &sections {
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<ActionRow>)> {
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:<id>`, `plugin:<plugin>/<command>`, `widget:<plugin>`,
/// `component:<node>` or `style:<id>`.
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::<u64>() {
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<char>,
}
/// 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<ActionRow>)> {
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<dyn Iterator<Item = (&str, &str)>> = 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<ActionRow>)> = 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<ActionRow> {
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<ActionRow> {
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<ActionRow>)> {
let ql = q.to_lowercase();
let Ok(doc) = app.session.doc() else { return Vec::new() };
let components: Vec<ActionRow> = 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<ActionRow> = 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<ActionRow> {
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
}
+163
View File
@@ -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<crate::menus::ActionRow>)]) -> 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::<Vec<_>>());
// 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");
}
+32 -10
View File
@@ -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.