mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
Merge fix/707
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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 §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<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
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user