Palette follows the bar: toggle, dismiss, no invented icons

The Actions button toggles (re-click and Cmd+K close), reads on in
accent blue with a white icon while open, and a press anywhere outside
closes the panel in one click. The opening press is spared once (the
spare expires on press-free frames); presses on the button itself never
dismiss, so the release toggles cleanly shut instead of reopening.

Rows drop the per-family icons (labels + right-side hints only);
plugins and widgets keep their letter tiles. The panel anchors to the
canvas centre at the toolbar pill's exact width (toolbar::main_width),
at a fixed size, with inset hover pills and larger section titles.

Tests: one-click dismiss pinned (palette_tests).
This commit is contained in:
Ahmed5Emad
2026-10-16 23:27:45 +03:00
parent dc97118c6f
commit 3b5c5d563b
5 changed files with 101 additions and 10 deletions
+1
View File
@@ -416,6 +416,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;
app.ui.palette_armed = true;
}
// true / false, or a top-level menu to open it at ("Plugins").
match p.get("mainMenu") {
+13 -2
View File
@@ -408,6 +408,10 @@ pub struct UiState {
pub palette: Option<String>,
/// The Actions palette's tab.
pub palette_tab: menus::PaletteTab,
/// 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,
@@ -538,6 +542,8 @@ impl Default for UiState {
main_menu: false,
palette: None,
palette_tab: menus::PaletteTab::All,
palette_armed: false,
actions_rect: None,
variables: None,
about: false,
about_tab: dialogs::AboutTab::About,
@@ -794,8 +800,13 @@ 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());
self.ui.palette_tab = menus::PaletteTab::All;
if self.ui.palette.is_some() {
self.ui.palette = None;
} else {
self.ui.palette = Some(String::new());
self.ui.palette_tab = menus::PaletteTab::All;
self.ui.palette_armed = true;
}
}
"panel.design" => self.ui.right_tab = RightTab::Design,
"panel.prototype" => self.ui.right_tab = RightTab::Prototype,
+18 -4
View File
@@ -651,6 +651,18 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) {
);
let mut run: Option<String> = None;
let mut close = false;
// 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;
}
egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(
ctx,
|ui| {
@@ -830,9 +842,9 @@ pub(crate) struct ActionRow {
/// 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);
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);
}
@@ -841,8 +853,10 @@ fn section(ui: &mut Ui, t: &Tokens, name: &str, rows: impl FnOnce(&mut Ui)) {
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());
// The hover pill stays inside the row: it never touches the panel's edges.
let pill = r.shrink2(vec2(4.0, 1.0));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
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 {
+52 -1
View File
@@ -1,7 +1,10 @@
//! 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.
//! sections group commands by menu, the query filters, gone entries drop out of Recents, a used
//! plugin resolves back to a row, and a press outside the panel closes it.
use egui::Pos2;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
@@ -58,3 +61,51 @@ fn gone_entries_drop_out_of_recents() {
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");
}
+17 -3
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);
@@ -138,13 +139,26 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
] {
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());
if app.ui.palette.is_some() {
app.ui.palette = None;
} else {
app.ui.palette = Some(String::new());
app.ui.palette_tab = crate::menus::PaletteTab::All;
app.ui.palette_armed = true;
}
} else {
app.ui.left_tab = crate::LeftTab::Assets;
app.ui.left_collapsed = false;