mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -06:00
Actions palette rework: Figma-shaped panel over the toolbar
Tabs (All, Assets, Plugins & widgets), Recents kept in the preferences (newest first, capped at 5), commands grouped by top-level menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles. Rows carry right-side shortcut/kind hints; Enter runs the first visible row. The panel floats 8 px above the toolbar pill with its exact width and centre (toolbar::main_width, canvas-centred), at a fixed size so tab switches never resize it. Tabs keep their frames when idle so the active pill never moves its neighbours. Engine: Prefs.recent_actions (persisted, skipped when empty). UI: invoke/menu/plugin picks record recents; gone entries drop out. Locales: new keys in en + es, retired the old search hint everywhere. Docs: ui-parity row describes the new surface (palette_tests).
This commit is contained in:
@@ -8,3 +8,5 @@ plans/
|
||||
/log/
|
||||
.DS_Store
|
||||
*.profraw
|
||||
|
||||
plugins/target/
|
||||
|
||||
@@ -652,8 +652,16 @@ pub struct Prefs {
|
||||
pub home_sort: String,
|
||||
/// Which way: `"desc"` (newest first, Z to A) or `"asc"`.
|
||||
pub home_order: String,
|
||||
/// Recently run actions (the Actions palette's Recents), newest first, at most
|
||||
/// [`MAX_RECENT_ACTIONS`]. Entries are `command:<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 {
|
||||
@@ -681,6 +689,7 @@ impl Default for Prefs {
|
||||
home_views: BTreeMap::new(),
|
||||
home_sort: "modified".into(),
|
||||
home_order: "desc".into(),
|
||||
recent_actions: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1133,7 +1133,6 @@ status = "draft — needs native review"
|
||||
"Scroll with parent" = "Posouvat s nadřazeným prvkem"
|
||||
"Search plugins" = "Hledat pluginy"
|
||||
"Search" = "Hledat"
|
||||
"Search actions, layers and tools" = "Hledat akce, vrstvy a nástroje"
|
||||
"Search assets" = "Hledat zdroje"
|
||||
"Search comments" = "Hledat komentáře"
|
||||
"Search fonts" = "Hledat písma"
|
||||
|
||||
@@ -1096,7 +1096,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "Zur Ebene scrollen"
|
||||
"Scroll with parent" = "Mit übergeordnetem Element scrollen"
|
||||
"Search" = "Suchen"
|
||||
"Search actions, layers and tools" = "Aktionen, Ebenen und Werkzeuge suchen"
|
||||
"Search assets" = "Assets suchen"
|
||||
"Search comments" = "Kommentare suchen"
|
||||
"Search fonts" = "Schriften suchen"
|
||||
|
||||
@@ -965,8 +965,10 @@ name = "English"
|
||||
"Place a shape or frame (its tool chosen)" = "Place a shape or frame (its tool chosen)"
|
||||
"Place automatically" = "Place automatically"
|
||||
"Place image" = "Place image"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust."
|
||||
"Plugins" = "Plugins"
|
||||
"Plugins & widgets" = "Plugins & widgets"
|
||||
"Plus darker" = "Plus darker"
|
||||
"Plus lighter" = "Plus lighter"
|
||||
"Point" = "Point"
|
||||
@@ -1148,7 +1150,7 @@ name = "English"
|
||||
"Scroll with parent" = "Scroll with parent"
|
||||
"Search plugins" = "Search plugins"
|
||||
"Search" = "Search"
|
||||
"Search actions, layers and tools" = "Search actions, layers and tools"
|
||||
"Search actions, assets and plugins" = "Search actions, assets and plugins"
|
||||
"Search assets" = "Search assets"
|
||||
"Search comments" = "Search comments"
|
||||
"Search fonts" = "Search fonts"
|
||||
@@ -1468,6 +1470,7 @@ name = "English"
|
||||
"White" = "White"
|
||||
"Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files"
|
||||
"Why it changed" = "Why it changed"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Width"
|
||||
"Width point" = "Width point"
|
||||
|
||||
@@ -966,8 +966,10 @@ status = "draft — needs native review"
|
||||
"Place a shape or frame (its tool chosen)" = "Colocar forma o marco (herramienta elegida)"
|
||||
"Place automatically" = "Colocar automáticamente"
|
||||
"Place image" = "Colocar imagen"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Los plugins se ejecutan en el entorno aislado de FigCraft, sin archivos, red ni reloj. Instala solo plugins de confianza."
|
||||
"Plugins" = "Plugins"
|
||||
"Plugins & widgets" = "Plugins & widgets"
|
||||
"Plus darker" = "Más oscuro"
|
||||
"Plus lighter" = "Más claro"
|
||||
"Point" = "Punto"
|
||||
@@ -1149,8 +1151,8 @@ status = "draft — needs native review"
|
||||
"Scroll with parent" = "Desplazar con el padre"
|
||||
"Search plugins" = "Buscar plugins"
|
||||
"Search" = "Buscar"
|
||||
"Search actions, layers and tools" = "Buscar acciones, capas y herramientas"
|
||||
"Search assets" = "Buscar recursos"
|
||||
"Search actions, assets and plugins" = "Buscar acciones, recursos y plugins"
|
||||
"Search comments" = "Buscar comentarios"
|
||||
"Search fonts" = "Buscar fuentes"
|
||||
"Search layers" = "Buscar capas"
|
||||
@@ -1469,6 +1471,7 @@ status = "draft — needs native review"
|
||||
"White" = "Blanco"
|
||||
"Who runs it, its terms and how long it keeps files" = "Quién lo gestiona, sus condiciones y cuánto tiempo conserva los archivos"
|
||||
"Why it changed" = "Por qué cambió"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Ancho"
|
||||
"Width point" = "Punto de ancho"
|
||||
|
||||
@@ -1090,7 +1090,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "Défiler jusqu’au calque"
|
||||
"Scroll with parent" = "Défiler avec le parent"
|
||||
"Search" = "Rechercher"
|
||||
"Search actions, layers and tools" = "Rechercher des actions, des calques et des outils"
|
||||
"Search assets" = "Rechercher des ressources"
|
||||
"Search comments" = "Rechercher des commentaires"
|
||||
"Search fonts" = "Rechercher des polices"
|
||||
|
||||
@@ -1137,7 +1137,6 @@ status = "draft — needs native review"
|
||||
"Scroll with parent" = "Gulir bersama induk"
|
||||
"Search plugins" = "Cari plugin"
|
||||
"Search" = "Cari"
|
||||
"Search actions, layers and tools" = "Cari tindakan, layer, dan alat"
|
||||
"Search assets" = "Cari aset"
|
||||
"Search comments" = "Cari komentar"
|
||||
"Search fonts" = "Cari font"
|
||||
|
||||
@@ -1140,7 +1140,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "レイヤーまでスクロール"
|
||||
"Scroll with parent" = "親と一緒にスクロール"
|
||||
"Search" = "検索"
|
||||
"Search actions, layers and tools" = "アクション、レイヤー、ツールを検索"
|
||||
"Search assets" = "アセットを検索"
|
||||
"Search comments" = "コメントを検索"
|
||||
"Search fonts" = "フォントを検索"
|
||||
|
||||
@@ -1090,7 +1090,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "레이어로 스크롤"
|
||||
"Scroll with parent" = "부모와 함께 스크롤"
|
||||
"Search" = "검색"
|
||||
"Search actions, layers and tools" = "액션, 레이어, 도구 검색"
|
||||
"Search assets" = "에셋 검색"
|
||||
"Search comments" = "댓글 검색"
|
||||
"Search fonts" = "글꼴 검색"
|
||||
|
||||
@@ -1089,7 +1089,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "Rolar até a camada"
|
||||
"Scroll with parent" = "Rolar com o pai"
|
||||
"Search" = "Pesquisar"
|
||||
"Search actions, layers and tools" = "Pesquisar ações, camadas e ferramentas"
|
||||
"Search assets" = "Pesquisar recursos"
|
||||
"Search comments" = "Pesquisar comentários"
|
||||
"Search fonts" = "Pesquisar fontes"
|
||||
|
||||
@@ -1134,7 +1134,6 @@ name = "Русский"
|
||||
"Scroll with parent" = "Прокручивать с родителем"
|
||||
"Search plugins" = "Поиск плагинов"
|
||||
"Search" = "Поиск"
|
||||
"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов"
|
||||
"Search assets" = "Поиск ресурсов"
|
||||
"Search comments" = "Поиск комментариев"
|
||||
"Search fonts" = "Поиск шрифтов"
|
||||
|
||||
@@ -1135,7 +1135,6 @@ name = "Svenska"
|
||||
"Scroll with parent" = "Rulla med överordnat element"
|
||||
"Search plugins" = "Sök plugin-program"
|
||||
"Search" = "Sök"
|
||||
"Search actions, layers and tools" = "Sök åtgärder, lager och verktyg"
|
||||
"Search assets" = "Sök tillgångar"
|
||||
"Search comments" = "Sök i kommentarer"
|
||||
"Search fonts" = "Sök typsnitt"
|
||||
|
||||
@@ -1137,7 +1137,6 @@ name = "Türkçe"
|
||||
"Scroll with parent" = "Üst öğeyle birlikte kaydır"
|
||||
"Search plugins" = "Eklentilerde ara"
|
||||
"Search" = "Ara"
|
||||
"Search actions, layers and tools" = "Eylem, katman ve araç ara"
|
||||
"Search assets" = "Varlıklarda ara"
|
||||
"Search comments" = "Yorumlarda ara"
|
||||
"Search fonts" = "Yazı tiplerinde ara"
|
||||
|
||||
@@ -1091,7 +1091,6 @@ status = "draft — needs native review"
|
||||
"Scroll to layer" = "滚动到图层"
|
||||
"Scroll with parent" = "随父级滚动"
|
||||
"Search" = "搜索"
|
||||
"Search actions, layers and tools" = "搜索操作、图层和工具"
|
||||
"Search assets" = "搜索资源"
|
||||
"Search comments" = "搜索评论"
|
||||
"Search fonts" = "搜索字体"
|
||||
|
||||
@@ -415,6 +415,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
}
|
||||
if let Some(h) = p.get("palette").and_then(Value::as_bool) {
|
||||
app.ui.palette = if h { Some(String::new()) } else { None };
|
||||
app.ui.palette_tab = crate::menus::PaletteTab::All;
|
||||
}
|
||||
// true / false, or a top-level menu to open it at ("Plugins").
|
||||
match p.get("mainMenu") {
|
||||
|
||||
@@ -136,6 +136,8 @@ mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod palette_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_remove_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
@@ -404,6 +406,8 @@ pub struct UiState {
|
||||
pub rename_page: Option<(NodeId, String)>,
|
||||
pub main_menu: bool,
|
||||
pub palette: Option<String>,
|
||||
/// The Actions palette's tab.
|
||||
pub palette_tab: menus::PaletteTab,
|
||||
/// The Variables editor, when open.
|
||||
pub variables: Option<variables_panel::Editor>,
|
||||
pub about: bool,
|
||||
@@ -533,6 +537,7 @@ impl Default for UiState {
|
||||
rename_page: None,
|
||||
main_menu: false,
|
||||
palette: None,
|
||||
palette_tab: menus::PaletteTab::All,
|
||||
variables: None,
|
||||
about: false,
|
||||
about_tab: dialogs::AboutTab::About,
|
||||
@@ -788,7 +793,10 @@ impl FigApp {
|
||||
}
|
||||
"dialog.preferences" => self.ui.preferences = true,
|
||||
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
||||
"palette.actions" => self.ui.palette = Some(String::new()),
|
||||
"palette.actions" => {
|
||||
self.ui.palette = Some(String::new());
|
||||
self.ui.palette_tab = menus::PaletteTab::All;
|
||||
}
|
||||
"panel.design" => self.ui.right_tab = RightTab::Design,
|
||||
"panel.prototype" => self.ui.right_tab = RightTab::Prototype,
|
||||
"panel.designPrototype" => {
|
||||
|
||||
+363
-42
@@ -7,9 +7,9 @@ use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::a11y;
|
||||
use crate::i18n::{self, tr, tr_noop, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{ACCENT, Tokens, regular};
|
||||
use crate::i18n::{self, tr, tr_noop, trf};
|
||||
use crate::theme::{Tokens, regular};
|
||||
use crate::toolbar::pretty_shortcut;
|
||||
|
||||
/// Commands the UI layer implements itself (dialogs and platform actions).
|
||||
@@ -73,7 +73,28 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<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 a ran action for the palette's Recents (newest first, capped).
|
||||
pub fn record_recent(app: &mut FigApp, key: &str) {
|
||||
let r = &mut app.session.prefs.recent_actions;
|
||||
if r.first().is_some_and(|f| f == key) {
|
||||
return;
|
||||
}
|
||||
r.retain(|k| k != key);
|
||||
r.insert(0, key.to_string());
|
||||
r.truncate(figcraft_engine::MAX_RECENT_ACTIONS);
|
||||
}
|
||||
|
||||
pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
record_recent(app, &format!("command:{id}"));
|
||||
match id {
|
||||
"file.open" => app.open_dialog(),
|
||||
"plugin.import" => crate::plugins_ui::import_dialog(app),
|
||||
@@ -612,14 +633,25 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Actions palette: fuzzy search over every command.
|
||||
/// The Actions palette's content height (the frame adds its own 8 px margins).
|
||||
const PALETTE_H: f32 = 450.0;
|
||||
|
||||
/// The Actions palette: search over commands, assets and plugins, in Figma's shape (tabs,
|
||||
/// recents, grouped sections). Rows run on click; Enter runs the first visible row.
|
||||
fn palette(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some(mut q) = app.ui.palette.clone() else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Same centre and width as the toolbar pill (it centres on the canvas, not the window),
|
||||
// floating 8 px above it. Fixed size: switching tabs never resizes the panel.
|
||||
let bar_w = crate::toolbar::main_width();
|
||||
let canvas = app.canvas.rect;
|
||||
let area = Rect::from_min_size(
|
||||
pos2(canvas.center().x - bar_w / 2.0, canvas.max.y - 68.0 - (PALETTE_H + 16.0)),
|
||||
vec2(bar_w, PALETTE_H + 16.0),
|
||||
);
|
||||
let mut run: Option<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(
|
||||
egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::NONE
|
||||
@@ -629,64 +661,353 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) {
|
||||
.shadow(egui::Shadow { offset: [0, 8], blur: 32, spread: 0, color: t.shadow })
|
||||
.inner_margin(egui::Margin::same(8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(520.0);
|
||||
// Same outer width as the toolbar pill, and a fixed height: switching tabs
|
||||
// never resizes the panel.
|
||||
let inner = crate::toolbar::main_width() - 16.0;
|
||||
ui.set_width(inner);
|
||||
ui.set_height(PALETTE_H);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut q)
|
||||
.hint_text(tr("Search actions, layers and tools"))
|
||||
.desired_width(500.0)
|
||||
.hint_text(tr("Search actions, assets and plugins"))
|
||||
.desired_width(inner - 16.0)
|
||||
.font(regular(14.0))
|
||||
.frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))),
|
||||
);
|
||||
r.request_focus();
|
||||
crate::widgets::select_all_on_focus(ui, &r, &q);
|
||||
ui.add_space(4.0);
|
||||
let ql = q.to_lowercase();
|
||||
// Labels match in the UI language and in English.
|
||||
let matches =
|
||||
|label: &str, english: &str, id: &str| ql.is_empty() || [label, english, id].iter().any(|s| s.to_lowercase().contains(&ql));
|
||||
let mut hits: Vec<(&str, std::borrow::Cow<str>, &str)> = Vec::new();
|
||||
let menu_items = MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)).map(|m| (m.id, m.label));
|
||||
for (id, english) in figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)).chain(menu_items) {
|
||||
let label = i18n::command(id, english);
|
||||
if matches(&label, english, id) {
|
||||
hits.push((id, label, crate::shortcuts::shortcut(id).unwrap_or("")));
|
||||
}
|
||||
}
|
||||
hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len()));
|
||||
hits.dedup_by(|a, b| a.0 == b.0);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for (i, (id, label, sc)) in hits.iter().take(60).enumerate() {
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
if resp.hovered() || i == 0 {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover });
|
||||
}
|
||||
let col = if resp.hovered() { Color32::WHITE } else { t.text };
|
||||
ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, label.as_ref(), regular(12.0), col);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x - 10.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
pretty_shortcut(sc),
|
||||
regular(11.0),
|
||||
if resp.hovered() { Color32::WHITE } else { t.text_secondary },
|
||||
// Tabs: All, Assets, Plugins & widgets.
|
||||
let tabs = [(PaletteTab::All, tr("All")), (PaletteTab::Assets, tr("Assets")), (PaletteTab::Plugins, tr("Plugins & widgets"))];
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
for (tab, label) in tabs {
|
||||
let active = app.ui.palette_tab == tab;
|
||||
// The frame stays on for every tab (transparent when idle) so the
|
||||
// active pill never moves its neighbours.
|
||||
let resp = ui.add(
|
||||
egui::Button::new(egui::RichText::new(label).size(12.0).color(if active { t.text } else { t.text_secondary }))
|
||||
.frame(true)
|
||||
.fill(if active { t.hover } else { Color32::TRANSPARENT })
|
||||
.corner_radius(CornerRadius::same(6)),
|
||||
);
|
||||
if resp.clicked() || (enter && i == 0) {
|
||||
run = Some(id.to_string());
|
||||
if resp.clicked() {
|
||||
app.ui.palette_tab = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let mut first: Option<String> = None;
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
let tab = app.ui.palette_tab;
|
||||
if tab == PaletteTab::All {
|
||||
let recents: Vec<ActionRow> = app
|
||||
.session
|
||||
.prefs
|
||||
.recent_actions
|
||||
.clone()
|
||||
.iter()
|
||||
.filter_map(|key| recent_row(app, key, &q))
|
||||
.collect();
|
||||
if !recents.is_empty() {
|
||||
section(ui, &t, tr("Recents"), |ui| {
|
||||
for row in &recents {
|
||||
first.get_or_insert_with(|| row.key.clone());
|
||||
if draw_row(ui, &t, row) {
|
||||
run = Some(row.key.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if tab != PaletteTab::Plugins {
|
||||
for (group, rows) in command_sections(&q) {
|
||||
if tab == PaletteTab::Assets {
|
||||
continue;
|
||||
}
|
||||
section(ui, &t, &group, |ui| {
|
||||
for row in rows {
|
||||
first.get_or_insert_with(|| row.key.clone());
|
||||
if draw_row(ui, &t, &row) {
|
||||
run = Some(row.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if tab != PaletteTab::Assets {
|
||||
let plugins = plugin_rows(app, &q);
|
||||
let widgets = widget_rows(app, &q);
|
||||
if !plugins.is_empty() {
|
||||
section(ui, &t, tr("Plugins"), |ui| {
|
||||
for row in plugins {
|
||||
first.get_or_insert_with(|| row.key.clone());
|
||||
if draw_row(ui, &t, &row) {
|
||||
run = Some(row.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if !widgets.is_empty() {
|
||||
section(ui, &t, tr("Widgets"), |ui| {
|
||||
for row in widgets {
|
||||
first.get_or_insert_with(|| row.key.clone());
|
||||
if draw_row(ui, &t, &row) {
|
||||
run = Some(row.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if tab == PaletteTab::Assets {
|
||||
for (group, rows) in asset_sections(app, &q) {
|
||||
section(ui, &t, &group, |ui| {
|
||||
for row in rows {
|
||||
first.get_or_insert_with(|| row.key.clone());
|
||||
if draw_row(ui, &t, &row) {
|
||||
run = Some(row.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
if enter {
|
||||
if let Some(key) = first {
|
||||
run = Some(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
if let Some(id) = run {
|
||||
if let Some(key) = run {
|
||||
app.ui.palette = None;
|
||||
invoke(app, &id);
|
||||
run_action(app, &key);
|
||||
return;
|
||||
}
|
||||
app.ui.palette = if close { None } else { Some(q) };
|
||||
let _ = StrokeKind::Inside;
|
||||
}
|
||||
|
||||
/// Run a palette row: `command:<id>`, `plugin:<plugin>/<command>`, `widget:<plugin>`,
|
||||
/// `component:<node>` or `style:<id>`.
|
||||
fn run_action(app: &mut FigApp, key: &str) {
|
||||
if let Some(id) = key.strip_prefix("command:") {
|
||||
invoke(app, id);
|
||||
} else if let Some(rest) = key.strip_prefix("plugin:") {
|
||||
if let Some((plugin, command)) = rest.split_once('/') {
|
||||
record_recent(app, key);
|
||||
crate::plugins_ui::start(app, plugin, command);
|
||||
}
|
||||
} else if let Some(plugin) = key.strip_prefix("widget:") {
|
||||
record_recent(app, key);
|
||||
app.run("widget.insert", json!({ "plugin": plugin }));
|
||||
} else if let Some(node) = key.strip_prefix("component:") {
|
||||
if let Ok(n) = node.parse::<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(6.0);
|
||||
ui.painter().text(pos2(ui.min_rect().min.x + 10.0, ui.cursor().min.y + 7.0), Align2::LEFT_TOP, name, regular(11.0), t.text_secondary);
|
||||
ui.add_space(18.0);
|
||||
rows(ui);
|
||||
}
|
||||
|
||||
/// Draw one row (a letter tile for plugins, else just the label) with its right-side hint.
|
||||
/// True when clicked.
|
||||
fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow) -> bool {
|
||||
let w = ui.available_width();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 34.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
|
||||
}
|
||||
let mut label_x = r.min.x + 10.0;
|
||||
if let Some(letter) = row.tile {
|
||||
let tile = Rect::from_center_size(pos2(r.min.x + 20.0, r.center().y), vec2(18.0, 18.0));
|
||||
ui.painter().rect_filled(tile, CornerRadius::same(5), t.hover);
|
||||
ui.painter().text(tile.center(), Align2::CENTER_CENTER, letter.to_string(), regular(11.0), t.text);
|
||||
label_x = r.min.x + 38.0;
|
||||
}
|
||||
ui.painter().text(pos2(label_x, r.center().y), Align2::LEFT_CENTER, &row.label, regular(13.0), t.text);
|
||||
ui.painter().text(pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, &row.hint, regular(11.0), t.text_secondary);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
/// Whether `label` (or `extra`) matches the query, in any case.
|
||||
fn matches(q: &str, label: &str, extra: &str) -> bool {
|
||||
q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q)
|
||||
}
|
||||
|
||||
/// Commands grouped by top-level menu, in catalog order, filtered by the query.
|
||||
pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
let ql = q.to_lowercase();
|
||||
let mut groups: Vec<(String, Vec<ActionRow>)> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for m in figcraft_engine::catalog::MENU.iter() {
|
||||
if m.label == figcraft_engine::catalog::SEP || m.menu.starts_with("Plugins") || !available(m.id) || !seen.insert(m.id) {
|
||||
continue;
|
||||
}
|
||||
let english = m.label;
|
||||
let label = i18n::menu_item(m.id, english).into_owned();
|
||||
if !matches(&ql, &label, &format!("{english} {}", m.id)) {
|
||||
continue;
|
||||
}
|
||||
let group = m.menu.split('/').next().unwrap_or(&m.menu);
|
||||
let group = tr(group).to_string();
|
||||
let row = ActionRow {
|
||||
key: format!("command:{}", m.id),
|
||||
label,
|
||||
hint: crate::shortcuts::shortcut(m.id).map(pretty_shortcut).unwrap_or_default(),
|
||||
tile: None,
|
||||
};
|
||||
match groups.iter_mut().find(|(g, _)| *g == group) {
|
||||
Some((_, rows)) => rows.push(row),
|
||||
None => groups.push((group, vec![row])),
|
||||
}
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Installed plugins' commands, filtered by the query.
|
||||
fn plugin_rows(app: &FigApp, q: &str) -> Vec<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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
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);
|
||||
if !available(id) || !matches(&ql, english, id) {
|
||||
return None;
|
||||
}
|
||||
return Some(ActionRow {
|
||||
key: key.to_string(),
|
||||
label: i18n::command(id, english).into_owned(),
|
||||
hint: crate::shortcuts::shortcut(id).map(pretty_shortcut).unwrap_or_default(),
|
||||
tile: None,
|
||||
});
|
||||
}
|
||||
if let Some(rest) = key.strip_prefix("plugin:") {
|
||||
let (pid, cid) = rest.split_once('/')?;
|
||||
let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid)?;
|
||||
let c = p.manifest.commands.iter().find(|c| c.id == cid)?;
|
||||
let label = if p.manifest.commands.len() == 1 { p.manifest.name.clone() } else { format!("{} — {}", p.manifest.name, c.label) };
|
||||
if !matches(&ql, &label, pid) {
|
||||
return None;
|
||||
}
|
||||
return Some(ActionRow {
|
||||
key: key.to_string(),
|
||||
label,
|
||||
hint: tr("Plugin").to_string(),
|
||||
tile: p.manifest.name.chars().next(),
|
||||
});
|
||||
}
|
||||
if let Some(pid) = key.strip_prefix("widget:") {
|
||||
let p = app.session.prefs.plugins.iter().find(|p| p.manifest.id == pid && p.manifest.widget)?;
|
||||
if !matches(&ql, &p.manifest.name, pid) {
|
||||
return None;
|
||||
}
|
||||
return Some(ActionRow {
|
||||
key: key.to_string(),
|
||||
label: p.manifest.name.clone(),
|
||||
hint: tr("Widget").to_string(),
|
||||
tile: p.manifest.name.chars().next(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! The Actions palette, driven headless: invoking records recents (newest first, capped),
|
||||
//! sections group commands by menu, the query filters, and gone entries drop out of Recents.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
#[test]
|
||||
fn invoke_records_recents_newest_first_and_capped() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
for id in [
|
||||
"view.zoomIn",
|
||||
"view.zoomOut",
|
||||
"edit.copyAsText",
|
||||
"style.list",
|
||||
"edit.findReplace",
|
||||
"edit.copyAsCss",
|
||||
"view.zoomIn",
|
||||
] {
|
||||
crate::menus::invoke(&mut u.app, id);
|
||||
}
|
||||
let r = &u.app.session.prefs.recent_actions;
|
||||
assert_eq!(r.len(), figcraft_engine::MAX_RECENT_ACTIONS, "recents are capped: {r:?}");
|
||||
assert_eq!(r[0], "command:view.zoomIn", "a re-run moves to the front: {r:?}");
|
||||
assert_eq!(r.iter().filter(|k| k.as_str() == "command:view.zoomIn").count(), 1, "no duplicates: {r:?}");
|
||||
assert!(!r.contains(&"command:view.zoomOut".to_string()), "the oldest drops out: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_group_by_menu_in_catalog_order() {
|
||||
let groups = crate::menus::command_sections("");
|
||||
assert!(!groups.is_empty(), "the catalog has commands");
|
||||
assert_eq!(groups[0].0, "File", "catalog order starts at File");
|
||||
for (group, rows) in &groups {
|
||||
assert!(!rows.is_empty(), "{group} has no rows");
|
||||
for row in rows {
|
||||
assert!(row.key.starts_with("command:"), "unexpected key {}", row.key);
|
||||
}
|
||||
}
|
||||
let names: Vec<&str> = groups.iter().map(|(g, _)| g.as_str()).collect();
|
||||
assert!(names.contains(&"File") && names.contains(&"Edit"), "menus group: {names:?}");
|
||||
assert!(!names.iter().any(|g| g.starts_with("Plugins")), "plugins have their own tab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_filters_to_matches() {
|
||||
let groups = crate::menus::command_sections("save");
|
||||
let labels: Vec<&str> = groups.iter().flat_map(|(_, rows)| rows.iter().map(|r| r.label.as_str())).collect();
|
||||
assert!(!labels.is_empty(), "something matches 'save'");
|
||||
assert!(labels.iter().all(|l| l.to_lowercase().contains("save")), "only matches: {labels:?}");
|
||||
assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gone_entries_drop_out_of_recents() {
|
||||
let u = Ui::new(Session::new());
|
||||
assert!(crate::menus::recent_row(&u.app, "command:nope.nope", "").is_none(), "gone commands drop out");
|
||||
assert!(crate::menus::recent_row(&u.app, "plugin:nope.nope/nope", "").is_none(), "gone plugins drop out");
|
||||
assert!(crate::menus::recent_row(&u.app, "widget:nope", "").is_none(), "gone widgets drop out");
|
||||
}
|
||||
@@ -171,10 +171,12 @@ pub fn menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> bool {
|
||||
.flatten();
|
||||
crate::menus::separator(ui);
|
||||
if let Some(plugin) = widget {
|
||||
crate::menus::record_recent(app, &format!("widget:{plugin}"));
|
||||
app.run("widget.insert", json!({"plugin": plugin}));
|
||||
return true;
|
||||
}
|
||||
let Some((id, command)) = chosen else { return false };
|
||||
crate::menus::record_recent(app, &format!("plugin:{id}/{command}"));
|
||||
start(app, &id, &command);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -72,9 +72,18 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
builder_toolbar(app, ui);
|
||||
return;
|
||||
}
|
||||
let cell = 32.0;
|
||||
let gap = 4.0;
|
||||
let width = GROUPS.len() as f32 * (cell + 4.0) + 4.0 * 10.0 + 2.0 * (cell + 4.0) + 16.0 + 4.0 * 34.0 + 24.0;
|
||||
main_toolbar(app, ui, canvas, &t);
|
||||
}
|
||||
|
||||
/// The main toolbar pill's width: the Actions palette matches it so the two read as one bar.
|
||||
pub(crate) fn main_width() -> f32 {
|
||||
let (cell, gap) = (32.0, 4.0);
|
||||
GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0
|
||||
}
|
||||
|
||||
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
|
||||
let (cell, gap) = (32.0, 4.0);
|
||||
let width = main_width();
|
||||
let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);
|
||||
let current = app.session.tool.tool;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ interaction feel not yet compared.
|
||||
| Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) |
|
||||
| Minimize UI (Cmd+\\) | done | |
|
||||
| Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs (All, Assets, Plugins & widgets), Recents kept in the preferences, commands grouped by menu, this file's components (placed at the view centre) and styles (applied to the selection), installed plugins and widgets with letter tiles; rows carry icons and shortcut hints (`palette_tests`) |
|
||||
| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
|
||||
|
||||
Reference in New Issue
Block a user