Merge pull request #789 from yelintong/feat/popover

M4: a generic popover —  a 40-high header, dragged by its header
This commit is contained in:
BFlat
2026-10-29 13:38:12 +07:00
committed by GitHub
14 changed files with 772 additions and 215 deletions
+10
View File
@@ -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<f32>) {
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.
@@ -53,6 +53,25 @@ fn picker_closes_when_another_layer_is_selected() {
assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
}
#[test]
fn clicking_the_swatch_again_closes_the_picker() {
let (mut u, a, _) = two();
u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));
let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };
u.app.toggle_picker(target.clone(), Some(200.0));
u.frame(vec![]);
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");
u.app.toggle_picker(target.clone(), Some(200.0));
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");
// Another paint of the same layer opens the picker for that one.
let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };
u.app.toggle_picker(second.clone(), Some(200.0));
u.frame(vec![]);
assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");
}
// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's
// left edge (only the first row was indented) and ran past its right edge.
#[test]
@@ -77,11 +96,9 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {
u.frame(vec![]);
}
let out = u.frame_output(vec![]);
// The picker is the only floating window open.
let picker = u
.ctx
.memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id)))
.expect("the picker is open");
// The picker is the only floating window open: its area is the picker's own id, which the style
// picker also looks up to tell a press in here from one outside.
let picker = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("the picker is open");
// A swatch is a 14 pt square with a 1 pt outline.
let swatches: Vec<egui::Rect> = out
.shapes
+13
View File
@@ -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<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
/// The colour picker's popover while it is open (its place survives a drag).
pub picker_popover: Option<popover::Popover>,
/// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):
/// a press in it is held out of the popover's closing, so its own click toggles the picker.
pub picker_button: Option<egui::Rect>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the
/// focused layer row or the canvas selection and takes the keyboard.
@@ -449,6 +457,8 @@ pub struct UiState {
pub color_profile: Option<figcraft_color::ColorProfile>,
/// The open style picker.
pub style_picker: Option<style_picker::Picker>,
/// The style picker's popover while it is open (its place survives a drag).
pub style_picker_popover: Option<popover::Popover>,
/// The style picker shows colour styles as a grid of swatches.
pub style_grid: bool,
/// Style folders closed in the style picker.
@@ -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,
+33
View File
@@ -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");
}
+5 -2
View File
@@ -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)
+3 -2
View File
@@ -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)
+13 -3
View File
@@ -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));
@@ -47,9 +47,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti
colors
}
pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {
pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, top: Option<f32>) {
let Ok(ds) = app.session.ds() else { return };
app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top);
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
@@ -72,7 +72,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
crate::right::row(ui, 32.0, |ui| {
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
if swatch.clicked() {
open_picker(app, group.targets.clone());
toggle(app, group.targets.clone(), Some(swatch.rect.min.y));
}
let width = (ui.available_width() - 116.0).max(40.0);
let linked_name = group
@@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));
if let Some(name) = linked_name {
if widgets::dropdown(ui, name, width, None).clicked() {
open_picker(app, group.targets.clone());
toggle(app, group.targets.clone(), None);
}
} else if let Some(color) = group.paint.solid_color() {
if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {
@@ -100,7 +100,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
} else {
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
if response.clicked() {
open_picker(app, group.targets.clone());
toggle(app, group.targets.clone(), Some(response.rect.min.y));
}
}
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
+283
View File
@@ -0,0 +1,283 @@
//! The chrome every detached panel shares: a card with a header — the title 16 points in from its
//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup
//! shadow, around one slot.
//!
//! The measurements are our own: they are the header the colour picker has always drawn, and the
//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing
//! here is taken from another product's interface.
//!
//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off
//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on
//! Escape and on a press outside it — except in the rects the caller holds out (the button that
//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead
//! of the press closing it and the release opening it again.
use egui::epaint::RectShape;
use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2};
use crate::a11y::Tip;
use crate::i18n::tr;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, semibold};
use crate::widgets;
/// The default width: the colour picker's, and the one every detached panel shares.
pub const WIDTH: f32 = 240.0;
/// The header's height: the title's line and the X sit in it.
pub const HEADER_H: f32 = 40.0;
/// The title starts this far from the panel's left edge, the X ends this far from its right one.
const TITLE_LEFT: f32 = 16.0;
const CLOSE_RIGHT: f32 = 8.0;
/// The X's button (the icon is inset in it).
const CLOSE: f32 = 24.0;
/// The slot's default margin inside the panel.
const SLOT_PAD: f32 = 16.0;
/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others.
const SCREEN_GAP: f32 = 16.0;
const EDGE: f32 = 8.0;
/// The panel's corners.
const RADIUS: u8 = 13;
/// The surface a popover draws on: the panels' own, or the dark one the style picker uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Surface {
Panel,
Menu,
}
/// Where a popover opens when the caller has not put it down itself.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Open {
/// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),
/// lifted when that would run its bottom past the screen's bottom.
Anchor(Pos2),
/// Its top-left exactly here.
At(Pos2),
}
/// What keeps a popover open while a press or Escape happens somewhere else.
#[derive(Clone, Copy, Default)]
pub struct Hold<'a> {
/// Presses in these rects do not close it: the button that opened it, the popovers it opened.
pub rects: &'a [Rect],
/// Escape belongs to something above it (a field being typed in, a picker of its own).
pub escape: bool,
}
/// Where the popover shows.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Place {
/// From [`Open`], until the header drags it.
Auto,
/// Here: put down by the caller or by the drag.
At(Pos2),
}
/// An open popover.
pub struct Popover {
id: Id,
/// The header's line.
pub title: String,
width: f32,
pad: f32,
surface: Surface,
/// Whether a press outside it closes it.
outside: bool,
place: Place,
/// While the header is held down: where in the panel the pointer grabbed it.
grab: Option<Vec2>,
/// The panel, its header, its slot and the X, as drawn last frame.
pub rect: Rect,
pub header: Rect,
pub slot: Rect,
pub close: Rect,
/// The panel's size as laid out last frame.
size: Vec2,
}
impl Popover {
/// A popover titled `title`; `id` names its egui area, so it must be stable and unique.
pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {
Self::with_id(Id::unique(id), title)
}
/// A popover whose egui area is `id` itself — not a hash of it — so a caller that publishes the
/// id can find the area again (the colour picker's [`crate::colorpicker::window_id`]).
pub fn with_id(id: Id, title: impl Into<String>) -> Self {
Self {
id,
title: title.into(),
width: WIDTH,
pad: SLOT_PAD,
surface: Surface::Panel,
outside: true,
place: Place::Auto,
grab: None,
rect: Rect::NOTHING,
header: Rect::NOTHING,
slot: Rect::NOTHING,
close: Rect::NOTHING,
size: vec2(WIDTH, 0.0),
}
}
/// `width` points wide instead of [`WIDTH`].
pub fn width(mut self, width: f32) -> Self {
self.width = width.max(1.0);
self.size.x = self.width;
self
}
/// `pad` points of margin around the slot instead of the default 16.
pub fn pad(mut self, pad: f32) -> Self {
self.pad = pad.max(0.0);
self
}
/// Drawn on the dark surface the style browser uses.
pub fn surface(mut self, surface: Surface) -> Self {
self.surface = surface;
self
}
/// Whether a press outside it closes it (the default). The colour picker stays open, so a
/// press on a row beside it keeps editing the paint it is on.
pub fn close_on_outside(mut self, yes: bool) -> Self {
self.outside = yes;
self
}
/// Its top-left at `pos`, instead of where [`Open`] would put it.
pub fn at(mut self, pos: Pos2) -> Self {
self.place = Place::At(pos);
self
}
/// Draws the panel and fills its slot with `body`; true when it asked to close — the X,
/// Escape or a press outside it (outside [`Hold`]'s rects) on this frame.
pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool {
let t = Tokens::get(ctx);
let dark = self.surface == Surface::Menu;
let screen = ctx.content_rect();
// The anchor rule needs the panel's height, which only a layout gives: the first frame
// lays it out invisibly and the next one places it — a jump would show.
let first = self.size.y <= 0.0;
let pos = match self.place {
Place::At(p) => clamp(p, self.size, screen),
Place::Auto => {
let p = match open {
Open::At(p) => p,
Open::Anchor(a) => open_pos(a, self.size, screen),
};
clamp(p, self.size, screen)
}
};
let mut close = false;
egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {
if first {
ui.set_invisible();
ctx.request_repaint();
}
// The panel is painted into a place kept before the content: the shadow and the fill
// go under it, though its size is only known once the slot is laid out.
let bg = ui.painter().add(egui::Shape::Noop);
let spacing = ui.spacing().item_spacing;
ui.spacing_mut().item_spacing = Vec2::ZERO;
// 1. The header: the title, then the X at the right.
let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover());
let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE));
let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y));
let drag = ui.interact(handle, self.id.with("drag"), Sense::drag());
let font = semibold(11.0);
let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone());
ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark));
close = close_button(ui, self.id.with("close"), close_rect, dark, t);
// 2. The header drags the panel (grabbed where the press landed, so the first frame of
// a fast drag counts too); the X keeps its own clicks.
if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) {
self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos);
}
if drag.dragged()
&& let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos())
{
self.place = Place::At(clamp(at - grab, self.size, screen));
}
if ctx.input(|i| i.pointer.any_released()) {
self.grab = None;
}
if drag.dragged() || drag.hovered() {
ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
}
// 3. The slot, under a hairline.
let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| {
ui.spacing_mut().item_spacing = spacing;
ui.set_width(self.width - 2.0 * self.pad);
body(ui);
ui.min_rect()
});
let slot = frame.inner;
let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y));
let shadow = ctx.global_style().visuals.popup_shadow;
let fill = if dark { t.menu } else { t.panel };
let shapes = vec![
egui::Shape::from(shadow.as_shape(panel, RADIUS)),
RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(),
// A point of the theme's border colour under the header.
RectShape::filled(
Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)),
CornerRadius::ZERO,
if dark { Color32::from_white_alpha(24) } else { t.border },
)
.into(),
];
ui.painter().set(bg, egui::Shape::Vec(shapes));
self.rect = panel;
self.header = header;
self.slot = slot;
self.close = close_rect;
self.size = panel.size();
});
// Closing is the widget's: the X above, Escape and a press outside (a child popup or a
// held-out rect keeps it open, so the button that opened it can click it shut).
let popup_open = egui::Popup::is_any_open(ctx);
let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape));
let outside = self.outside
&& !popup_open
&& ctx.input(|i| {
i.pointer.any_pressed()
&& i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at)))
});
close || escape || outside
}
}
fn header_text(t: Tokens, dark: bool) -> Color32 {
if dark { t.menu_text } else { t.text }
}
/// The X: a 24-point button, its icon inset 6 (the colour picker's header).
fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {
let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));
if resp.hovered() {
ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });
}
icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });
resp.clicked()
}
/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on
/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to
/// keep [`SCREEN_GAP`] under it.
fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
let bottom = screen.max.y - SCREEN_GAP;
let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y };
pos2(anchor.x - size.x, y)
}
/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.
fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {
let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);
let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0));
let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0));
pos2(x, y)
}
+175
View File
@@ -0,0 +1,175 @@
//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in
//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule
//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen),
//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press
//! in a held-out rect, so the button that opened it can toggle it shut.
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH};
const SCREEN: egui::Vec2 = vec2(1440.0, 900.0);
/// A context with the UI's fonts: the header's title is drawn in the semibold family.
fn context() -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, false, 0);
ctx
}
fn input(t: &mut f64, events: Vec<Event>) -> RawInput {
*t += 1.0 / 60.0;
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() }
}
fn press(p: Pos2, pressed: bool) -> Vec<Event> {
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]
}
fn escape() -> Vec<Event> {
vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]
}
/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it
/// asked to close.
fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec<Event>) -> (Vec<egui::Shape>, bool) {
let mut closed = false;
let mut out = ctx.run_ui(input(t, events), |ui| {
closed = p.show(ui.ctx(), open, hold, |ui| {
ui.label("slot");
});
});
out.textures_delta.clear();
(crate::live_tests::flat(&out.shapes), closed)
}
/// Two frames: the first lays the panel out to learn its height, the second places and paints it.
fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec<egui::Shape> {
draw(ctx, t, p, open, Hold::default(), vec![]);
draw(ctx, t, p, open, Hold::default(), vec![]).0
}
fn near(a: Pos2, b: Pos2) -> bool {
(a - b).length() < 0.05
}
#[test]
fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_header", "Custom");
let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
assert_eq!(p.rect.width(), WIDTH, "240 points wide");
assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect);
assert_eq!(p.header.height(), HEADER_H, "the header is 40 high");
// The X: 24 square, its right edge 8 in from the panel's, centred on the header's line.
assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close);
assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line");
// The title: one line, 16 in from the left, on the header's middle.
let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows");
assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}");
assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}");
// The slot: 16 in from the panel's sides, under the header.
assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot);
assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot);
}
#[test]
fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_anchor", "Popup");
settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0)));
assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect);
assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);
}
#[test]
fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_lift", "Popup");
settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0)));
// Aligning the top would run it off: it rises so its bottom sits 16 above the screen's.
assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect);
assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect);
}
#[test]
fn the_header_drags_it_and_the_slot_does_not() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_drag", "Popup");
settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
let at = Open::At(pos2(160.0, 100.0));
let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0));
let before = p.rect.min;
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min);
// The slot does not drag it.
let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0));
let held = p.rect.min;
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min);
}
#[test]
fn the_x_and_escape_close_it() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_close", "Popup");
settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
let at = Open::At(pos2(160.0, 100.0));
let close = p.close.center();
assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet");
assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close");
assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close");
// Escape that belongs to something above it does not.
let mut p = Popover::new("popover_escape_held", "Popup");
settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));
let hold = Hold { rects: &[], escape: true };
assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open");
}
#[test]
fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() {
let ctx = context();
let mut t = 0.0;
let mut p = Popover::new("popover_outside", "Popup");
settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0)));
let at = Open::At(pos2(300.0, 200.0));
// The button that opened it: a press there leaves it open, so its release can toggle it shut.
let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0));
let hold = Hold { rects: &[opener], escape: false };
let on_opener = opener.center();
assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open");
assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release");
// A press anywhere else closes it, before the release.
let elsewhere = pos2(900.0, 400.0);
assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it");
}
#[test]
fn a_caller_can_put_it_where_it_likes() {
let ctx = context();
let mut t = 0.0;
// Placed outright, the opening rule no longer matters.
let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0));
settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0)));
assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect);
// Nor after a drag: it stays where it was dropped.
let at = Open::At(pos2(900.0, 100.0));
let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0));
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);
draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));
draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);
assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);
}
+5
View File
@@ -454,6 +454,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
return true;
}
// The colour picker open: Escape closes it here, before the binding deselects — unless one of
// its own dropdowns is open, which takes this Escape.
if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {
return true;
}
if key_box(app, key, m) {
return true;
}
+172 -156
View File
@@ -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<Rect> = vec![panel];
held.extend(app.ui.picker_button);
let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
// The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
if let Some(c) = app.session.tool.picked.take() {
set_color(app, &target, &mut paint, c, stop_id(), ui);
}
// Paint type.
if !matches!(
target,
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
) {
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<Color> = match &paint.kind {
PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
_ => None,
};
let sel_stop_id = stop_id();
if let PaintKind::Gradient(g) = paint.kind.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<Color> = match &paint.kind {
PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),
_ => None,
};
let sel_stop_id = stop_id();
if let PaintKind::Gradient(g) = paint.kind.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)
}
+30 -32
View File
@@ -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<Rect> = picker_rect.into_iter().chain(variables).collect();
let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| {
if p.edit.is_some() {
editor(ui, &doc, &mut p, &mut out);
} else {
listing(app, ui, &doc, &mut p, &mut out);
}
});
let mut close = closed;
let ids = p.ids.clone();
let slot = p.slot;
// The canvas shows the text style under the pointer on the selected text.
@@ -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 });
}
@@ -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.