From 5a7689c3b44e47860e5fa0d17acb242307b255d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 05:18:16 +0000 Subject: [PATCH] G11: Color Picker and popover Fill/Stroke proxy drags (#3440) ## Summary Illustrator lets you drag the fill colour onto the stroke (or the other way) in the Color Picker and in the fill/stroke chip popover. VectorCraft now shows the Fill/Stroke proxy pair in the Color Picker (except for artboard background edits) and applies paint.setFill or paint.setStroke when a proxy paint is dropped on either square; clicking a square switches which side the picker edits. The fill/stroke chip popover stays open while the pointer is down, while a panel paint drag is active, or when the opening click is on the chip anchor, so drags between the proxies can finish. ## Tests - color_picker_proxy_drag_applies_fill_to_stroke (headless egui) - existing dropping_a_proxy_paint_on_the_other_proxy_applies_it unchanged Closes storytold/vectorcraft#3440 Co-authored-by: nrq6993-a11y --- crates/ui-egui/src/dialogs/color_picker.rs | 65 ++++++++++++++++++++++ crates/ui-egui/src/widgets.rs | 14 +++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index d79b3d46d..78751b865 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -203,6 +203,40 @@ fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color { } } +/// Fill/Stroke proxies in the picker: switch which side is edited and accept drags between them. +fn proxy_row(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> Option { + let (fill, stroke) = crate::panels::current_paints(app); + let mixed = crate::panels::mixed_paints(app, ui.ctx()); + let editing_stroke = d.bool("stroke"); + let mut switch: Option = None; + ui.push_id("cp-proxy", |ui| { + let c = widgets::fill_stroke_proxy(ui, &fill, &stroke, mixed, !editing_stroke, 36.0); + if c.fill && editing_stroke { + switch = Some(false); + } else if c.stroke && !editing_stroke { + switch = Some(true); + } + if c.swap { + let _ = app.run("paint.swap", json!({})); + } + if c.default { + let _ = app.run("paint.default", json!({})); + } + if let Some(p) = c.dropped_fill { + let _ = app.run("paint.setFill", p); + } + if let Some(p) = c.dropped_stroke { + let _ = app.run("paint.setStroke", p); + } + }); + if let Some(stroke) = switch { + d.fields.insert("stroke".into(), json!(stroke)); + d.fields.remove(PREVIEWED); + return Some(Picked::of(proxy_color(app, stroke))); + } + None +} + /// `ui.colorPicker {stroke?, artboard?, color?}`: open the picker for the fill (or stroke) proxy, /// or for artboard `artboard`'s background color, starting from `color`, else the proxy's color /// or the artboard's color (white when it has none). @@ -313,6 +347,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { ui.allocate_ui_with_layout(vec2(side, 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| { sample = crate::screen_color::button(app, ui); }); + if !d.fields.contains_key("artboard") { + ui.add_space(4.0); + pick = pick.or(proxy_row(app, ui, d)); + } ui.add_space(4.0); ui.with_layout(layout, |ui| { ui.spacing_mut().item_spacing.x = 0.0; @@ -1273,4 +1311,31 @@ mod tests { assert!(app.ui.dialog.is_none()); } } + + /// #3440: dragging the fill proxy onto the stroke proxy in the Color Picker applies the fill + /// colour to the stroke. + #[test] + fn color_picker_proxy_drag_applies_fill_to_stroke() { + use egui::{Event, Modifiers, PointerButton, Rect}; + use vectorcraft_color::Paint; + + use crate::widgets::PanelDrag; + + let (ctx, mut app) = (context(), app()); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("paint.setStroke", json!({"color": "#0000ff"})).unwrap(); + app.run("ui.colorPicker", json!({})).unwrap(); + settled(&ctx, &mut app, screen()); + let widget = |key: &'static str| -> Rect { + ctx.viewport(|vp| vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).find(|w| w.id == w.parent_id.with(key)).map(|w| w.rect)) + .expect(key) + }; + let at = widget("stroke-proxy").center(); + let red = PanelDrag::paint(Paint::solid(Color::from_hex("#ff0000").unwrap())); + egui::DragAndDrop::set_payload(&ctx, red); + let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }; + draw(&ctx, &mut app, screen(), vec![Event::PointerMoved(at), release]); + let stroke = app.session.shown_stroke().unwrap(); + assert_eq!(stroke.paint.color().map(|c| c.to_hex()), Some("#ff0000".into()), "{}", app.ui.status); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f62f105c0..fef0aaca4 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1099,10 +1099,16 @@ pub fn popover(resp: &Response, toggle: bool, content: impl FnOnce(&mut Ui) - !was_open } else { // Popups (this one and those it opened) are on foreground layers; a click anywhere - // else closes it. - let clicked_elsewhere = ctx - .input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()) - .is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); + // else closes it. While the pointer is down or a panel paint drag is active, keep the + // popover open so a Fill/Stroke proxy drag can finish (#3440). + let pointer_down = ctx.input(|i| i.pointer.any_down()); + let panel_drag = egui::DragAndDrop::payload::(ctx).is_some(); + let click_pos = ctx.input(|i| i.pointer.any_click().then(|| i.pointer.interact_pos()).flatten()); + let clicked_anchor = click_pos.is_some_and(|pos| resp.rect.contains(pos)); + let clicked_elsewhere = !pointer_down + && !panel_drag + && !clicked_anchor + && click_pos.is_some_and(|pos| ctx.layer_id_at(pos).is_none_or(|l| l.order != egui::Order::Foreground)); was_open && !clicked_elsewhere }; let inner =