From 5a7689c3b44e47860e5fa0d17acb242307b255d8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 05:18:16 +0000 Subject: [PATCH 1/2] 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 = From 36eed1ac777c0695d6631acfa6dd58b5e165bbc6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 07:33:36 +0000 Subject: [PATCH 2/2] Keep fill/stroke chip popover open during proxy drags ## Summary The fill/stroke chip popover no longer closes while the pointer is down, a panel paint drag is active, or the opening click is still on the chip anchor, so dragging fill onto stroke (or the reverse) inside the popover can finish. Color Picker proxies are not added; the reference app edits only the colour the picker was opened for. ## Tests - panels::tests::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 ---------------------- 1 file changed, 65 deletions(-) diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 78751b865..d79b3d46d 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -203,40 +203,6 @@ 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). @@ -347,10 +313,6 @@ 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; @@ -1311,31 +1273,4 @@ 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); - } }