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
@@ -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.@@ -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 .shapes@@ -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,@@ -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");+}@@ -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)@@ -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)@@ -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));@@ -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));new file mode 100644@@ -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)+}new file mode 100644@@ -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);+}@@ -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; }@@ -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) } @@ -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 }); } @@ -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.