From 3b5c5d563b18c2d329ed2f4e92b8c3de87071d72 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 23:27:45 +0300 Subject: [PATCH] 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). --- crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/lib.rs | 15 ++++++-- crates/ui-egui/src/menus.rs | 22 +++++++++--- crates/ui-egui/src/palette_tests.rs | 53 ++++++++++++++++++++++++++++- crates/ui-egui/src/toolbar.rs | 20 +++++++++-- 5 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9dd8e2f4..6e55c8a4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 46830316..34f6ac0d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -408,6 +408,10 @@ pub struct UiState { pub palette: Option, /// 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, /// The Variables editor, when open. pub variables: Option, 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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0e079313..a95fe2fd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -651,6 +651,18 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ); let mut run: Option = 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 { diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index e496cb77..06e9fdf5 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -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"); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 0aef7555..962b7497 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -57,6 +57,7 @@ fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let canvas = app.canvas.rect; + app.ui.actions_rect = None; // A file you can't edit now (view only, or on its way to a server): moving around only. if let Some(why) = app.session.active_read_only() { view_only_toolbar(app, ui, why); @@ -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;