diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index c44d457d..7a27aff3 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -32,6 +32,16 @@ impl FigApp { } } + /// Clicking the control that opened the colour picker closes it again; clicking another opens + /// the picker for that one. + pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, _top: Option) { + if self.ui.picker.as_ref() == Some(&target) { + crate::colorpicker::close(self); + } else { + self.ui.picker = Some(target); + } + } + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index fff5536a..85c8d291 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -53,6 +53,25 @@ fn picker_closes_when_another_layer_is_selected() { assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } +#[test] +fn clicking_the_swatch_again_closes_the_picker() { + let (mut u, a, _) = two(); + u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"})); + let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; + u.app.toggle_picker(target.clone(), Some(200.0)); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker"); + u.app.toggle_picker(target.clone(), Some(200.0)); + u.frame(vec![]); + assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut"); + // Another paint of the same layer opens the picker for that one. + let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] }; + u.app.toggle_picker(second.clone(), Some(200.0)); + u.frame(vec![]); + assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it"); +} + // Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's // left edge (only the first row was indented) and ran past its right edge. #[test] @@ -77,11 +96,9 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { u.frame(vec![]); } let out = u.frame_output(vec![]); - // The picker is the only floating window open. - let picker = u - .ctx - .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id))) - .expect("the picker is open"); + // The picker is the only floating window open: its area is the picker's own id, which the style + // picker also looks up to tell a press in here from one outside. + let picker = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("the picker is open"); // A swatch is a 14 pt square with a 1 pt outline. let swatches: Vec = out .shapes diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8d5d3e1b..ae4245c7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -17,6 +17,7 @@ pub mod icons; pub mod live; pub mod menus; pub mod pending_open; +pub mod popover; pub mod present; pub mod shortcuts; pub mod tabs; @@ -161,6 +162,8 @@ mod plugin_save_ui_tests; #[cfg(test)] mod plugins_browse_tests; #[cfg(test)] +mod popover_tests; +#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_links_tests; @@ -402,6 +405,11 @@ 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, + /// 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. @@ -449,6 +457,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. @@ -578,6 +588,8 @@ impl Default for UiState { present_view: present::PresentView::default(), preview: None, picker: None, + picker_popover: None, + picker_button: None, context_menu: None, key_menu: false, key_box: None, @@ -605,6 +617,7 @@ impl Default for UiState { pdf_print: figcraft_doc::PrintSettings::default(), color_profile: None, style_picker: None, + style_picker_popover: None, style_grid: false, style_folds: BTreeSet::new(), text_preview: None, diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index bc48207c..50de5bf9 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -192,3 +192,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() { assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3)); assert_eq!(u.app.ui.picker, None); } + +/// The shared popover's closing reaches the colour picker: a press on the canvas closes it. +#[test] +fn a_press_on_the_canvas_closes_the_colour_picker() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center(); + click(&mut u, sw); + assert!(u.app.ui.picker.is_some(), "the swatch opens the picker"); + click(&mut u, Pos2::new(320.0, 300.0)); + assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker"); +} + +/// Pressing the swatch that opened the picker again closes it: its press is held out of the +/// popover's outside-press closing, so the swatch's own toggle gets the click. +#[test] +fn the_swatch_that_opened_the_colour_picker_closes_it_again() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let sw = u.ctx.read_response(Id::unique(("swatch", true, 0usize))).unwrap().rect.center(); + click(&mut u, sw); + assert!(u.app.ui.picker.is_some(), "the swatch opens the picker"); + click(&mut u, sw); + assert_eq!(u.app.ui.picker, None, "the same swatch closes it again"); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 80b0c3e0..be691a8a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -306,11 +306,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { }; widgets::section_header(ui, tr("Page"), &[], false); let mut open_picker = false; + let mut swatch_top = None; let mut new_hex = None; row(ui, 32.0, |ui| { let p = Paint::solid(bg); - if widgets::swatch(ui, &p, 16.0).clicked() { + let sw = widgets::swatch(ui, &p, 16.0); + if sw.clicked() { open_picker = true; + swatch_top = Some(sw.rect.min.y); } if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); @@ -318,7 +321,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { widgets::label(ui, "100 %", t.text); }); if open_picker { - app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground); + app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top); } if let Some(h) = new_hex && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index f2d48eba..688d22e1 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -329,8 +329,9 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { /// duo noise's second colour), in the current row; `edit` applies a new one. fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) { let Some(c) = e.color(secondary) else { return }; - if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }); + let sw = widgets::swatch(ui, &Paint::solid(c), 16.0); + if sw.clicked() { + app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y)); } if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 5de0dfff..f73f8beb 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -253,8 +253,9 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: match fills.as_ref().and_then(|f| f.first()) { Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); - if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); + let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()); + if range_swatch.clicked() { + app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y)); } if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); @@ -445,8 +446,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); widgets::paint_swatch(ui.painter(), sw, p); let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); + // While this paint is the one the picker edits, its swatch is held out of the popover's + // closing, so a press on it again reaches the toggle below. + let editing = matches!( + &app.ui.picker, + Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids + ); + if editing { + app.ui.picker_button = Some(sresp.rect); + } if sresp.clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }); + app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y)); } let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 682b37fe..3fdada05 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -47,9 +47,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, 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); } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity @@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); if swatch.clicked() { - open_picker(app, group.targets.clone()); + toggle(app, group.targets.clone(), Some(swatch.rect.min.y)); } let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group @@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); if let Some(name) = linked_name { if widgets::dropdown(ui, name, width, None).clicked() { - open_picker(app, group.targets.clone()); + toggle(app, group.targets.clone(), None); } } else if let Some(color) = group.paint.solid_color() { if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { @@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } else { let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); if response.clicked() { - open_picker(app, group.targets.clone()); + toggle(app, group.targets.clone(), Some(response.rect.min.y)); } } let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs new file mode 100644 index 00000000..dbcf3bdb --- /dev/null +++ b/crates/ui-egui/src/popover.rs @@ -0,0 +1,283 @@ +//! The chrome every detached panel shares: a card with a header — the title 16 points in from its +//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup +//! shadow, around one slot. +//! +//! The measurements are our own: they are the header the colour picker has always drawn, and the +//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing +//! here is taken from another product's interface. +//! +//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off +//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on +//! Escape and on a press outside it — except in the rects the caller holds out (the button that +//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead +//! of the press closing it and the release opening it again. + +use egui::epaint::RectShape; +use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2}; + +use crate::a11y::Tip; +use crate::i18n::tr; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, semibold}; +use crate::widgets; + +/// The default width: the colour picker's, and the one every detached panel shares. +pub const WIDTH: f32 = 240.0; +/// The header's height: the title's line and the X sit in it. +pub const HEADER_H: f32 = 40.0; +/// The title starts this far from the panel's left edge, the X ends this far from its right one. +const TITLE_LEFT: f32 = 16.0; +const CLOSE_RIGHT: f32 = 8.0; +/// The X's button (the icon is inset in it). +const CLOSE: f32 = 24.0; +/// The slot's default margin inside the panel. +const SLOT_PAD: f32 = 16.0; +/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others. +const SCREEN_GAP: f32 = 16.0; +const EDGE: f32 = 8.0; +/// The panel's corners. +const RADIUS: u8 = 13; + +/// The surface a popover draws on: the panels' own, or the dark one the style picker uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Surface { + Panel, + Menu, +} + +/// Where a popover opens when the caller has not put it down itself. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Open { + /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top), + /// lifted when that would run its bottom past the screen's bottom. + Anchor(Pos2), + /// Its top-left exactly here. + At(Pos2), +} + +/// What keeps a popover open while a press or Escape happens somewhere else. +#[derive(Clone, Copy, Default)] +pub struct Hold<'a> { + /// Presses in these rects do not close it: the button that opened it, the popovers it opened. + pub rects: &'a [Rect], + /// Escape belongs to something above it (a field being typed in, a picker of its own). + pub escape: bool, +} + +/// Where the popover shows. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Place { + /// From [`Open`], until the header drags it. + Auto, + /// Here: put down by the caller or by the drag. + At(Pos2), +} + +/// An open popover. +pub struct Popover { + id: Id, + /// The header's line. + pub title: String, + width: f32, + pad: f32, + surface: Surface, + /// Whether a press outside it closes it. + outside: bool, + place: Place, + /// While the header is held down: where in the panel the pointer grabbed it. + grab: Option, + /// The panel, its header, its slot and the X, as drawn last frame. + pub rect: Rect, + pub header: Rect, + pub slot: Rect, + pub close: Rect, + /// The panel's size as laid out last frame. + size: Vec2, +} + +impl Popover { + /// A popover titled `title`; `id` names its egui area, so it must be stable and unique. + pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into) -> Self { + Self::with_id(Id::unique(id), title) + } + + /// A popover whose egui area is `id` itself — not a hash of it — so a caller that publishes the + /// id can find the area again (the colour picker's [`crate::colorpicker::window_id`]). + pub fn with_id(id: Id, title: impl Into) -> Self { + Self { + id, + title: title.into(), + width: WIDTH, + pad: SLOT_PAD, + surface: Surface::Panel, + outside: true, + place: Place::Auto, + grab: None, + rect: Rect::NOTHING, + header: Rect::NOTHING, + slot: Rect::NOTHING, + close: Rect::NOTHING, + size: vec2(WIDTH, 0.0), + } + } + + /// `width` points wide instead of [`WIDTH`]. + pub fn width(mut self, width: f32) -> Self { + self.width = width.max(1.0); + self.size.x = self.width; + self + } + + /// `pad` points of margin around the slot instead of the default 16. + pub fn pad(mut self, pad: f32) -> Self { + self.pad = pad.max(0.0); + self + } + + /// Drawn on the dark surface the style browser uses. + pub fn surface(mut self, surface: Surface) -> Self { + self.surface = surface; + self + } + + /// Whether a press outside it closes it (the default). The colour picker stays open, so a + /// press on a row beside it keeps editing the paint it is on. + pub fn close_on_outside(mut self, yes: bool) -> Self { + self.outside = yes; + self + } + + /// Its top-left at `pos`, instead of where [`Open`] would put it. + pub fn at(mut self, pos: Pos2) -> Self { + self.place = Place::At(pos); + self + } + + /// Draws the panel and fills its slot with `body`; true when it asked to close — the X, + /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame. + pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool { + let t = Tokens::get(ctx); + let dark = self.surface == Surface::Menu; + let screen = ctx.content_rect(); + // The anchor rule needs the panel's height, which only a layout gives: the first frame + // lays it out invisibly and the next one places it — a jump would show. + let first = self.size.y <= 0.0; + let pos = match self.place { + Place::At(p) => clamp(p, self.size, screen), + Place::Auto => { + let p = match open { + Open::At(p) => p, + Open::Anchor(a) => open_pos(a, self.size, screen), + }; + clamp(p, self.size, screen) + } + }; + let mut close = false; + egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { + if first { + ui.set_invisible(); + ctx.request_repaint(); + } + // The panel is painted into a place kept before the content: the shadow and the fill + // go under it, though its size is only known once the slot is laid out. + let bg = ui.painter().add(egui::Shape::Noop); + let spacing = ui.spacing().item_spacing; + ui.spacing_mut().item_spacing = Vec2::ZERO; + // 1. The header: the title, then the X at the right. + let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover()); + let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE)); + let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y)); + let drag = ui.interact(handle, self.id.with("drag"), Sense::drag()); + let font = semibold(11.0); + let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone()); + ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark)); + close = close_button(ui, self.id.with("close"), close_rect, dark, t); + // 2. The header drags the panel (grabbed where the press landed, so the first frame of + // a fast drag counts too); the X keeps its own clicks. + if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) { + self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos); + } + if drag.dragged() + && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos()) + { + self.place = Place::At(clamp(at - grab, self.size, screen)); + } + if ctx.input(|i| i.pointer.any_released()) { + self.grab = None; + } + if drag.dragged() || drag.hovered() { + ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + // 3. The slot, under a hairline. + let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| { + ui.spacing_mut().item_spacing = spacing; + ui.set_width(self.width - 2.0 * self.pad); + body(ui); + ui.min_rect() + }); + let slot = frame.inner; + let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y)); + let shadow = ctx.global_style().visuals.popup_shadow; + let fill = if dark { t.menu } else { t.panel }; + let shapes = vec![ + egui::Shape::from(shadow.as_shape(panel, RADIUS)), + RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(), + // A point of the theme's border colour under the header. + RectShape::filled( + Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)), + CornerRadius::ZERO, + if dark { Color32::from_white_alpha(24) } else { t.border }, + ) + .into(), + ]; + ui.painter().set(bg, egui::Shape::Vec(shapes)); + self.rect = panel; + self.header = header; + self.slot = slot; + self.close = close_rect; + self.size = panel.size(); + }); + // Closing is the widget's: the X above, Escape and a press outside (a child popup or a + // held-out rect keeps it open, so the button that opened it can click it shut). + let popup_open = egui::Popup::is_any_open(ctx); + let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape)); + let outside = self.outside + && !popup_open + && ctx.input(|i| { + i.pointer.any_pressed() + && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at))) + }); + close || escape || outside + } +} + +fn header_text(t: Tokens, dark: bool) -> Color32 { + if dark { t.menu_text } else { t.text } +} + +/// The X: a 24-point button, its icon inset 6 (the colour picker's header). +fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool { + let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close")); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover }); + } + icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon }); + resp.clicked() +} + +/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on +/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to +/// keep [`SCREEN_GAP`] under it. +fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 { + let bottom = screen.max.y - SCREEN_GAP; + let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y }; + pos2(anchor.x - size.x, y) +} + +/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't. +fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 { + let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE); + let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0)); + let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0)); + pos2(x, y) +} diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rs new file mode 100644 index 00000000..713e8d39 --- /dev/null +++ b/crates/ui-egui/src/popover_tests.rs @@ -0,0 +1,175 @@ +//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in +//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule +//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen), +//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press +//! in a held-out rect, so the button that opened it can toggle it shut. + +use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; + +use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH}; + +const SCREEN: egui::Vec2 = vec2(1440.0, 900.0); + +/// A context with the UI's fonts: the header's title is drawn in the semibold family. +fn context() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + ctx +} + +fn input(t: &mut f64, events: Vec) -> 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() }] +} + +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(), open, hold, |ui| { + ui.label("slot"); + }); + }); + out.textures_delta.clear(); + (crate::live_tests::flat(&out.shapes), closed) +} + +/// Two frames: the first lays the panel out to learn its height, the second places and paints it. +fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec { + draw(ctx, t, p, open, Hold::default(), vec![]); + draw(ctx, t, p, open, Hold::default(), vec![]).0 +} + +fn near(a: Pos2, b: Pos2) -> bool { + (a - b).length() < 0.05 +} + +#[test] +fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_header", "Custom"); + let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + assert_eq!(p.rect.width(), WIDTH, "240 points wide"); + assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect); + assert_eq!(p.header.height(), HEADER_H, "the header is 40 high"); + // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line. + assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close); + assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line"); + // The title: one line, 16 in from the left, on the header's middle. + let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows"); + assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}"); + assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}"); + // The slot: 16 in from the panel's sides, under the header. + assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot); + assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot); +} + +#[test] +fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_anchor", "Popup"); + settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0))); + assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect); + assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect); +} + +#[test] +fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_lift", "Popup"); + settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0))); + // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's. + assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect); + assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect); +} + +#[test] +fn the_header_drags_it_and_the_slot_does_not() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_drag", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let at = Open::At(pos2(160.0, 100.0)); + let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0)); + let before = p.rect.min; + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); + assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min); + // The slot does not drag it. + let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0)); + let held = p.rect.min; + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); + assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min); +} + +#[test] +fn the_x_and_escape_close_it() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_close", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let at = Open::At(pos2(160.0, 100.0)); + let close = p.close.center(); + assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet"); + assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close"); + assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close"); + // Escape that belongs to something above it does not. + let mut p = Popover::new("popover_escape_held", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0))); + let hold = Hold { rects: &[], escape: true }; + assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open"); +} + +#[test] +fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() { + let ctx = context(); + let mut t = 0.0; + let mut p = Popover::new("popover_outside", "Popup"); + settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0))); + let at = Open::At(pos2(300.0, 200.0)); + // The button that opened it: a press there leaves it open, so its release can toggle it shut. + let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0)); + let hold = Hold { rects: &[opener], escape: false }; + let on_opener = opener.center(); + assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open"); + assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release"); + // A press anywhere else closes it, before the release. + let elsewhere = pos2(900.0, 400.0); + assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it"); +} + +#[test] +fn a_caller_can_put_it_where_it_likes() { + let ctx = context(); + let mut t = 0.0; + // Placed outright, the opening rule no longer matters. + let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0)); + settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0))); + assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect); + // Nor after a drag: it stays where it was dropped. + let at = Open::At(pos2(900.0, 100.0)); + let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]); + draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false)); + draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]); + assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c5955ec0..5a0933e4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -454,6 +454,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) { return true; } + // The colour picker open: Escape closes it here, before the binding deselects — unless one of + // its own dropdowns is open, which takes this Escape. + if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) { + return true; + } if key_box(app, key, m) { return true; } diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 2f236a93..c3bd47c5 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -1,7 +1,7 @@ //! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders, //! hex and RGB fields, gradient stops, image and pattern fills and document colours. -use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::{ BlendMode, Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile, @@ -13,7 +13,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)] @@ -853,6 +854,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); @@ -863,170 +865,184 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { _ => None, }; let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); - let mut open = true; - let mut close_now = false; - egui::Window::new("color_picker") - // A fixed area id so the style picker can tell a click in here from a click outside - // (`Window::new` alone would key the area by `Id::unique(Some(title))`). - .id(window_id()) - .title_bar(false) - .fixed_pos(pos) - .fixed_size(vec2(240.0, 0.0)) - .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0))) - .open(&mut open) - .show(ctx, |ui| { - ui.spacing_mut().item_spacing = vec2(8.0, 8.0); - // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). - if let Some(c) = app.session.tool.picked.take() { - set_color(app, &target, &mut paint, c, stop_id(), ui); + // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared + // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the + // style picker looks up to tell a press in here from one outside. + let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::with_id(window_id(), tr("Custom")).pad(0.0)); + // Sampling a colour from the canvas — the eyedropper this picker's own button started, or the + // colour it just sampled — is the press, not a press outside: neither may close the picker. + let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); + pop = pop.close_on_outside(!picking); + // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel + // beside it keep it (swatches and fields there switch the colour it edits); the control that + // opened it is held too, so pressing it again reaches its own toggle. + let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max); + let mut held: Vec = vec![panel]; + held.extend(app.ui.picker_button); + let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| { + ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). + if let Some(c) = app.session.tool.picked.take() { + set_color(app, &target, &mut paint, c, stop_id(), ui); + } + // Paint type. + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { + paint_type_row(ui, app, &target, &mut paint); + } + // The paint's own blend mode, with the paints and layers under it. + if !matches!( + target, + PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } + ) { + blend_row(ui, app, &target, &mut paint); + } + // 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.clone() { + edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id); + } + if let PaintKind::Image(img) = paint.kind.clone() { + image_controls(app, ui, &target, &mut paint, img, &t); + } + if let PaintKind::Pattern(pt) = paint.kind.clone() { + pattern_controls(app, ui, &target, &mut paint, pt); + } + if let Some(color) = edit_color { + let mut hsb = ui + .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb"))) + .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) + .map(|(h, s, b, _)| Hsb { h, s, b }) + .unwrap_or_else(|| Hsb::from_color(color)); + let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); + let (c1, d1) = sv_square(ui, sq, &mut hsb); + let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); + let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0)); + if icons::button( + &mut ui + .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))), + Icon::Eyedropper, + 24.0, + false, + tr("Pick color (I)"), + ) + .clicked() + { + app.run("edit.pickColor", json!({"apply": false})); + return; } - // Header. - let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); - let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click()); - icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); - if cresp.clicked() { - close_now = true; + 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)); } - // Paint type. - if !matches!( - target, - PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } - ) { - paint_type_row(ui, app, &target, &mut paint); + 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; } - // The paint's own blend mode, with the paints and layers under it. - if !matches!( - target, - PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. } - ) { - blend_row(ui, app, &target, &mut paint); + // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha. + if let Some((c, d)) = model_fields(ui, nc) { + nc = c; + hsb = Hsb::from_color(nc); + changed = true; + done |= d; } - // 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.clone() { - edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id); + ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. + if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { + gamut_warning(ui, &t); } - if let PaintKind::Image(img) = paint.kind.clone() { - image_controls(app, ui, &target, &mut paint, img, &t); - } - if let PaintKind::Pattern(pt) = paint.kind.clone() { - pattern_controls(app, ui, &target, &mut paint, pt); - } - if let Some(color) = edit_color { - let mut hsb = ui - .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb"))) - .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) - .map(|(h, s, b, _)| Hsb { h, s, b }) - .unwrap_or_else(|| Hsb::from_color(color)); - let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover()); - let (c1, d1) = sv_square(ui, sq, &mut hsb); - let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover()); - let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0)); - if icons::button( - &mut ui.new_child( - egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))), - ), - Icon::Eyedropper, - 24.0, - false, - tr("Pick color (I)"), - ) - .clicked() - { - app.run("edit.pickColor", json!({"apply": false})); - return; - } - let (c2, d2) = hue_slider(ui, hr, &mut hsb); - let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover()); - let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0)); - let mut new_alpha = None; - if let Some((a, d)) = alpha_slider(ui, ar, color) { - new_alpha = Some((a, d)); - } - let mut nc = hsb.to_color().with_alpha(color.a); - let mut done = d1 || d2; - let mut changed = c1 || c2; - if let Some((a, d)) = new_alpha { - nc = nc.with_alpha(a); - changed = true; - done |= d; - } - // Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha. - if let Some((c, d)) = model_fields(ui, nc) { - nc = c; - hsb = Hsb::from_color(nc); - changed = true; - done |= d; - } - ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); - // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. - if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { - gamut_warning(ui, &t); - } - if changed { - match &mut paint.kind { - PaintKind::Solid { color } => { - *color = nc.with_alpha(1.0); - paint.opacity = nc.a; - } - PaintKind::Gradient(g) => { - let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0); - if let Some(s) = g.stops.get_mut(sel) { - s.color = nc; - } - } - PaintKind::Image(_) | PaintKind::Pattern(_) => {} + if changed { + match &mut paint.kind { + PaintKind::Solid { color } => { + *color = nc.with_alpha(1.0); + paint.opacity = nc.a; } - write(app, &target, &paint, !done); - } - 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); + 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); } - // 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 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); } - }); - } - ui.add_space(12.0); - }); - if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - finish(app); - app.ui.picker = None; + } + }); + } + // Recent colours. + let recent = app.session.recent_colors.clone(); + if !recent.is_empty() { + ui.horizontal(|ui| { + ui.add_space(16.0); + widgets::label(ui, tr("Recent"), t.text_secondary); + }); + inset(ui, |ui| { + for c in recent.iter().take(14) { + if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() { + set_color(app, &target, &mut paint, *c, sel_stop_id, ui); + } + } + }); + } + ui.add_space(12.0); + }); + if closed { + close(app); + } else if app.ui.picker.is_some() { + // Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave + // a stale place for the next picker to inherit. + app.ui.picker_popover = Some(pop); } - let _ = Pos2::ZERO; +} + +/// Escape with the picker open closes it (the selection stays); false when it was not open, so the +/// key goes on to the binding that deselects. +pub fn close_if_open(app: &mut FigApp) -> bool { + if app.ui.picker.is_none() { + return false; + } + close(app); + true +} + +/// The colour picker closes (its X, a press outside it, Escape, or another popover took over): +/// pending edits commit and the picker with its popover go. +pub fn close(app: &mut FigApp) { + finish(app); + app.ui.picker = None; + app.ui.picker_popover = None; + app.ui.picker_button = None; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. @@ -1324,7 +1340,7 @@ mod selection_color_tests { s.execute("select.set", &json!({"ids":[a,b]})).unwrap(); let mut app = FigApp::new(s, Box::new(NoServices)); let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone(); - crate::selection_colors::open_picker(&mut app, targets); + crate::selection_colors::toggle(&mut app, targets, None); (app, a, b) } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index aaf807ca..b1001a0e 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -19,11 +19,13 @@ use crate::a11y::Tip; use crate::colorpicker::PickerTarget; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::popover::{Hold, Open, Popover, Surface}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets; -/// Content width of the popover. -const W: f32 = 240.0; +/// The slot's margin inside the shared popover, and the content width left of it. +const PAD: f32 = 8.0; +const W: f32 = 224.0; /// Height of a list row. const ROW_H: f32 = 26.0; /// Height of a text style's list row (its name in its own font). @@ -167,6 +169,9 @@ impl Picker { /// Open the picker for `slot` of `ids`, level with the pointer. pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) { let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0); + // Opening the style picker closes the colour picker (it would linger beside it). + crate::colorpicker::close(app); + app.ui.style_picker_popover = None; app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None }); } @@ -174,6 +179,8 @@ pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) { pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) { let ids = app.session.selection(); let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing)); + crate::colorpicker::close(app); + app.ui.style_picker_popover = None; app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit }); } @@ -516,9 +523,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut let link = link_of(app, doc, &p.ids, slot); let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id)); let grid = slot.has_grid() && app.ui.style_grid; - // Header: title, then view toggle, detach and create. + // The title is the popover's; this row keeps the view toggle, detach and create. let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text); let mut x = hr.max.x - 24.0; let mut slot_rect = || { let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0)); @@ -973,7 +979,6 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, /// Editing one style: name, description, its values (paints open the colour picker; text styles /// have their typography), update from the selection, delete. fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { - let t = Tokens::get(ui.ctx()); let slot = p.slot; let has_selection = !p.ids.is_empty(); let Some(e) = p.edit.as_mut() else { return }; @@ -985,7 +990,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)); @@ -1074,9 +1078,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if let Some(e) = &p.edit { commit_edit(app, &doc, e); } + app.ui.style_picker_popover = None; return; } - let t = Tokens::get(ctx); let screen = ctx.content_rect(); // Left of the Design panel; further left while the colour picker (which sits there) is open. let mut x = screen.max.x - app.ui.right_width - W - 32.0; @@ -1085,34 +1089,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0)); let mut out = Frame { act: None, hover: None, budget: 4 }; - let area = egui::Area::new(Id::unique("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| { - egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { - ui.set_width(W); - if p.edit.is_some() { - editor(ui, &doc, &mut p, &mut out); - } else { - listing(app, ui, &doc, &mut p, &mut out); - } - }); - }); - let rect = area.response.rect; - ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::unique("style_picker_border"))).rect_stroke( - rect, - CornerRadius::same(10), - Stroke::new(1.0, Color32::from_black_alpha(40)), - egui::StrokeKind::Inside, - ); - // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, - // keep it open. + // The title and the X are the shared popover's now; the list keeps its tools row. + let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() }; + let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu)); + pop.title = title.to_string(); + // Clicks in the colour picker editing this picker's style, in the variable picker binding one + // of its fields, or in a menu the editor opened, keep it open. let picker_rect = app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id()))); - // So do clicks in the variable picker binding one of its fields (Escape closes that first). let variables = crate::variable_ui::picker_rect(ctx); - let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); - let outside = ctx.input(|i| { - i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos))) - }) && !in_popup; - let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup); + let holds: Vec = picker_rect.into_iter().chain(variables).collect(); + let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| { + if p.edit.is_some() { + editor(ui, &doc, &mut p, &mut out); + } else { + listing(app, ui, &doc, &mut p, &mut out); + } + }); + let mut close = closed; let ids = p.ids.clone(); let slot = p.slot; // The canvas shows the text style under the pointer on the selected text. @@ -1185,8 +1179,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); } } @@ -1194,6 +1190,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn edit_local(app: &mut FigApp, ui: &Ui, slot: StyleSlot, id: &str) { let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0); let edit = app.session.doc().ok().and_then(|d| d.style(id).map(editing)); + crate::colorpicker::close(app); + app.ui.style_picker_popover = None; app.ui.style_picker = Some(Picker { slot, ids: app.session.selection(), y, query: String::new(), edit }); } diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index b732c11d..fa996374 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -323,17 +323,10 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui { u } -/// The left edge of the style editor's content (its "Edit style" title starts 30 px in). +/// The left edge of the style editor's content: the shared popover's slot, inside its margin. fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 { - let mut left = None; - for c in u.frame_output(vec![]).shapes { - if let egui::Shape::Text(t) = &c.shape - && t.galley.text() == "Edit style" - { - left = Some(t.pos.x - 30.0); - } - } - left.expect("the editor's title") + u.frame_output(vec![]); + u.app.ui.style_picker_popover.as_ref().expect("the editor's popover").slot.min.x } /// The texts drawn in one frame, with their centers.