M4: the colour picker closes like every other detached panel

The picker draws on the shared popover, so it closes the way the popover does: the X,
Escape, and a press outside it.

- A press on the canvas closes it; presses in the panel beside it keep it, since a
  swatch, a style chip or a field there switches the colour it edits.
- The control that opened it is held out of that closing and its rect is refreshed every
  frame, so pressing the same swatch again reaches its own toggle — after the panel
  scrolls too (the bug #730 had, in the picker's hands).
- Escape closes it before the binding deselects the layer, and not while one of its own
  dropdowns is open.

Tests: a press on the canvas closes it, the same swatch closes it again (panel_remove_tests).
This commit is contained in:
叶林橦
2026-10-20 12:24:09 +08:00
parent 3862a2db88
commit 50b978b8f9
5 changed files with 63 additions and 2 deletions
+18 -2
View File
@@ -465,8 +465,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0));
// The colour picker's own header — title 16 in, the X 8 in from the right — is the shared
// popover chrome now; the picker keeps its body.
let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0).close_on_outside(false));
let closed = pop.show(ctx, Open::At(pos), Hold::default(), |ui| {
let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0));
// Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel
// beside it keep it (swatches and fields there switch the colour it edits); the control that
// opened it is held too, so pressing it again reaches its own toggle.
let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max);
let mut held: Vec<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);
// Paint type.
if !matches!(
@@ -855,10 +861,20 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
/// The colour picker closes (its X, Escape, or another popover took over): pending edits commit
/// and the picker with its popover go.
/// Escape with the picker open closes it (the selection stays).
pub fn close_if_open(app: &mut FigApp) -> bool {
if app.ui.picker.is_none() {
return false;
}
close(app);
true
}
pub fn close(app: &mut FigApp) {
finish(app);
app.ui.picker = None;
app.ui.picker_popover = None;
app.ui.picker_button = None;
}
/// Swatches wrapped in rows inset 16 points from both sides of the picker.
+4
View File
@@ -490,6 +490,9 @@ pub struct UiState {
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.
@@ -635,6 +638,7 @@ impl Default for UiState {
preview: None,
picker: None,
picker_popover: None,
picker_button: None,
context_menu: None,
key_menu: false,
key_box: None,
+33
View File
@@ -167,3 +167,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() {
assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));
assert_eq!(u.app.ui.picker, None);
}
/// The shared popover's closing reaches the colour picker: a press on the canvas closes it.
#[test]
fn a_press_on_the_canvas_closes_the_colour_picker() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
let sw = u.ctx.read_response(Id::new(("swatch", true, 0usize))).unwrap().rect.center();
click(&mut u, sw);
assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
click(&mut u, Pos2::new(320.0, 300.0));
assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker");
}
/// Pressing the swatch that opened the picker again closes it: its press is held out of the
/// popover's outside-press closing, so the swatch's own toggle gets the click.
#[test]
fn the_swatch_that_opened_the_colour_picker_closes_it_again() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.enable_accesskit();
let sw = u.ctx.read_response(Id::new(("swatch", true, 0usize))).unwrap().rect.center();
click(&mut u, sw);
assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");
click(&mut u, sw);
assert_eq!(u.app.ui.picker, None, "the same swatch closes it again");
}
+3
View File
@@ -1547,6 +1547,9 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
widgets::paint_swatch(ui.painter(), sw, p);
let sresp = ui.interact(sw.expand(2.0), Id::new(("swatch", fills, i)), Sense::click());
if app.ui.picker.as_ref() == Some(&crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }) {
app.ui.picker_button = Some(sresp.rect);
}
if sresp.clicked() {
app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y));
}
+5
View File
@@ -241,6 +241,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;
}