collective

Browse / github.com/storytold/figcraft / patch

M4: a generic popover — a 40-high header, dragged by its header

M4: a generic popover — a 40-high header, dragged by its header The chrome detached panels share, in one place, instead of each panel growing its own frame. ## What lands - `crates/ui-egui/src/popover.rs`: a 240-point card with a 40-point header — the title 16 in from its left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup shadow, around **one slot**: ```rust let mut p = Popover::new("my_popover", tr("Title")); let closed = p.show(ctx, Open::Anchor(pos), Hold::default(), |ui| { /* the slot */ }); ``` - **Where the measurements come from**: they are our own — the header is the colour picker's, which this widge

Status
open
Score
0.00
Adopters
0 other operators
Author
yelintong (imported from pull request #789, open)
Base
43af145a12eacd1e87f5f5e6984c49b7da040288
Size
+989 −432 in 14 files
Review
pending
Shared
2026-10-28
Id
ad6da83bd524246226a84916

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/app/frame.rs crates/ui-egui/src/colorpicker_selection_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panel_remove_tests.rs crates/ui-egui/src/panels/right.rs crates/ui-egui/src/panels/right/effects.rs crates/ui-egui/src/panels/right/paints.rs crates/ui-egui/src/panels/selection_colors.rs crates/ui-egui/src/popover.rs crates/ui-egui/src/popover_tests.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/src/widgets/colorpicker.rs crates/ui-egui/src/widgets/style_picker.rs crates/ui-egui/src/widgets/style_picker/tests.rs

diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs--- a/crates/ui-egui/src/app/frame.rs+++ b/crates/ui-egui/src/app/frame.rs@@ -29,6 +29,16 @@ impl FigApp {         }     } +    /// Clicking the control that opened the colour picker closes it again; clicking another opens+    /// the picker for that one.+    pub fn toggle_picker(&mut self, target: colorpicker::PickerTarget, _top: Option<f32>) {+        if self.ui.picker.as_ref() == Some(&target) {+            colorpicker::close(self);+        } else {+            self.ui.picker = Some(target);+        }+    }+     /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect     /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels     /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more.diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs--- a/crates/ui-egui/src/colorpicker_selection_tests.rs+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs@@ -53,6 +53,25 @@ fn picker_closes_when_another_layer_is_selected() {     assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } +#[test]+fn clicking_the_swatch_again_closes_the_picker() {+    let (mut u, a, _) = two();+    u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));+    let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };+    u.app.toggle_picker(target.clone(), Some(200.0));+    u.frame(vec![]);+    u.frame(vec![]);+    assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");+    u.app.toggle_picker(target.clone(), Some(200.0));+    u.frame(vec![]);+    assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");+    // Another paint of the same layer opens the picker for that one.+    let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };+    u.app.toggle_picker(second.clone(), Some(200.0));+    u.frame(vec![]);+    assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");+}+ // Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's // left edge (only the first row was indented) and ran past its right edge. #[test]@@ -77,11 +96,9 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {         u.frame(vec![]);     }     let out = u.frame_output(vec![]);-    // The picker is the only floating window open.-    let picker = u-        .ctx-        .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id)))-        .expect("the picker is open");+    // The picker is the only floating window open: its area is the picker's own id, which the style+    // picker also looks up to tell a press in here from one outside.+    let picker = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("the picker is open");     // A swatch is a 14 pt square with a 1 pt outline.     let swatches: Vec<egui::Rect> = out         .shapesdiff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -17,6 +17,7 @@ pub mod icons; pub mod live; pub mod menus; pub mod pending_open;+pub mod popover; pub mod present; pub mod shortcuts; pub mod tabs;@@ -157,6 +158,8 @@ mod plugin_save_ui_tests; #[cfg(test)] mod plugins_browse_tests; #[cfg(test)]+mod popover_tests;+#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_links_tests;@@ -394,6 +397,11 @@ pub struct UiState {     /// The prototype preview pane floating over the canvas, when open.     pub preview: Option<present::Pane>,     pub picker: Option<colorpicker::PickerTarget>,+    /// The colour picker's popover while it is open (its place survives a drag).+    pub picker_popover: Option<popover::Popover>,+    /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):+    /// a press in it is held out of the popover's closing, so its own click toggles the picker.+    pub picker_button: Option<egui::Rect>,     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the     /// focused layer row or the canvas selection and takes the keyboard.@@ -441,6 +449,8 @@ pub struct UiState {     pub color_profile: Option<figcraft_color::ColorProfile>,     /// The open style picker.     pub style_picker: Option<style_picker::Picker>,+    /// The style picker's popover while it is open (its place survives a drag).+    pub style_picker_popover: Option<popover::Popover>,     /// The style picker shows colour styles as a grid of swatches.     pub style_grid: bool,     /// Style folders closed in the style picker.@@ -570,6 +580,8 @@ impl Default for UiState {             present_view: present::PresentView::default(),             preview: None,             picker: None,+            picker_popover: None,+            picker_button: None,             context_menu: None,             key_menu: false,             key_box: None,@@ -597,6 +609,7 @@ impl Default for UiState {             pdf_print: figcraft_doc::PrintSettings::default(),             color_profile: None,             style_picker: None,+            style_picker_popover: None,             style_grid: false,             style_folds: BTreeSet::new(),             text_preview: None,diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs--- a/crates/ui-egui/src/panel_remove_tests.rs+++ b/crates/ui-egui/src/panel_remove_tests.rs@@ -192,3 +192,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() {     assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));     assert_eq!(u.app.ui.picker, None); }++/// The shared popover's closing reaches the colour picker: a press on the canvas closes it.+#[test]+fn a_press_on_the_canvas_closes_the_colour_picker() {+    let mut s = Session::new();+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();+    s.execute("select.set", &json!({"ids": [r]})).unwrap();+    let mut u = Ui::new(s);+    u.ctx.enable_accesskit();+    let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();+    click(&mut u, sw);+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");+    click(&mut u, Pos2::new(320.0, 300.0));+    assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker");+}++/// Pressing the swatch that opened the picker again closes it: its press is held out of the+/// popover's outside-press closing, so the swatch's own toggle gets the click.+#[test]+fn the_swatch_that_opened_the_colour_picker_closes_it_again() {+    let mut s = Session::new();+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();+    s.execute("select.set", &json!({"ids": [r]})).unwrap();+    let mut u = Ui::new(s);+    u.ctx.enable_accesskit();+    let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center();+    click(&mut u, sw);+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");+    click(&mut u, sw);+    assert_eq!(u.app.ui.picker, None, "the same swatch closes it again");+}diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs--- a/crates/ui-egui/src/panels/right.rs+++ b/crates/ui-egui/src/panels/right.rs@@ -300,11 +300,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {     };     widgets::section_header(ui, tr("Page"), &[], false);     let mut open_picker = false;+    let mut swatch_top = None;     let mut new_hex = None;     row(ui, 32.0, |ui| {         let p = Paint::solid(bg);-        if widgets::swatch(ui, &p, 16.0).clicked() {+        let sw = widgets::swatch(ui, &p, 16.0);+        if sw.clicked() {             open_picker = true;+            swatch_top = Some(sw.rect.min.y);         }         if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") {             new_hex = Some(h);@@ -312,7 +315,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {         widgets::label(ui, "100 %", t.text);     });     if open_picker {-        app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);+        app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top);     }     if let Some(h) = new_hex         && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs--- a/crates/ui-egui/src/panels/right/effects.rs+++ b/crates/ui-egui/src/panels/right/effects.rs@@ -325,8 +325,9 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { /// duo noise's second colour), in the current row; `edit` applies a new one. fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) {     let Some(c) = e.color(secondary) else { return };-    if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() {-        app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });+    let sw = widgets::swatch(ui, &Paint::solid(c), 16.0);+    if sw.clicked() {+        app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y));     }     if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")         && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs--- a/crates/ui-egui/src/panels/right/paints.rs+++ b/crates/ui-egui/src/panels/right/paints.rs@@ -248,8 +248,9 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:         match fills.as_ref().and_then(|f| f.first()) {             Some(p) => {                 widgets::paint_swatch(ui.painter(), sw, p);-                if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() {-                    app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });+                let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click());+                if range_swatch.clicked() {+                    app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y));                 }                 if let Some(c) = p.solid_color() {                     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));@@ -437,8 +438,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,         let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));         widgets::paint_swatch(ui.painter(), sw, p);         let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click());+        // While this paint is the one the picker edits, its swatch is held out of the popover's+        // closing, so a press on it again reaches the toggle below.+        let editing = matches!(+            &app.ui.picker,+            Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids+        );+        if editing {+            app.ui.picker_button = Some(sresp.rect);+        }         if sresp.clicked() {-            app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() });+            app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y));         }         let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));         let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs--- a/crates/ui-egui/src/panels/selection_colors.rs+++ b/crates/ui-egui/src/panels/selection_colors.rs@@ -47,9 +47,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti     colors } -pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {+pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, top: Option<f32>) {     let Ok(ds) = app.session.ds() else { return };-    app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });+    app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); }  /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity@@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                     crate::right::row(ui, 32.0, |ui| {                         let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));                         if swatch.clicked() {-                            open_picker(app, group.targets.clone());+                            toggle(app, group.targets.clone(), Some(swatch.rect.min.y));                         }                         let width = (ui.available_width() - 116.0).max(40.0);                         let linked_name = group@@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                             .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));                         if let Some(name) = linked_name {                             if widgets::dropdown(ui, name, width, None).clicked() {-                                open_picker(app, group.targets.clone());+                                toggle(app, group.targets.clone(), None);                             }                         } else if let Some(color) = group.paint.solid_color() {                             if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {@@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                         } else {                             let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);                             if response.clicked() {-                                open_picker(app, group.targets.clone());+                                toggle(app, group.targets.clone(), Some(response.rect.min.y));                             }                         }                         let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/popover.rs@@ -0,0 +1,283 @@+//! The chrome every detached panel shares: a card with a header — the title 16 points in from its+//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup+//! shadow, around one slot.+//!+//! The measurements are our own: they are the header the colour picker has always drawn, and the+//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing+//! here is taken from another product's interface.+//!+//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off+//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on+//! Escape and on a press outside it — except in the rects the caller holds out (the button that+//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead+//! of the press closing it and the release opening it again.++use egui::epaint::RectShape;+use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2};++use crate::a11y::Tip;+use crate::i18n::tr;+use crate::icons::{self, Icon};+use crate::theme::{Tokens, semibold};+use crate::widgets;++/// The default width: the colour picker's, and the one every detached panel shares.+pub const WIDTH: f32 = 240.0;+/// The header's height: the title's line and the X sit in it.+pub const HEADER_H: f32 = 40.0;+/// The title starts this far from the panel's left edge, the X ends this far from its right one.+const TITLE_LEFT: f32 = 16.0;+const CLOSE_RIGHT: f32 = 8.0;+/// The X's button (the icon is inset in it).+const CLOSE: f32 = 24.0;+/// The slot's default margin inside the panel.+const SLOT_PAD: f32 = 16.0;+/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others.+const SCREEN_GAP: f32 = 16.0;+const EDGE: f32 = 8.0;+/// The panel's corners.+const RADIUS: u8 = 13;++/// The surface a popover draws on: the panels' own, or the dark one the style picker uses.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Surface {+    Panel,+    Menu,+}++/// Where a popover opens when the caller has not put it down itself.+#[derive(Clone, Copy, Debug, PartialEq)]+pub enum Open {+    /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),+    /// lifted when that would run its bottom past the screen's bottom.+    Anchor(Pos2),+    /// Its top-left exactly here.+    At(Pos2),+}++/// What keeps a popover open while a press or Escape happens somewhere else.+#[derive(Clone, Copy, Default)]+pub struct Hold<'a> {+    /// Presses in these rects do not close it: the button that opened it, the popovers it opened.+    pub rects: &'a [Rect],+    /// Escape belongs to something above it (a field being typed in, a picker of its own).+    pub escape: bool,+}++/// Where the popover shows.+#[derive(Clone, Copy, Debug, PartialEq)]+enum Place {+    /// From [`Open`], until the header drags it.+    Auto,+    /// Here: put down by the caller or by the drag.+    At(Pos2),+}++/// An open popover.+pub struct Popover {+    id: Id,+    /// The header's line.+    pub title: String,+    width: f32,+    pad: f32,+    surface: Surface,+    /// Whether a press outside it closes it.+    outside: bool,+    place: Place,+    /// While the header is held down: where in the panel the pointer grabbed it.+    grab: Option<Vec2>,+    /// The panel, its header, its slot and the X, as drawn last frame.+    pub rect: Rect,+    pub header: Rect,+    pub slot: Rect,+    pub close: Rect,+    /// The panel's size as laid out last frame.+    size: Vec2,+}++impl Popover {+    /// A popover titled `title`; `id` names its egui area, so it must be stable and unique.+    pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {+        Self::with_id(Id::unique(id), title)+    }++    /// A popover whose egui area is `id` itself — not a hash of it — so a caller that publishes the+    /// id can find the area again (the colour picker's [`crate::colorpicker::window_id`]).+    pub fn with_id(id: Id, title: impl Into<String>) -> Self {+        Self {+            id,+            title: title.into(),+            width: WIDTH,+            pad: SLOT_PAD,+            surface: Surface::Panel,+            outside: true,+            place: Place::Auto,+            grab: None,+            rect: Rect::NOTHING,+            header: Rect::NOTHING,+            slot: Rect::NOTHING,+            close: Rect::NOTHING,+            size: vec2(WIDTH, 0.0),+        }+    }++    /// `width` points wide instead of [`WIDTH`].+    pub fn width(mut self, width: f32) -> Self {+        self.width = width.max(1.0);+        self.size.x = self.width;+        self+    }++    /// `pad` points of margin around the slot instead of the default 16.+    pub fn pad(mut self, pad: f32) -> Self {+        self.pad = pad.max(0.0);+        self+    }++    /// Drawn on the dark surface the style browser uses.+    pub fn surface(mut self, surface: Surface) -> Self {+        self.surface = surface;+        self+    }++    /// Whether a press outside it closes it (the default). The colour picker stays open, so a+    /// press on a row beside it keeps editing the paint it is on.+    pub fn close_on_outside(mut self, yes: bool) -> Self {+        self.outside = yes;+        self+    }++    /// Its top-left at `pos`, instead of where [`Open`] would put it.+    pub fn at(mut self, pos: Pos2) -> Self {+        self.place = Place::At(pos);+        self+    }++    /// Draws the panel and fills its slot with `body`; true when it asked to close — the X,+    /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame.+    pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool {+        let t = Tokens::get(ctx);+        let dark = self.surface == Surface::Menu;+        let screen = ctx.content_rect();+        // The anchor rule needs the panel's height, which only a layout gives: the first frame+        // lays it out invisibly and the next one places it — a jump would show.+        let first = self.size.y <= 0.0;+        let pos = match self.place {+            Place::At(p) => clamp(p, self.size, screen),+            Place::Auto => {+                let p = match open {+                    Open::At(p) => p,+                    Open::Anchor(a) => open_pos(a, self.size, screen),+                };+                clamp(p, self.size, screen)+            }+        };+        let mut close = false;+        egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {+            if first {+                ui.set_invisible();+                ctx.request_repaint();+            }+            // The panel is painted into a place kept before the content: the shadow and the fill+            // go under it, though its size is only known once the slot is laid out.+            let bg = ui.painter().add(egui::Shape::Noop);+            let spacing = ui.spacing().item_spacing;+            ui.spacing_mut().item_spacing = Vec2::ZERO;+            // 1. The header: the title, then the X at the right.+            let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover());+            let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE));+            let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y));+            let drag = ui.interact(handle, self.id.with("drag"), Sense::drag());+            let font = semibold(11.0);+            let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone());+            ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark));+            close = close_button(ui, self.id.with("close"), close_rect, dark, t);+            // 2. The header drags the panel (grabbed where the press landed, so the first frame of+            // a fast drag counts too); the X keeps its own clicks.+            if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) {+                self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos);+            }+            if drag.dragged()+                && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos())+            {+                self.place = Place::At(clamp(at - grab, self.size, screen));+            }+            if ctx.input(|i| i.pointer.any_released()) {+                self.grab = None;+            }+            if drag.dragged() || drag.hovered() {+                ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });+            }+            // 3. The slot, under a hairline.+            let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| {+                ui.spacing_mut().item_spacing = spacing;+                ui.set_width(self.width - 2.0 * self.pad);+                body(ui);+                ui.min_rect()+            });+            let slot = frame.inner;+            let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y));+            let shadow = ctx.global_style().visuals.popup_shadow;+            let fill = if dark { t.menu } else { t.panel };+            let shapes = vec![+                egui::Shape::from(shadow.as_shape(panel, RADIUS)),+                RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(),+                // A point of the theme's border colour under the header.+                RectShape::filled(+                    Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)),+                    CornerRadius::ZERO,+                    if dark { Color32::from_white_alpha(24) } else { t.border },+                )+                .into(),+            ];+            ui.painter().set(bg, egui::Shape::Vec(shapes));+            self.rect = panel;+            self.header = header;+            self.slot = slot;+            self.close = close_rect;+            self.size = panel.size();+        });+        // Closing is the widget's: the X above, Escape and a press outside (a child popup or a+        // held-out rect keeps it open, so the button that opened it can click it shut).+        let popup_open = egui::Popup::is_any_open(ctx);+        let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape));+        let outside = self.outside+            && !popup_open+            && ctx.input(|i| {+                i.pointer.any_pressed()+                    && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at)))+            });+        close || escape || outside+    }+}++fn header_text(t: Tokens, dark: bool) -> Color32 {+    if dark { t.menu_text } else { t.text }+}++/// The X: a 24-point button, its icon inset 6 (the colour picker's header).+fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {+    let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));+    if resp.hovered() {+        ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });+    }+    icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });+    resp.clicked()+}++/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on+/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to+/// keep [`SCREEN_GAP`] under it.+fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {+    let bottom = screen.max.y - SCREEN_GAP;+    let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y };+    pos2(anchor.x - size.x, y)+}++/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.+fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {+    let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);+    let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0));+    let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0));+    pos2(x, y)+}diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/popover_tests.rs@@ -0,0 +1,175 @@+//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in+//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule+//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen),+//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press+//! in a held-out rect, so the button that opened it can toggle it shut.++use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2};++use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH};++const SCREEN: egui::Vec2 = vec2(1440.0, 900.0);++/// A context with the UI's fonts: the header's title is drawn in the semibold family.+fn context() -> egui::Context {+    let ctx = egui::Context::default();+    crate::theme::install_fonts(&ctx, false, 0);+    ctx+}++fn input(t: &mut f64, events: Vec<Event>) -> RawInput {+    *t += 1.0 / 60.0;+    RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() }+}++fn press(p: Pos2, pressed: bool) -> Vec<Event> {+    vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]+}++fn escape() -> Vec<Event> {+    vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]+}++/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it+/// asked to close.+fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec<Event>) -> (Vec<egui::Shape>, bool) {+    let mut closed = false;+    let mut out = ctx.run_ui(input(t, events), |ui| {+        closed = p.show(ui.ctx(), open, hold, |ui| {+            ui.label("slot");+        });+    });+    out.textures_delta.clear();+    (crate::live_tests::flat(&out.shapes), closed)+}++/// Two frames: the first lays the panel out to learn its height, the second places and paints it.+fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec<egui::Shape> {+    draw(ctx, t, p, open, Hold::default(), vec![]);+    draw(ctx, t, p, open, Hold::default(), vec![]).0+}++fn near(a: Pos2, b: Pos2) -> bool {+    (a - b).length() < 0.05+}++#[test]+fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_header", "Custom");+    let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    assert_eq!(p.rect.width(), WIDTH, "240 points wide");+    assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect);+    assert_eq!(p.header.height(), HEADER_H, "the header is 40 high");+    // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line.+    assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close);+    assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line");+    // The title: one line, 16 in from the left, on the header's middle.+    let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows");+    assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}");+    assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}");+    // The slot: 16 in from the panel's sides, under the header.+    assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot);+    assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot);+}++#[test]+fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_anchor", "Popup");+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0)));+    assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect);+    assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);+}++#[test]+fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_lift", "Popup");+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0)));+    // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's.+    assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect);+    assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect);+}++#[test]+fn the_header_drags_it_and_the_slot_does_not() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_drag", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let at = Open::At(pos2(160.0, 100.0));+    let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0));+    let before = p.rect.min;+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min);+    // The slot does not drag it.+    let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0));+    let held = p.rect.min;+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min);+}++#[test]+fn the_x_and_escape_close_it() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_close", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let at = Open::At(pos2(160.0, 100.0));+    let close = p.close.center();+    assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet");+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close");+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close");+    // Escape that belongs to something above it does not.+    let mut p = Popover::new("popover_escape_held", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let hold = Hold { rects: &[], escape: true };+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open");+}++#[test]+fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_outside", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0)));+    let at = Open::At(pos2(300.0, 200.0));+    // The button that opened it: a press there leaves it open, so its release can toggle it shut.+    let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0));+    let hold = Hold { rects: &[opener], escape: false };+    let on_opener = opener.center();+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open");+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release");+    // A press anywhere else closes it, before the release.+    let elsewhere = pos2(900.0, 400.0);+    assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it");+}++#[test]+fn a_caller_can_put_it_where_it_likes() {+    let ctx = context();+    let mut t = 0.0;+    // Placed outright, the opening rule no longer matters.+    let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0));+    settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0)));+    assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect);+    // Nor after a drag: it stays where it was dropped.+    let at = Open::At(pos2(900.0, 100.0));+    let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);+}diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs--- a/crates/ui-egui/src/shortcuts.rs+++ b/crates/ui-egui/src/shortcuts.rs@@ -454,6 +454,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re     if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {         return true;     }+    // The colour picker open: Escape closes it here, before the binding deselects — unless one of+    // its own dropdowns is open, which takes this Escape.+    if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {+        return true;+    }     if key_box(app, key, m) {         return true;     }diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs--- a/crates/ui-egui/src/widgets/colorpicker.rs+++ b/crates/ui-egui/src/widgets/colorpicker.rs@@ -1,7 +1,7 @@ //! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders, //! hex and RGB fields, gradient stops, image and pattern fills and document colours. -use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};+use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::{     Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile, };@@ -12,7 +12,8 @@ use crate::FigApp; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon};-use crate::theme::{Tokens, doc_c32, regular, semibold};+use crate::popover::{Hold, Open, Popover};+use crate::theme::{Tokens, doc_c32, regular}; use crate::widgets;  #[derive(Clone, Debug, PartialEq)]@@ -568,6 +569,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {             finish(app);         }         app.ui.picker = None;+        app.ui.picker_popover = None;         return;     };     let t = Tokens::get(ctx);@@ -578,413 +580,427 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {         _ => None,     };     let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0));-    let mut open = true;-    let mut close_now = false;-    egui::Window::new("color_picker")-        // A fixed area id so the style picker can tell a click in here from a click outside-        // (`Window::new` alone would key the area by `Id::unique(Some(title))`).-        .id(window_id())-        .title_bar(false)-        .fixed_pos(pos)-        .fixed_size(vec2(240.0, 0.0))-        .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))-        .open(&mut open)-        .show(ctx, |ui| {-            ui.spacing_mut().item_spacing = vec2(8.0, 8.0);-            // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).-            if let Some(c) = app.session.tool.picked.take() {-                set_color(app, &target, &mut paint, c, stop_id(), ui);-            }-            // Header.-            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());-            ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);-            let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));-            let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click());-            icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon);-            if cresp.clicked() {-                close_now = true;-            }-            // Paint type.-            if !matches!(-                target,-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }-            ) {-                let kinds = [-                    (Icon::Fill, tr("Solid")),-                    (Icon::GradientLinear, tr("Linear")),-                    (Icon::GradientRadial, tr("Radial")),-                    (Icon::GradientAngular, tr("Angular")),-                    (Icon::GradientDiamond, tr("Diamond")),-                    (Icon::Image, tr("Image")),-                    (Icon::ViewGrid, tr("Pattern")),-                ];-                let active = match &paint.kind {-                    PaintKind::Solid { .. } => 0,-                    PaintKind::Gradient(g) => match g.kind {-                        GradientKind::Linear => 1,-                        GradientKind::Radial => 2,-                        GradientKind::Angular => 3,-                        GradientKind::Diamond => 4,-                    },-                    PaintKind::Image(_) => 5,-                    PaintKind::Pattern(_) => 6,-                };-                ui.horizontal(|ui| {-                    ui.add_space(12.0);-                    if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {-                        let base = paint.preview_color();-                        let mut image_pending = false;-                        paint.kind = match i {-                            0 => PaintKind::Solid { color: base.with_alpha(1.0) },-                            5 => {-                                image_pending = true;-                                let target = target.clone();-                                app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {-                                    let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };-                                    let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };-                                    if let Some(mut paint) = current_paint(app, &target) {-                                        paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;-                                        write(app, &target, &paint, false);-                                    }-                                });+    // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared+    // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the+    // style picker looks up to tell a press in here from one outside.+    let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::with_id(window_id(), tr("Custom")).pad(0.0));+    // Sampling a colour from the canvas — the eyedropper this picker's own button started, or the+    // colour it just sampled — is the press, not a press outside: neither may close the picker.+    let picking = app.session.tool.pick_only || app.session.tool.picked.is_some();+    pop = pop.close_on_outside(!picking);+    // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel+    // beside it keep it (swatches and fields there switch the colour it edits); the control that+    // opened it is held too, so pressing it again reaches its own toggle.+    let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max);+    let mut held: Vec<Rect> = vec![panel];+    held.extend(app.ui.picker_button);+    let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| {+        ui.spacing_mut().item_spacing = vec2(8.0, 8.0);+        // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).+        if let Some(c) = app.session.tool.picked.take() {+            set_color(app, &target, &mut paint, c, stop_id(), ui);+        }+        // Paint type.+        if !matches!(+            target,+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }+        ) {+            let kinds = [+                (Icon::Fill, tr("Solid")),+                (Icon::GradientLinear, tr("Linear")),+                (Icon::GradientRadial, tr("Radial")),+                (Icon::GradientAngular, tr("Angular")),+                (Icon::GradientDiamond, tr("Diamond")),+                (Icon::Image, tr("Image")),+                (Icon::ViewGrid, tr("Pattern")),+            ];+            let active = match &paint.kind {+                PaintKind::Solid { .. } => 0,+                PaintKind::Gradient(g) => match g.kind {+                    GradientKind::Linear => 1,+                    GradientKind::Radial => 2,+                    GradientKind::Angular => 3,+                    GradientKind::Diamond => 4,+                },+                PaintKind::Image(_) => 5,+                PaintKind::Pattern(_) => 6,+            };+            ui.horizontal(|ui| {+                ui.add_space(12.0);+                if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {+                    let base = paint.preview_color();+                    let mut image_pending = false;+                    paint.kind = match i {+                        0 => PaintKind::Solid { color: base.with_alpha(1.0) },+                        5 => {+                            image_pending = true;+                            let target = target.clone();+                            app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {+                                let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };+                                let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };+                                if let Some(mut paint) = current_paint(app, &target) {+                                    paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;+                                    write(app, &target, &paint, false);+                                }+                            });+                            paint.kind.clone()+                        }+                        6 => match pattern_sources(app, &target).first() {+                            Some((id, _)) => Paint::pattern(id.0).kind,+                            None => {+                                app.toast(tr("Add a layer or component to tile first").to_string());                                 paint.kind.clone()                             }-                            6 => match pattern_sources(app, &target).first() {-                                Some((id, _)) => Paint::pattern(id.0).kind,-                                None => {-                                    app.toast(tr("Add a layer or component to tile first").to_string());-                                    paint.kind.clone()-                                }-                            },-                            k => {-                                let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];-                                match &paint.kind {-                                    PaintKind::Gradient(g) => {-                                        PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })-                                    }-                                    _ => PaintKind::Gradient(Gradient::default_for(kind, base)),+                        },+                        k => {+                            let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];+                            match &paint.kind {+                                PaintKind::Gradient(g) => {+                                    PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })                                 }+                                _ => PaintKind::Gradient(Gradient::default_for(kind, base)),                             }-                        };-                        if !image_pending {-                            write(app, &target, &paint, false);                         }+                    };+                    if !image_pending {+                        write(app, &target, &paint, false);                     }-                });-            }-            // The paint's own blend mode, with the paints and layers under it.-            if !matches!(-                target,-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }-            ) {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));-                    crate::right::menu_popup(&resp).width(200.0).show(|ui| {-                        if let Some(b) = crate::right::blend_menu(ui, false) {-                            paint.blend = b;-                            write(app, &target, &paint, false);-                        }-                    });-                });-            }-            // Gradient stops.-            let mut edit_color: Option<Color> = match &paint.kind {-                PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),-                _ => None,-            };-            let sel_stop_id = stop_id();-            if let PaintKind::Gradient(g) = &paint.kind {-                let mut g = g.clone();-                let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));-                let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());-                let bar = bar.shrink2(vec2(16.0, 6.0));-                widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);-                let n = 24;-                for i in 0..n {-                    let a = i as f32 / n as f32;-                    let b = (i + 1) as f32 / n as f32;-                    let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));-                    ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));                 }-                let mut changed = false;-                let mut done = false;-                // The bar first, so the stops on it take their own clicks.-                let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());-                for (i, s) in g.stops.clone().iter().enumerate() {-                    let x = bar.min.x + s.position * bar.width();-                    let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));-                    let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());-                    ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));-                    ui.painter().rect_stroke(-                        r,-                        CornerRadius::same(3),-                        Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),-                        StrokeKind::Outside,-                    );-                    if resp.clicked() || resp.drag_started() {-                        sel = i;-                    }-                    if resp.dragged()-                        && let Some(p) = resp.interact_pointer_pos()-                        && let Some(st) = g.stops.get_mut(i)-                    {-                        st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);-                        changed = true;-                    }-                    if resp.drag_stopped() {-                        done = true;+            });+        }+        // The paint's own blend mode, with the paints and layers under it.+        if !matches!(+            target,+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }+        ) {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));+                crate::right::menu_popup(&resp).width(200.0).show(|ui| {+                    if let Some(b) = crate::right::blend_menu(ui, false) {+                        paint.blend = b;+                        write(app, &target, &paint, false);                     }+                });+            });+        }+        // Gradient stops.+        let mut edit_color: Option<Color> = match &paint.kind {+            PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),+            _ => None,+        };+        let sel_stop_id = stop_id();+        if let PaintKind::Gradient(g) = &paint.kind {+            let mut g = g.clone();+            let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));+            let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());+            let bar = bar.shrink2(vec2(16.0, 6.0));+            widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);+            let n = 24;+            for i in 0..n {+                let a = i as f32 / n as f32;+                let b = (i + 1) as f32 / n as f32;+                let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));+                ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));+            }+            let mut changed = false;+            let mut done = false;+            // The bar first, so the stops on it take their own clicks.+            let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());+            for (i, s) in g.stops.clone().iter().enumerate() {+                let x = bar.min.x + s.position * bar.width();+                let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));+                let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());+                ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));+                ui.painter().rect_stroke(+                    r,+                    CornerRadius::same(3),+                    Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),+                    StrokeKind::Outside,+                );+                if resp.clicked() || resp.drag_started() {+                    sel = i;                 }-                // Click on the bar adds a stop.-                if bresp.clicked()-                    && let Some(p) = bresp.interact_pointer_pos()+                if resp.dragged()+                    && let Some(p) = resp.interact_pointer_pos()+                    && let Some(st) = g.stops.get_mut(i)                 {-                    let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);-                    g.stops.push(ColorStop { position: pos, color: g.sample(pos) });-                    sel = g.stops.len() - 1;+                    st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);                     changed = true;+                }+                if resp.drag_stopped() {                     done = true;                 }-                ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));-                if changed {+            }+            // Click on the bar adds a stop.+            if bresp.clicked()+                && let Some(p) = bresp.interact_pointer_pos()+            {+                let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);+                g.stops.push(ColorStop { position: pos, color: g.sample(pos) });+                sel = g.stops.len() - 1;+                changed = true;+                done = true;+            }+            ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));+            if changed {+                paint.kind = PaintKind::Gradient(g.clone());+                write(app, &target, &paint, !done);+            }+            if done {+                finish(app);+            }+            edit_color = g.stops.get(sel).map(|s| s.color);+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {+                    g.stops.remove(sel);+                    ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));+                    paint.kind = PaintKind::Gradient(g.clone());+                    write(app, &target, &paint, false);+                }+                if ui.small_button(tr("Flip")).clicked() {+                    for s in &mut g.stops {+                        s.position = 1.0 - s.position;+                    }                     paint.kind = PaintKind::Gradient(g.clone());-                    write(app, &target, &paint, !done);+                    write(app, &target, &paint, false);                 }-                if done {-                    finish(app);+                if ui.small_button(tr("Rotate 90°")).clicked() {+                    let (fx, fy) = g.from;+                    let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));+                    g.to = rot(g.to);+                    g.width = rot(g.width);+                    paint.kind = PaintKind::Gradient(g.clone());+                    write(app, &target, &paint, false);                 }-                edit_color = g.stops.get(sel).map(|s| s.color);+            });+            // The selected stop's colour can come from a colour variable.+            if let PickerTarget::Paint { fills, index, ids } = &target {+                let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });+                let stop = g.stops.get(sel).map(|s| s.color);                 ui.horizontal(|ui| {                     ui.add_space(16.0);-                    if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {-                        g.stops.remove(sel);-                        ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);-                    }-                    if ui.small_button(tr("Flip")).clicked() {-                        for s in &mut g.stops {-                            s.position = 1.0 - s.position;+                    crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {+                        let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());+                        ui.painter().rect_filled(r, CornerRadius::same(5), t.field);+                        let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));+                        if let Some(c) = stop {+                            widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));+                            let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);+                            ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);+                        }+                    });+                });+            }+        }+        if let PaintKind::Image(img) = &paint.kind {+            let mut img = img.clone();+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);+                crate::right::menu_popup(&resp).show(|ui| {+                    for (l, m) in [+                        (tr("Fill"), ImageScaleMode::Fill),+                        (tr("Fit"), ImageScaleMode::Fit),+                        (tr("Crop"), ImageScaleMode::Crop),+                        (tr("Tile"), ImageScaleMode::Tile),+                    ] {+                        if crate::right::menu_item(ui, l, "") {+                            img.scale_mode = m;+                            paint.kind = PaintKind::Image(img.clone());+                            write(app, &target, &paint, false);+                            ui.close();                         }-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);-                    }-                    if ui.small_button(tr("Rotate 90°")).clicked() {-                        let (fx, fy) = g.from;-                        let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));-                        g.to = rot(g.to);-                        g.width = rot(g.width);-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);                     }                 });-                // The selected stop's colour can come from a colour variable.-                if let PickerTarget::Paint { fills, index, ids } = &target {-                    let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });-                    let stop = g.stops.get(sel).map(|s| s.color);-                    ui.horizontal(|ui| {-                        ui.add_space(16.0);-                        crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {-                            let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());-                            ui.painter().rect_filled(r, CornerRadius::same(5), t.field);-                            let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));-                            if let Some(c) = stop {-                                widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));-                                let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);-                                ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);-                            }-                        });-                    });+                if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {+                    img.rotation = (img.rotation + 1) % 4;+                    paint.kind = PaintKind::Image(img.clone());+                    write(app, &target, &paint, false);                 }-            }-            if let PaintKind::Image(img) = &paint.kind {-                let mut img = img.clone();+            });+            // The image's own colour profile (decoding converts it into the file's).+            let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));+            let source = match source.as_deref() {+                None => tr("Unknown — treated as sRGB"),+                Some("") => tr("Embedded profile"),+                Some(name) => name,+            };+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let text = trf("Color profile: {name}", &[("name", &source)]);+                ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());+            });+            image_presets(app, ui, &target, &mut paint, img.filters);+            for (label, val) in [+                (tr_noop("Exposure"), img.filters.exposure),+                (tr_noop("Contrast"), img.filters.contrast),+                (tr_noop("Saturation"), img.filters.saturation),+                (tr_noop("Temperature"), img.filters.temperature),+                (tr_noop("Tint"), img.filters.tint),+                (tr_noop("Highlights"), img.filters.highlights),+                (tr_noop("Shadows"), img.filters.shadows),+            ] {                 ui.horizontal(|ui| {                     ui.add_space(16.0);-                    let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);-                    crate::right::menu_popup(&resp).show(|ui| {-                        for (l, m) in [-                            (tr("Fill"), ImageScaleMode::Fill),-                            (tr("Fit"), ImageScaleMode::Fit),-                            (tr("Crop"), ImageScaleMode::Crop),-                            (tr("Tile"), ImageScaleMode::Tile),-                        ] {-                            if crate::right::menu_item(ui, l, "") {-                                img.scale_mode = m;-                                paint.kind = PaintKind::Image(img.clone());-                                write(app, &target, &paint, false);-                                ui.close();-                            }+                    // Labels share a column so the sliders line up.+                    let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());+                    ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);+                    let mut v = val;+                    let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));+                    if r.changed() {+                        let f = &mut img.filters;+                        match label {+                            "Exposure" => f.exposure = v,+                            "Contrast" => f.contrast = v,+                            "Saturation" => f.saturation = v,+                            "Temperature" => f.temperature = v,+                            "Tint" => f.tint = v,+                            "Highlights" => f.highlights = v,+                            _ => f.shadows = v,                         }-                    });-                    if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {-                        img.rotation = (img.rotation + 1) % 4;                         paint.kind = PaintKind::Image(img.clone());-                        write(app, &target, &paint, false);+                        write(app, &target, &paint, !r.drag_stopped());+                    }+                    if r.drag_stopped() {+                        finish(app);                     }                 });-                // The image's own colour profile (decoding converts it into the file's).-                let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));-                let source = match source.as_deref() {-                    None => tr("Unknown — treated as sRGB"),-                    Some("") => tr("Embedded profile"),-                    Some(name) => name,-                };-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    let text = trf("Color profile: {name}", &[("name", &source)]);-                    ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());-                });-                image_presets(app, ui, &target, &mut paint, img.filters);-                for (label, val) in [-                    (tr_noop("Exposure"), img.filters.exposure),-                    (tr_noop("Contrast"), img.filters.contrast),-                    (tr_noop("Saturation"), img.filters.saturation),-                    (tr_noop("Temperature"), img.filters.temperature),-                    (tr_noop("Tint"), img.filters.tint),-                    (tr_noop("Highlights"), img.filters.highlights),-                    (tr_noop("Shadows"), img.filters.shadows),-                ] {-                    ui.horizontal(|ui| {-                        ui.add_space(16.0);-                        // Labels share a column so the sliders line up.-                        let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());-                        ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);-                        let mut v = val;-                        let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));-                        if r.changed() {-                            let f = &mut img.filters;-                            match label {-                                "Exposure" => f.exposure = v,-                                "Contrast" => f.contrast = v,-                                "Saturation" => f.saturation = v,-                                "Temperature" => f.temperature = v,-                                "Tint" => f.tint = v,-                                "Highlights" => f.highlights = v,-                                _ => f.shadows = v,-                            }-                            paint.kind = PaintKind::Image(img.clone());-                            write(app, &target, &paint, !r.drag_stopped());-                        }-                        if r.drag_stopped() {-                            finish(app);-                        }-                    });-                }             }-            if let PaintKind::Pattern(pt) = paint.kind.clone() {-                pattern_controls(app, ui, &target, &mut paint, pt);+        }+        if let PaintKind::Pattern(pt) = paint.kind.clone() {+            pattern_controls(app, ui, &target, &mut paint, pt);+        }+        if let Some(color) = edit_color {+            let mut hsb = ui+                .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))+                .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())+                .map(|(h, s, b, _)| Hsb { h, s, b })+                .unwrap_or_else(|| Hsb::from_color(color));+            let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());+            let (c1, d1) = sv_square(ui, sq, &mut hsb);+            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());+            let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));+            if icons::button(+                &mut ui+                    .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))),+                Icon::Eyedropper,+                24.0,+                false,+                tr("Pick color (I)"),+            )+            .clicked()+            {+                app.run("edit.pickColor", json!({"apply": false}));+                return;             }-            if let Some(color) = edit_color {-                let mut hsb = ui-                    .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb")))-                    .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())-                    .map(|(h, s, b, _)| Hsb { h, s, b })-                    .unwrap_or_else(|| Hsb::from_color(color));-                let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());-                let (c1, d1) = sv_square(ui, sq, &mut hsb);-                let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());-                let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));-                if icons::button(-                    &mut ui.new_child(-                        egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))),-                    ),-                    Icon::Eyedropper,-                    24.0,-                    false,-                    tr("Pick color (I)"),-                )-                .clicked()-                {-                    app.run("edit.pickColor", json!({"apply": false}));-                    return;-                }-                let (c2, d2) = hue_slider(ui, hr, &mut hsb);-                let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());-                let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));-                let mut new_alpha = None;-                if let Some((a, d)) = alpha_slider(ui, ar, color) {-                    new_alpha = Some((a, d));-                }-                let mut nc = hsb.to_color().with_alpha(color.a);-                let mut done = d1 || d2;-                let mut changed = c1 || c2;-                if let Some((a, d)) = new_alpha {-                    nc = nc.with_alpha(a);-                    changed = true;-                    done |= d;-                }-                // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.-                if let Some((c, d)) = model_fields(ui, nc) {-                    nc = c;-                    hsb = Hsb::from_color(nc);-                    changed = true;-                    done |= d;-                }-                ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));-                // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.-                if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {-                    gamut_warning(ui, &t);-                }-                if changed {-                    match &mut paint.kind {-                        PaintKind::Solid { color } => {-                            *color = nc.with_alpha(1.0);-                            paint.opacity = nc.a;-                        }-                        PaintKind::Gradient(g) => {-                            let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);-                            if let Some(s) = g.stops.get_mut(sel) {-                                s.color = nc;-                            }+            let (c2, d2) = hue_slider(ui, hr, &mut hsb);+            let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());+            let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));+            let mut new_alpha = None;+            if let Some((a, d)) = alpha_slider(ui, ar, color) {+                new_alpha = Some((a, d));+            }+            let mut nc = hsb.to_color().with_alpha(color.a);+            let mut done = d1 || d2;+            let mut changed = c1 || c2;+            if let Some((a, d)) = new_alpha {+                nc = nc.with_alpha(a);+                changed = true;+                done |= d;+            }+            // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.+            if let Some((c, d)) = model_fields(ui, nc) {+                nc = c;+                hsb = Hsb::from_color(nc);+                changed = true;+                done |= d;+            }+            ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));+            // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.+            if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {+                gamut_warning(ui, &t);+            }+            if changed {+                match &mut paint.kind {+                    PaintKind::Solid { color } => {+                        *color = nc.with_alpha(1.0);+                        paint.opacity = nc.a;+                    }+                    PaintKind::Gradient(g) => {+                        let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);+                        if let Some(s) = g.stops.get_mut(sel) {+                            s.color = nc;                         }-                        PaintKind::Image(_) | PaintKind::Pattern(_) => {}                     }-                    write(app, &target, &paint, !done);-                }-                if done {-                    finish(app);+                    PaintKind::Image(_) | PaintKind::Pattern(_) => {}                 }+                write(app, &target, &paint, !done);             }-            // Colours used on this page.-            let doc_colors = page_colors(app, ui);-            if !doc_colors.is_empty() {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    widgets::label(ui, tr("On this page"), t.text_secondary);-                });-                // Inset on both sides: `add_space` would only indent the first row of a wrap.-                inset(ui, |ui| {-                    for c in &doc_colors {-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);-                        }-                    }-                });+            if done {+                finish(app);             }-            // Recent colours.-            let recent = app.session.recent_colors.clone();-            if !recent.is_empty() {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    widgets::label(ui, tr("Recent"), t.text_secondary);-                });-                inset(ui, |ui| {-                    for c in recent.iter().take(14) {-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);-                        }+        }+        // Colours used on this page.+        let doc_colors = page_colors(app, ui);+        if !doc_colors.is_empty() {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                widgets::label(ui, tr("On this page"), t.text_secondary);+            });+            // Inset on both sides: `add_space` would only indent the first row of a wrap.+            inset(ui, |ui| {+                for c in &doc_colors {+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);                     }-                });-            }-            ui.add_space(12.0);-        });-    if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {-        finish(app);-        app.ui.picker = None;+                }+            });+        }+        // Recent colours.+        let recent = app.session.recent_colors.clone();+        if !recent.is_empty() {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                widgets::label(ui, tr("Recent"), t.text_secondary);+            });+            inset(ui, |ui| {+                for c in recent.iter().take(14) {+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);+                    }+                }+            });+        }+        ui.add_space(12.0);+    });+    if closed {+        close(app);+    } else if app.ui.picker.is_some() {+        // Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave+        // a stale place for the next picker to inherit.+        app.ui.picker_popover = Some(pop);     }-    let _ = Pos2::ZERO;+}++/// Escape with the picker open closes it (the selection stays); false when it was not open, so the+/// key goes on to the binding that deselects.+pub fn close_if_open(app: &mut FigApp) -> bool {+    if app.ui.picker.is_none() {+        return false;+    }+    close(app);+    true+}++/// The colour picker closes (its X, a press outside it, Escape, or another popover took over):+/// pending edits commit and the picker with its popover go.+pub fn close(app: &mut FigApp) {+    finish(app);+    app.ui.picker = None;+    app.ui.picker_popover = None;+    app.ui.picker_button = None; }  /// Swatches wrapped in rows inset 16 points from both sides of the picker.@@ -1282,7 +1298,7 @@ mod selection_color_tests {         s.execute("select.set", &json!({"ids":[a,b]})).unwrap();         let mut app = FigApp::new(s, Box::new(NoServices));         let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();-        crate::selection_colors::open_picker(&mut app, targets);+        crate::selection_colors::toggle(&mut app, targets, None);         (app, a, b)     } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs--- a/crates/ui-egui/src/widgets/style_picker.rs+++ b/crates/ui-egui/src/widgets/style_picker.rs@@ -19,11 +19,13 @@ use crate::a11y::Tip; use crate::colorpicker::PickerTarget; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon};+use crate::popover::{Hold, Open, Popover, Surface}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets; -/// Content width of the popover.-const W: f32 = 240.0;+/// The slot's margin inside the shared popover, and the content width left of it.+const PAD: f32 = 8.0;+const W: f32 = 224.0; /// Height of a list row. const ROW_H: f32 = 26.0; /// Height of a text style's list row (its name in its own font).@@ -167,6 +169,9 @@ impl Picker { /// Open the picker for `slot` of `ids`, level with the pointer. pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);+    // Opening the style picker closes the colour picker (it would linger beside it).+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None }); } @@ -174,6 +179,8 @@ pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) { pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) {     let ids = app.session.selection();     let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing));+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit }); } @@ -516,9 +523,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut     let link = link_of(app, doc, &p.ids, slot);     let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id));     let grid = slot.has_grid() && app.ui.style_grid;-    // Header: title, then view toggle, detach and create.+    // The title is the popover's; this row keeps the view toggle, detach and create.     let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());-    ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text);     let mut x = hr.max.x - 24.0;     let mut slot_rect = || {         let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0));@@ -919,7 +925,6 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, /// Editing one style: name, description, its values (paints open the colour picker; text styles /// have their typography), update from the selection, delete. fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {-    let t = Tokens::get(ui.ctx());     let slot = p.slot;     let has_selection = !p.ids.is_empty();     let Some(e) = p.edit.as_mut() else { return };@@ -931,7 +936,6 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {     if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() {         out.act = Some(Act::Back);     }-    ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text);     ui.add_space(6.0);     caption(ui, tr("Name"));     let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));@@ -1020,9 +1024,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {         if let Some(e) = &p.edit {             commit_edit(app, &doc, e);         }+        app.ui.style_picker_popover = None;         return;     }-    let t = Tokens::get(ctx);     let screen = ctx.content_rect();     // Left of the Design panel; further left while the colour picker (which sits there) is open.     let mut x = screen.max.x - app.ui.right_width - W - 32.0;@@ -1031,34 +1035,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {     }     let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));     let mut out = Frame { act: None, hover: None, budget: 4 };-    let area = egui::Area::new(Id::unique("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {-        egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {-            ui.set_width(W);-            if p.edit.is_some() {-                editor(ui, &doc, &mut p, &mut out);-            } else {-                listing(app, ui, &doc, &mut p, &mut out);-            }-        });-    });-    let rect = area.response.rect;-    ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("style_picker_border"))).rect_stroke(-        rect,-        CornerRadius::same(10),-        Stroke::new(1.0, Color32::from_black_alpha(40)),-        egui::StrokeKind::Inside,-    );-    // Clicks in the colour picker editing this picker's style, or in a menu the editor opened,-    // keep it open.+    // The title and the X are the shared popover's now; the list keeps its tools row.+    let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() };+    let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu));+    pop.title = title.to_string();+    // Clicks in the colour picker editing this picker's style, in the variable picker binding one+    // of its fields, or in a menu the editor opened, keep it open.     let picker_rect =         app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())));-    // So do clicks in the variable picker binding one of its fields (Escape closes that first).     let variables = crate::variable_ui::picker_rect(ctx);-    let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some();-    let outside = ctx.input(|i| {-        i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos)))-    }) && !in_popup;-    let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup);+    let holds: Vec<Rect> = picker_rect.into_iter().chain(variables).collect();+    let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| {+        if p.edit.is_some() {+            editor(ui, &doc, &mut p, &mut out);+        } else {+            listing(app, ui, &doc, &mut p, &mut out);+        }+    });+    let mut close = closed;     let ids = p.ids.clone();     let slot = p.slot;     // The canvas shows the text style under the pointer on the selected text.@@ -1131,8 +1125,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {         if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) {             app.ui.picker = None;         }+        app.ui.style_picker_popover = None;     } else {         app.ui.style_picker = Some(p);+        app.ui.style_picker_popover = Some(pop);     } } @@ -1140,6 +1136,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn edit_local(app: &mut FigApp, ui: &Ui, slot: StyleSlot, id: &str) {     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);     let edit = app.session.doc().ok().and_then(|d| d.style(id).map(editing));+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids: app.session.selection(), y, query: String::new(), edit }); } diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs--- a/crates/ui-egui/src/widgets/style_picker/tests.rs+++ b/crates/ui-egui/src/widgets/style_picker/tests.rs@@ -323,17 +323,10 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {     u } -/// The left edge of the style editor's content (its "Edit style" title starts 30 px in).+/// The left edge of the style editor's content: the shared popover's slot, inside its margin. fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 {-    let mut left = None;-    for c in u.frame_output(vec![]).shapes {-        if let egui::Shape::Text(t) = &c.shape-            && t.galley.text() == "Edit style"-        {-            left = Some(t.pos.x - 30.0);-        }-    }-    left.expect("the editor's title")+    u.frame_output(vec![]);+    u.app.ui.style_picker_popover.as_ref().expect("the editor's popover").slot.min.x }  /// The texts drawn in one frame, with their centers.