From e9a0c437e2d1b72433d82980f48a40445a0d19e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:12:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?M4:=20a=20generic=20popover=20=E2=80=94=20a?= =?UTF-8?q?=20240=20pt=20card=20with=20a=2040=20pt=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chrome detached panels share, in one place: a 240 pt card with a 40 pt header — the title 16 in from its left edge, a 24 pt X 8 in from its right — a hairline under the header and the theme's popup shadow, around one slot: let mut p = Popover::new("my_popover", tr("Title")); let closed = p.show(ctx, Open::Anchor(pos), Hold::default(), |ui| { /* the slot */ }); The measurements are the ones the colour picker's header already used, which this widget now draws; the card is as wide as the picker was. The shadow is the theme's (`visuals.popup_shadow`), so the dark theme gets its own. Closing is the widget's: the X, Escape, and a press outside it — except in the rects the caller holds out (`Hold { rects, escape }`: 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 (the bug the fill-rule panel has). Press-and-release tests pin all of it. It opens against an anchor (its right edge on the anchor, top on it, lifted when that would run off the screen's bottom) or at a point; the header drags it. Six headless tests: the header's geometry, the opening rule, the lift, the drag, the X, Escape, and the press-and-release of a press on the opener (open) and elsewhere (closed). --- crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/popover.rs | 186 ++++++++++++++++++++++++++++ crates/ui-egui/src/popover_tests.rs | 144 +++++++++++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 crates/ui-egui/src/popover.rs create mode 100644 crates/ui-egui/src/popover_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b4..fdb209e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,7 @@ pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; +pub mod popover; pub mod present; pub mod proto_canvas; pub mod proto_editor; @@ -174,6 +175,8 @@ mod plugin_panel_tests; #[cfg(test)] mod plugins_browse_tests; #[cfg(test)] +mod popover_tests; +#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_links_tests; diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs new file mode 100644 index 00000000..b6a56d76 --- /dev/null +++ b/crates/ui-egui/src/popover.rs @@ -0,0 +1,186 @@ +//! The chrome every detached panel shares: a 15rem-wide card with a 40-point header — the title 16 +//! in from its left edge, the X 8 in from its right — a hairline under the header and the +//! three-layer shadow of the design spec, around one slot. +//! +//! It opens against an anchor: its right edge on the anchor's x (the left edge of the right-hand +//! panel), its top on the anchor's y (the top of the button that opened it) — lifted, when that +//! would run its bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The +//! header drags it anywhere else, and a caller can put it down outright with [`Popover::at`]. + +use egui::epaint::RectShape; +use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Shadow, Ui, Vec2, pos2, vec2}; + +use crate::i18n::tr; +use crate::icons::Icon; +use crate::theme::{Tokens, semibold}; +use crate::widgets; + +/// 15rem: the width of every popover. +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 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 spec's three shadows — `0 2px 5px rgba(0,0,0,.15)`, `0 10px 16px rgba(0,0,0,.12)`, the +/// `0 0 .5px rgba(0,0,0,.12)` hairline — painted in reverse: the first of them ends up on top. +const SHADOW_NEAR: Shadow = Shadow { offset: [0, 2], blur: 5, spread: 0, color: Color32::from_black_alpha(38) }; +const SHADOW_FAR: Shadow = Shadow { offset: [0, 10], blur: 16, spread: 0, color: Color32::from_black_alpha(31) }; +const SHADOW_EDGE: Shadow = Shadow { offset: [0, 0], blur: 1, spread: 0, color: Color32::from_black_alpha(31) }; + +/// Where the popover shows. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Place { + /// Against the anchor, until the header drags it (the rule on opening). + Anchor, + /// 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, + place: Place, + /// While the header is held down: where in the panel the pointer grabbed it. + grab: Option, + /// The panel, its header, its slot and the X, as drawn last frame (0-sized until it has been). + 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`, opening against an anchor (see [`Self::show`]). + pub fn new(id: &'static str, title: impl Into) -> Self { + Self { + id: Id::new(id), + title: title.into(), + place: Place::Anchor, + grab: None, + rect: Rect::NOTHING, + header: Rect::NOTHING, + slot: Rect::NOTHING, + close: Rect::NOTHING, + size: vec2(WIDTH, 0.0), + } + } + + /// Its top-left at `pos`, instead of against the anchor. + 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 the X was clicked. + /// + /// Opened against `anchor` — its right edge on `anchor.x` (the right panel's left edge), its top + /// on `anchor.y` (the top of the button that opened it) — it is lifted when that would run its + /// bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The header + /// drags it; [`Self::at`] and the drag both overrule the anchor. + pub fn show(&mut self, ctx: &Context, anchor: Pos2, body: impl FnOnce(&mut Ui)) -> bool { + let t = Tokens::get(ctx); + let screen = ctx.content_rect(); + // The 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::Anchor => clamp(open_pos(anchor, self.size, screen), self.size, screen), + }; + let mut closed = 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 shadows 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(WIDTH, HEADER_H), Sense::hover()); + let close = 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.min.x - 6.0, header.max.y)); + let drag = ui.interact(handle, self.id.with("drag"), Sense::drag()); + let font = semibold(11.5); + 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, t.text); + closed = widgets::icon_button_at(ui, self.id.with("close"), close, Icon::Close, tr("Close")); + // 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(SLOT_PAD as i8)).show(ui, |ui| { + ui.spacing_mut().item_spacing = spacing; + ui.set_width(WIDTH - 2.0 * SLOT_PAD); + body(ui); + ui.min_rect() + }); + let slot = frame.inner; + let panel = Rect::from_min_size(header.min, vec2(WIDTH, frame.response.rect.max.y - header.min.y)); + let shade = |s: Shadow| egui::Shape::from(s.as_shape(panel, RADIUS)); + // `inset 0 -1px #E6E6E6` under the header: the theme's border colour, a point thick. + let hairline = Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)); + let shapes = vec![ + shade(SHADOW_EDGE), + shade(SHADOW_FAR), + shade(SHADOW_NEAR), + RectShape::filled(panel, CornerRadius::same(RADIUS), t.panel).into(), + RectShape::filled(hairline, CornerRadius::ZERO, t.border).into(), + ]; + ui.painter().set(bg, egui::Shape::Vec(shapes)); + self.rect = panel; + self.header = header; + self.slot = slot; + self.close = close; + self.size = panel.size(); + }); + closed + } +} + +/// 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.rs new file mode 100644 index 00000000..80d067d2 --- /dev/null +++ b/crates/ui-egui/src/popover_tests.rs @@ -0,0 +1,144 @@ +//! The generic popover drawn on its own: 15rem 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) and the +//! header dragging the panel. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; + +use crate::popover::{HEADER_H, 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) -> 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 { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +/// One frame of `p` against `anchor`, its slot holding a row: the shapes it painted and whether +/// the X was clicked. +fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: Vec) -> (Vec, bool) { + let mut closed = false; + let mut out = ctx.run_ui(input(t, events), |ui| { + closed = p.show(ui.ctx(), anchor, |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, anchor: Pos2) -> Vec { + draw(ctx, t, p, anchor, vec![]); + draw(ctx, t, p, anchor, 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", "Fill rule"); + let shapes = settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + // 15rem wide, its top-left where the rule puts it. + assert_eq!(p.rect.width(), WIDTH, "15rem wide"); + assert!(near(p.rect.min, pos2(400.0 - WIDTH, 100.0)), "right edge on the anchor, top on it: {:?}", 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 == "Fill rule").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, 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, 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, pos2(400.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, pos2(400.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), 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, pos2(400.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min); +} + +#[test] +fn the_x_closes_it() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_close", "Popup"); + settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); + let at = p.close.center(); + assert!(!draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, true)).1, "a press is not a click yet"); + assert!(draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, false)).1, "the X asks for the close"); +} + +#[test] +fn a_caller_can_put_it_where_it_likes() { + let ctx = context(); + let mut t = 0.0; + // Placed outright, the anchor no longer matters. + let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0)); + settle(&ctx, &mut t, &mut p, 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 (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(from, true)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(to, false)); + draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![]); + assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect); +} From 345a07fb8f295288b01e2f9ed2809a75cb560fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:15:18 +0800 Subject: [PATCH 2/4] M4: the popover every detached panel shares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Popover` is the one card detached panels draw on: a header 40 points high with the title 16 in from its left edge and a 24-point X 8 in from its right, a hairline under it, the theme's popup shadow, and one slot. Its measurements are our own — the colour picker's header, which it now uses — not another product's. The colour picker and the style picker move onto it in this commit; each keeps only its own body. The style picker keeps its dark surface through `Surface::Menu`, and its tools row (view, detach, create) now starts the slot, under the shared title. Closing is the widget's now: the X, Escape, and 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 (the bug #730 has). The colour picker keeps its old habit of staying open while the panel beside it is used. Tests: press-and-release for the X, for a press on the opener (open) and for a press elsewhere (closed), plus Escape, the opening rule, the lift and the drag. --- crates/ui-egui/src/colorpicker.rs | 725 ++++++++++++++-------------- crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/popover.rs | 199 +++++--- crates/ui-egui/src/popover_tests.rs | 115 +++-- crates/ui-egui/src/style_picker.rs | 54 +-- 5 files changed, 603 insertions(+), 496 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index f4a27c5e..07fff995 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/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)] @@ -451,6 +452,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); @@ -461,407 +463,394 @@ 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") - .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); - // 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::new("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 chrome now; the picker keeps its body. + let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0).close_on_outside(false)); + let closed = pop.show(ctx, Open::At(pos), Hold::default(), |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // 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); } - } - }); - } - // 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 = 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::new("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::new(("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; + }; + if !image_pending { + write(app, &target, &paint, false); } } - // Click on the bar adds a stop. - if bresp.clicked() - && let Some(p) = bresp.interact_pointer_pos() + }); + } + // 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 = 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::new("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::new(("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) { - 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, !done); + write(app, &target, &paint, false); } - if done { - finish(app); + 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, false); } - edit_color = g.stops.get(sel).map(|s| s.color); + 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); - 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); } - 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 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(); - } - } - }); - 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); - } - }); - // 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, - } + } + 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, !r.drag_stopped()); + write(app, &target, &paint, false); + ui.close(); } - if r.drag_stopped() { - finish(app); - } - }); - } - } - 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::new("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.ui.picker = None; - app.run("edit.pickColor", json!({})); - 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 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::new("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 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 done { - finish(app); - } - } - // Colours used on this page. - let doc_colors = page_colors(app, ui); - if !doc_colors.is_empty() { + }); + // 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); - 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); + // 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); } }); } - // 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); + } + 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::new("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.ui.picker = None; + app.run("edit.pickColor", json!({})); + 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 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::new("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); } - ui.add_space(12.0); - }); - if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if done { + finish(app); + } + } + // 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); + } + } + }); + } + // 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 { finish(app); app.ui.picker = None; + app.ui.picker_popover = None; + } else { + app.ui.picker_popover = Some(pop); } - let _ = Pos2::ZERO; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fdb209e3..84c85a80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,8 @@ pub struct UiState { /// The prototype preview pane floating over the canvas, when open. pub preview: Option, pub picker: Option, + /// The colour picker's popover while it is open (its place survives a drag). + pub picker_popover: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// 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. @@ -529,6 +531,8 @@ pub struct UiState { pub color_profile: Option, /// The open style picker. pub style_picker: Option, + /// The style picker's popover while it is open (its place survives a drag). + pub style_picker_popover: Option, /// The style picker shows colour styles as a grid of swatches. pub style_grid: bool, /// Style folders closed in the style picker. @@ -630,6 +634,7 @@ impl Default for UiState { present_view: present::PresentView::default(), preview: None, picker: None, + picker_popover: None, context_menu: None, key_menu: false, key_box: None, @@ -655,6 +660,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/popover.rs b/crates/ui-egui/src/popover.rs index b6a56d76..97652c39 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -1,21 +1,27 @@ -//! The chrome every detached panel shares: a 15rem-wide card with a 40-point header — the title 16 -//! in from its left edge, the X 8 in from its right — a hairline under the header and the -//! three-layer shadow of the design spec, around one slot. +//! 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. //! -//! It opens against an anchor: its right edge on the anchor's x (the left edge of the right-hand -//! panel), its top on the anchor's y (the top of the button that opened it) — lifted, when that -//! would run its bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The -//! header drags it anywhere else, and a caller can put it down outright with [`Popover::at`]. +//! 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, Shadow, Ui, Vec2, pos2, vec2}; +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::Icon; +use crate::icons::{self, Icon}; use crate::theme::{Tokens, semibold}; use crate::widgets; -/// 15rem: the width of every popover. +/// 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; @@ -24,7 +30,7 @@ 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 margin inside the panel. +/// 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; @@ -32,17 +38,37 @@ const EDGE: f32 = 8.0; /// The panel's corners. const RADIUS: u8 = 13; -/// The spec's three shadows — `0 2px 5px rgba(0,0,0,.15)`, `0 10px 16px rgba(0,0,0,.12)`, the -/// `0 0 .5px rgba(0,0,0,.12)` hairline — painted in reverse: the first of them ends up on top. -const SHADOW_NEAR: Shadow = Shadow { offset: [0, 2], blur: 5, spread: 0, color: Color32::from_black_alpha(38) }; -const SHADOW_FAR: Shadow = Shadow { offset: [0, 10], blur: 16, spread: 0, color: Color32::from_black_alpha(31) }; -const SHADOW_EDGE: Shadow = Shadow { offset: [0, 0], blur: 1, spread: 0, color: Color32::from_black_alpha(31) }; +/// 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 { - /// Against the anchor, until the header drags it (the rule on opening). - Anchor, + /// From [`Open`], until the header drags it. + Auto, /// Here: put down by the caller or by the drag. At(Pos2), } @@ -52,10 +78,15 @@ 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, - /// The panel, its header, its slot and the X, as drawn last frame (0-sized until it has been). + /// The panel, its header, its slot and the X, as drawn last frame. pub rect: Rect, pub header: Rect, pub slot: Rect, @@ -65,12 +96,16 @@ pub struct Popover { } impl Popover { - /// A popover titled `title`, opening against an anchor (see [`Self::show`]). - pub fn new(id: &'static str, title: impl Into) -> Self { + /// 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) -> Self { Self { id: Id::new(id), title: title.into(), - place: Place::Anchor, + width: WIDTH, + pad: SLOT_PAD, + surface: Surface::Panel, + outside: true, + place: Place::Auto, grab: None, rect: Rect::NOTHING, header: Rect::NOTHING, @@ -80,48 +115,77 @@ impl Popover { } } - /// Its top-left at `pos`, instead of against the anchor. + /// `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 the X was clicked. - /// - /// Opened against `anchor` — its right edge on `anchor.x` (the right panel's left edge), its top - /// on `anchor.y` (the top of the button that opened it) — it is lifted when that would run its - /// bottom past the screen's bottom, so its bottom keeps [`SCREEN_GAP`] from it. The header - /// drags it; [`Self::at`] and the drag both overrule the anchor. - pub fn show(&mut self, ctx: &Context, anchor: Pos2, body: impl FnOnce(&mut Ui)) -> bool { + /// 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 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. + // 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::Anchor => clamp(open_pos(anchor, self.size, screen), 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 closed = false; + 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 shadows and the fill + // 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(WIDTH, HEADER_H), Sense::hover()); - let close = 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.min.x - 6.0, header.max.y)); + 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.5); + 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, t.text); - closed = widgets::icon_button_at(ui, self.id.with("close"), close, Icon::Close, tr("Close")); + 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()) { @@ -139,35 +203,62 @@ impl Popover { 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(SLOT_PAD as i8)).show(ui, |ui| { + 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(WIDTH - 2.0 * SLOT_PAD); + 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(WIDTH, frame.response.rect.max.y - header.min.y)); - let shade = |s: Shadow| egui::Shape::from(s.as_shape(panel, RADIUS)); - // `inset 0 -1px #E6E6E6` under the header: the theme's border colour, a point thick. - let hairline = Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)); + 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![ - shade(SHADOW_EDGE), - shade(SHADOW_FAR), - shade(SHADOW_NEAR), - RectShape::filled(panel, CornerRadius::same(RADIUS), t.panel).into(), - RectShape::filled(hairline, CornerRadius::ZERO, t.border).into(), + 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; + self.close = close_rect; self.size = panel.size(); }); - closed + // 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. diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs index 80d067d2..713e8d39 100644 --- a/crates/ui-egui/src/popover_tests.rs +++ b/crates/ui-egui/src/popover_tests.rs @@ -1,11 +1,12 @@ -//! The generic popover drawn on its own: 15rem 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) and the -//! header dragging the panel. +//! 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, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; -use crate::popover::{HEADER_H, Popover, WIDTH}; +use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH}; const SCREEN: egui::Vec2 = vec2(1440.0, 900.0); @@ -25,12 +26,16 @@ fn press(p: Pos2, pressed: bool) -> Vec { vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] } -/// One frame of `p` against `anchor`, its slot holding a row: the shapes it painted and whether -/// the X was clicked. -fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: Vec) -> (Vec, bool) { +fn escape() -> Vec { + 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) -> (Vec, bool) { let mut closed = false; let mut out = ctx.run_ui(input(t, events), |ui| { - closed = p.show(ui.ctx(), anchor, |ui| { + closed = p.show(ui.ctx(), open, hold, |ui| { ui.label("slot"); }); }); @@ -39,9 +44,9 @@ fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, anchor: Pos2, events: } /// 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, anchor: Pos2) -> Vec { - draw(ctx, t, p, anchor, vec![]); - draw(ctx, t, p, anchor, vec![]).0 +fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec { + draw(ctx, t, p, open, Hold::default(), vec![]); + draw(ctx, t, p, open, Hold::default(), vec![]).0 } fn near(a: Pos2, b: Pos2) -> bool { @@ -52,17 +57,16 @@ fn near(a: Pos2, b: Pos2) -> bool { 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", "Fill rule"); - let shapes = settle(&ctx, &mut t, &mut p, pos2(400.0, 100.0)); - // 15rem wide, its top-left where the rule puts it. - assert_eq!(p.rect.width(), WIDTH, "15rem wide"); - assert!(near(p.rect.min, pos2(400.0 - WIDTH, 100.0)), "right edge on the anchor, top on it: {:?}", p.rect); + 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 == "Fill rule").expect("the title shows"); + 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. @@ -75,7 +79,7 @@ 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, pos2(900.0, 200.0)); + 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); } @@ -85,7 +89,7 @@ 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, pos2(900.0, 860.0)); + 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); @@ -96,49 +100,76 @@ 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, pos2(400.0, 100.0)); + 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, pos2(400.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + 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, pos2(400.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), vec![]); + 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_closes_it() { +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, pos2(400.0, 100.0)); - let at = p.close.center(); - assert!(!draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, true)).1, "a press is not a click yet"); - assert!(draw(&ctx, &mut t, &mut p, pos2(400.0, 100.0), press(at, false)).1, "the X asks for the close"); + 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 anchor no longer matters. + // 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, pos2(900.0, 100.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, pos2(900.0, 100.0), press(from, true)); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![Event::PointerMoved(to)]); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), press(to, false)); - draw(&ctx, &mut t, &mut p, pos2(900.0, 100.0), vec![]); + 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/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 966ea482..18a253cf 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/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). @@ -516,9 +518,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)); @@ -777,7 +778,6 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { /// 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 }; @@ -789,7 +789,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)); @@ -880,7 +879,6 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } 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; @@ -889,34 +887,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::new("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::new("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(Id::new("color_picker")))); - // 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 = picker_rect.into_iter().chain(variables).collect(); + let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.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. @@ -989,8 +977,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); } } From 3862a2db885616febbe6c5f901c116445a3b6e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:15:52 +0800 Subject: [PATCH 3/4] M4: the colour swatches toggle the picker; the pickers' review fixes Clicking a colour swatch opens the picker; clicking the same one again closes it (another swatch switches the picker to its paint). The page background, the text range, the paint rows, the effect rows and the selection colours all toggle. The pickers' review fixes on top: the colour picker keeps its popover only while it is open (a body that closed it left a stale dragged place for the next picker to inherit); the style picker resets its retained popover when a new picker opens and on its early-close paths; and Escape belongs to the nested colour picker first, so closing it doesn't take the style picker down with it. Opening the style picker, the layout settings or the stroke settings closes the colour picker instead of leaving it lingering beside them. --- crates/ui-egui/src/colorpicker.rs | 18 +++++++++++++----- .../src/colorpicker_selection_tests.rs | 19 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 9 +++++++++ crates/ui-egui/src/right.rs | 19 ++++++++++++------- crates/ui-egui/src/selection_colors.rs | 10 +++++----- crates/ui-egui/src/style_picker.rs | 10 +++++++++- 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 07fff995..4539d0c0 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -845,14 +845,22 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.add_space(12.0); }); if closed { - finish(app); - app.ui.picker = None; - app.ui.picker_popover = None; - } else { + 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); } } +/// The colour picker closes (its X, 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; +} + /// Swatches wrapped in rows inset 16 points from both sides of the picker. fn inset(ui: &mut Ui, add: impl FnOnce(&mut Ui)) { egui::Frame::NONE.inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| ui.horizontal_wrapped(add)); @@ -1148,7 +1156,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/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 57aae197..802a3342 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -52,3 +52,22 @@ fn picker_closes_when_another_layer_is_selected() { u.frame(vec![]); 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"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84c85a80..6f1f669a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1578,6 +1578,15 @@ impl FigApp { self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current); } + /// 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: crate::colorpicker::PickerTarget, _top: Option) { + if self.ui.picker.as_ref() == Some(&target) { + crate::colorpicker::close(self); + } else { + self.ui.picker = Some(target); + } + } fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf953..759c4254 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -400,11 +400,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::new("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); @@ -412,7 +415,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) @@ -1054,8 +1057,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::new("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::new("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)); @@ -1544,7 +1548,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, widgets::paint_swatch(ui.painter(), sw, p); let sresp = ui.interact(sw.expand(2.0), Id::new(("swatch", fills, i)), Sense::click()); 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)); @@ -1927,8 +1931,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::new(("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/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index f96fc609..75c9038a 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -44,9 +44,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } -pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) { +pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { 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); } pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { @@ -66,7 +66,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, 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 @@ -76,7 +76,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, 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.id().with("hex"), &color.to_hex(), width, tr("Color")) { @@ -94,7 +94,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, 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/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 18a253cf..cb66d7ab 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -169,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 }); } @@ -176,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 }); } @@ -877,6 +882,7 @@ 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 screen = ctx.content_rect(); @@ -897,7 +903,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); let variables = crate::variable_ui::picker_rect(ctx); let holds: Vec = picker_rect.into_iter().chain(variables).collect(); - let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() }, |ui| { + 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 { @@ -988,6 +994,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 }); } From 50b978b8f9b2aaa7b3dd68a332ca50a56997c6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?= <108113065+yelintong@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:01:13 +0800 Subject: [PATCH 4/4] M4: the colour picker closes like every other detached panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker draws on the shared popover, so it closes the way the popover does: the X, Escape, and a press outside it. - A press on the canvas closes it; presses in the panel beside it keep it, since a swatch, a style chip or a field there switches the colour it edits. - The control that opened it is held out of that closing and its rect is refreshed every frame, so pressing the same swatch again reaches its own toggle — after the panel scrolls too (the bug #730 had, in the picker's hands). - Escape closes it before the binding deselects the layer, and not while one of its own dropdowns is open. Tests: a press on the canvas closes it, the same swatch closes it again (panel_remove_tests). --- crates/ui-egui/src/colorpicker.rs | 20 ++++++++++++-- crates/ui-egui/src/lib.rs | 4 +++ crates/ui-egui/src/panel_remove_tests.rs | 33 ++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 3 +++ crates/ui-egui/src/shortcuts.rs | 5 ++++ 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 4539d0c0..13fb16b9 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -465,8 +465,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover chrome now; the picker keeps its body. - let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0).close_on_outside(false)); - let closed = pop.show(ctx, Open::At(pos), Hold::default(), |ui| { + let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0)); + // 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 = 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); // Paint type. if !matches!( @@ -855,10 +861,20 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { /// The colour picker closes (its X, Escape, or another popover took over): pending edits commit /// and the picker with its popover go. +/// Escape with the picker open closes it (the selection stays). +pub fn close_if_open(app: &mut FigApp) -> bool { + if app.ui.picker.is_none() { + return false; + } + close(app); + true +} + 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. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6f1f669a..9b8ee845 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -490,6 +490,9 @@ pub struct UiState { pub picker: Option, /// The colour picker's popover while it is open (its place survives a drag). pub picker_popover: Option, + /// 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, pub context_menu: Option<(egui::Pos2, Vec)>, /// 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. @@ -635,6 +638,7 @@ impl Default for UiState { preview: None, picker: None, picker_popover: None, + picker_button: None, context_menu: None, key_menu: false, key_box: None, diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..5a45d54e 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -167,3 +167,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::new(("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::new(("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/right.rs b/crates/ui-egui/src/right.rs index 759c4254..85cc9ec7 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -1547,6 +1547,9 @@ 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::new(("swatch", fills, i)), Sense::click()); + if app.ui.picker.as_ref() == Some(&crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }) { + app.ui.picker_button = Some(sresp.rect); + } if sresp.clicked() { app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y)); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f3197960..1c0f3d49 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -241,6 +241,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, 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; }