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 <nrq6993-a11y@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-10-22 14:35:56 +07:00
committed by bflatastic
co-authored by nrq6993-a11y
parent af520a7cf3
commit 5a7689c3b4
2 changed files with 75 additions and 4 deletions
@@ -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<Picked> {
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<bool> = 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);
}
}
+10 -4
View File
@@ -1099,10 +1099,16 @@ pub fn popover<R>(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::<PanelDrag>(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 =