mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #6926 from dotCooCoo/feat/recent-colors-to-swatches
Color: a recent color drags into the Swatches panel to become a swatch
This commit is contained in:
@@ -528,7 +528,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str {
|
||||
|
||||
/// "Recent Colors" header + rows of chips (the Session's recent colours, which every paint
|
||||
/// command feeds), wrapped onto as many rows as the panel's width needs; returns the one clicked,
|
||||
/// for the caller to apply.
|
||||
/// for the caller to apply. A chip drags its color as a solid paint, which the Swatches panel
|
||||
/// makes a swatch of.
|
||||
pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
crate::widgets::subheader(ui, tl!("Recent Colors"));
|
||||
@@ -543,7 +544,8 @@ pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Col
|
||||
for (i, c) in colors.iter().enumerate() {
|
||||
let (row, col) = (i / per_row, i % per_row);
|
||||
let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0));
|
||||
let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click());
|
||||
let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click_and_drag());
|
||||
crate::widgets::drag_source(ui, &resp, || crate::widgets::PanelDrag::paint(Paint::solid(*c)));
|
||||
crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered());
|
||||
if resp.on_hover_text(c.to_hex()).clicked() {
|
||||
chosen = Some(*c);
|
||||
|
||||
@@ -59,9 +59,10 @@ pub(super) enum Drop {
|
||||
/// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it
|
||||
/// gives that swatch its paint instead of moving.
|
||||
Move { names: Vec<String>, target: Option<String>, after: bool, with: Option<String> },
|
||||
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
|
||||
/// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch
|
||||
/// `target` when that is of its kind and not a tint swatch.
|
||||
/// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's
|
||||
/// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held
|
||||
/// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration
|
||||
/// nor a tint swatch.
|
||||
New { paint: Paint, target: Option<String>, replace: bool },
|
||||
/// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays).
|
||||
Pattern { ids: Vec<u64> },
|
||||
@@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
|
||||
None => return,
|
||||
},
|
||||
// Alt: an edited gradient (or colour) replaces the swatch it is dropped on.
|
||||
Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => {
|
||||
Drop::New { paint, target: Some(t), replace: true }
|
||||
if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) =>
|
||||
{
|
||||
("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)}))
|
||||
}
|
||||
Drop::New { paint, target, .. } => {
|
||||
@@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
|
||||
}
|
||||
|
||||
/// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches,
|
||||
/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance
|
||||
/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the
|
||||
/// thumbnail of a dragged graphic style.
|
||||
/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the
|
||||
/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged
|
||||
/// off the canvas), or the thumbnail of a dragged graphic style.
|
||||
pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return };
|
||||
let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint());
|
||||
|
||||
@@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim
|
||||
|
||||
/// [`drag`] with `modifiers` held.
|
||||
fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) {
|
||||
drag_drawn(app, ctx, from, to, modifiers, time, show);
|
||||
}
|
||||
|
||||
/// [`drag_with`] over the panels `draw` draws.
|
||||
fn drag_drawn(
|
||||
app: &mut VectorcraftApp,
|
||||
ctx: &egui::Context,
|
||||
from: Pos2,
|
||||
to: Pos2,
|
||||
modifiers: Modifiers,
|
||||
time: f64,
|
||||
draw: fn(&mut VectorcraftApp, &mut Ui),
|
||||
) {
|
||||
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers };
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw);
|
||||
frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw);
|
||||
}
|
||||
|
||||
fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec<String> {
|
||||
@@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() {
|
||||
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||
}
|
||||
|
||||
/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the
|
||||
/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps
|
||||
/// in the panel state `"test-color-scope"`.
|
||||
fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 {
|
||||
let mut chip = None;
|
||||
let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0)));
|
||||
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| {
|
||||
draw(app, ui);
|
||||
let scope = if color { pstate::<Option<egui::Id>>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() };
|
||||
chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
chip.unwrap_or_else(|| panic!("recent chip {i} is drawn"))
|
||||
}
|
||||
|
||||
/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color
|
||||
/// first and the CMYK one second; → its id and the CMYK color.
|
||||
fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) {
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap();
|
||||
app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap();
|
||||
let cmyk = app.session.recent_colors[1];
|
||||
assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}");
|
||||
(vectorcraft_doc::NodeId(id), cmyk)
|
||||
}
|
||||
|
||||
/// The fill of art `id`.
|
||||
fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option<Paint> {
|
||||
app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint())
|
||||
}
|
||||
|
||||
/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color
|
||||
/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added
|
||||
/// again under another name.
|
||||
#[test]
|
||||
fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let (id, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let art = fill_of(&app, id);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len());
|
||||
// Onto a grouped swatch: into that group, at its end.
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false);
|
||||
drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0);
|
||||
let d = app.session.doc().unwrap();
|
||||
let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches;
|
||||
assert_eq!(group.len(), brights + 1, "{}", app.ui.status);
|
||||
let first = group.last().cloned().expect("a swatch");
|
||||
assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK");
|
||||
assert_eq!(d.history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(fill_of(&app, id), art, "the drag paints no art");
|
||||
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||
// The same color again, onto an ungrouped swatch: a second swatch under another name.
|
||||
frame(&mut app, &ctx, vec![], 2.0, show);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false);
|
||||
drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0);
|
||||
let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch");
|
||||
assert_eq!(newest.paint, Paint::solid(cmyk));
|
||||
assert_ne!(newest.name, first.name, "another name");
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2);
|
||||
// Undo takes the last one off.
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1);
|
||||
}
|
||||
|
||||
/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves
|
||||
/// Registration as it is.
|
||||
#[test]
|
||||
fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
let (_, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||
let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone());
|
||||
let count = app.session.doc().unwrap().doc.swatches_iter().count();
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false);
|
||||
drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status);
|
||||
assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk)));
|
||||
assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged");
|
||||
}
|
||||
|
||||
/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click
|
||||
/// on it still paints the selected art.
|
||||
#[test]
|
||||
fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() {
|
||||
fn both(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
show(app, ui);
|
||||
ui.push_id("color", |ui| {
|
||||
crate::panels::color::show(app, ui);
|
||||
set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id()));
|
||||
});
|
||||
}
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.ui.color_show_recent_colors = true;
|
||||
let (id, cmyk) = art_with_two_recent_colors(&mut app);
|
||||
frame(&mut app, &ctx, vec![], 0.0, both);
|
||||
let count = app.session.doc().unwrap().doc.swatches_iter().count();
|
||||
let art = fill_of(&app, id);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true);
|
||||
drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both);
|
||||
let d = &app.session.doc().unwrap().doc;
|
||||
assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status);
|
||||
assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk)));
|
||||
assert_eq!(fill_of(&app, id), art, "the drag paints no art");
|
||||
// A click on the chip paints the selected art.
|
||||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||||
frame(&mut app, &ctx, vec![], 2.0, both);
|
||||
let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true);
|
||||
let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
||||
frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both);
|
||||
assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -4,12 +4,13 @@ use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2};
|
||||
/// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PanelDrag {
|
||||
/// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it
|
||||
/// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group,
|
||||
/// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the
|
||||
/// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes
|
||||
/// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is
|
||||
/// (library id, colour name), which that panel moves to where it is dropped.
|
||||
/// A paint: Swatches panel tiles, a Fill/Stroke proxy, a Recent Colors chip or the Gradient
|
||||
/// panel's thumbnail. Art it is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…;
|
||||
/// null for a colour group, which paints nothing) through the active proxy's
|
||||
/// `paint.setFill`/`paint.setStroke`; the Gradient panel's ramp takes its colour as a stop; the
|
||||
/// Swatches panel moves `rows`, or makes a swatch of a paint dragged from elsewhere. `library`:
|
||||
/// the Libraries panel colour it is (library id, colour name), which that panel moves to where
|
||||
/// it is dropped.
|
||||
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows>, library: Option<(String, String)> },
|
||||
/// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance
|
||||
/// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it.
|
||||
@@ -68,9 +69,9 @@ pub struct SwatchRows {
|
||||
}
|
||||
|
||||
impl PanelDrag {
|
||||
/// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient
|
||||
/// carries no placement: it fits the art it lands on (freeform points are placed afresh there,
|
||||
/// coloured like the dragged ones).
|
||||
/// `paint` dragged from a Fill/Stroke proxy, a Recent Colors chip or the Gradient panel's
|
||||
/// thumbnail. A gradient carries no placement: it fits the art it lands on (freeform points are
|
||||
/// placed afresh there, coloured like the dragged ones).
|
||||
pub fn paint(mut paint: Paint) -> Self {
|
||||
if let Paint::Gradient(g) = &mut paint {
|
||||
g.geom = None;
|
||||
|
||||
@@ -1335,6 +1335,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode").
|
||||
Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`.
|
||||
Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`.
|
||||
A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`.
|
||||
A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them.
|
||||
View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value.
|
||||
The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`).
|
||||
|
||||
Reference in New Issue
Block a user