mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Swatches: Alt-click on a swatch recolors the selected gradient stop
With a gradient stop selected, an Alt-click on a color swatch painted the inactive Fill or Stroke, as it does with no stop selected. The reference app's Default keyboard shortcuts page lists "Apply swatch color to active (or selected) color stop: Alt-click swatch in the Swatches panel" for the Gradient panel. Now, when the active Fill or Stroke is a linear or radial gradient with a selected stop, an Alt-click on a color swatch or a tint swatch recolors that stop in one undo step, and the stop stays selected. The stop changes as a drop of the same swatch on the Gradient panel's ramp changes it (drop_color and dropped_link in panels/gradient.rs): it becomes fully opaque, and a global or spot color or a tint stays linked, so editing the swatch recolors the stop. With a freeform gradient, the selected point takes the color through paint.freeform.setPoint. With no stop or point selected, Alt-click paints the inactive Fill or Stroke as before. The Fill and Stroke chip popovers show the same swatches and behave the same. Docs: docs/control-protocol.md, docs/target-app-parity.md.
This commit is contained in:
@@ -82,6 +82,23 @@ pub(crate) fn dropped_link(app: &VectorcraftApp, params: &Value) -> Option<(Stri
|
||||
Some((base, tint))
|
||||
}
|
||||
|
||||
/// Recolor the selected stop of the gradient behind the active proxy with `c`, as dropping the
|
||||
/// paint of `params` on the stop does (fully opaque, linked by [`dropped_link`]), or the selected
|
||||
/// point of a freeform gradient; the stop or point stays selected. Returns false, and changes
|
||||
/// nothing, when the active paint is not a gradient or no stop or point is selected.
|
||||
pub(crate) fn recolor_selected(app: &mut VectorcraftApp, c: Color, params: &Value) -> bool {
|
||||
let Paint::Gradient(g) = active_paint(app) else { return false };
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
let Some(i) = app.session.selected_freeform_point().filter(|i| *i < shown_points(&g).points.len()) else { return false };
|
||||
edit_point(app, "paint.freeform.setPoint", json!({ "index": i, "color": super::color_json(&c) }));
|
||||
return true;
|
||||
}
|
||||
let Some(i) = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len()) else { return false };
|
||||
let (v, i) = drop_color(&g.gradient, Some(i), 0.0, c, dropped_link(app, params));
|
||||
set_stops(app, &v, Some(i), Live::Released);
|
||||
true
|
||||
}
|
||||
|
||||
/// The stops after dragging stop `i` of `origin` to `offset`: moved, or with `copy` (Alt) a copy
|
||||
/// left there, or the colours swapped with stop `over` when the copy is dropped on it. Returns
|
||||
/// the stops and the stop to select.
|
||||
|
||||
@@ -160,11 +160,18 @@ fn swatch_params(e: &Entry) -> Option<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
|
||||
/// Apply a clicked swatch to the active proxy (Alt: the inactive one). An Alt-click on a color
|
||||
/// swatch with a gradient stop selected recolors the stop as dropping the swatch on it does,
|
||||
/// and with a freeform gradient point selected it recolors the point.
|
||||
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
|
||||
if let Some(params) = swatch_params(e) {
|
||||
super::apply_click(app, ui, params);
|
||||
let Some(params) = swatch_params(e) else { return };
|
||||
if super::alt_held(ui)
|
||||
&& let Some(c) = drag_of(e, vec![]).color()
|
||||
&& super::gradient::recolor_selected(app, c, ¶ms)
|
||||
{
|
||||
return;
|
||||
}
|
||||
super::apply_click(app, ui, params);
|
||||
}
|
||||
|
||||
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
|
||||
@@ -1378,6 +1385,67 @@ mod tests {
|
||||
assert_eq!(selected(&ctx), ["Grays"], "a plain click on a group selects it alone");
|
||||
}
|
||||
|
||||
/// The hex colors of the stops of the selection's gradient fill, and its stroke's color.
|
||||
fn stops_and_stroke(app: &VectorcraftApp) -> (Vec<String>, String) {
|
||||
let (fill, stroke) = crate::panels::current_paints(app);
|
||||
let Paint::Gradient(g) = fill else { panic!("the fill is still a gradient: {fill:?}") };
|
||||
(g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(), stroke.color().map(|c| c.to_hex()).unwrap_or_default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_recolors_the_selected_gradient_stop() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"color": "#000000"})).unwrap();
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap();
|
||||
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0);
|
||||
assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], "#000000".into()), "the stop, not the stroke");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(app.session.selected_stop(), Some(1), "the stop stays selected");
|
||||
assert!(app.session.fill_active, "the Fill proxy stays in front");
|
||||
// With no stop selected, Alt-click paints the inactive proxy.
|
||||
app.run("gradient.selectStop", json!({"index": null})).unwrap();
|
||||
click_tile(&mut app, &ctx, "Orange", Modifiers::ALT, 2.0);
|
||||
let orange = app.session.doc().unwrap().doc.swatch("Orange").and_then(|w| w.paint.color()).unwrap().to_hex();
|
||||
assert_eq!(stops_and_stroke(&app), (vec!["#ffffff".into(), "#ed1c24".into()], orange));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_links_the_selected_stop_to_a_global_swatch() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.run("swatch.edit", json!({"name": "Red", "global": true})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]);
|
||||
app.run("paint.setFill", json!({"gradient": {"stops": stops}})).unwrap();
|
||||
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
|
||||
click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0);
|
||||
let Paint::Gradient(g) = crate::panels::current_paints(&app).0 else { panic!("a gradient fill") };
|
||||
assert_eq!(g.gradient.stops[0].swatch.as_deref(), Some("Red"), "linked as a drop on the ramp links it");
|
||||
app.run("swatch.edit", json!({"name": "Red", "color": "#00ff00"})).unwrap();
|
||||
assert_eq!(stops_and_stroke(&app).0, ["#00ff00", "#000000"], "editing the swatch recolors the stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_click_recolors_the_selected_freeform_point() {
|
||||
let mut app = app();
|
||||
let ctx = context();
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
|
||||
app.run("paint.setStroke", json!({"color": "#000000", "focus": false})).unwrap();
|
||||
app.run("paint.editGradient", json!({"kind": "freeform"})).unwrap();
|
||||
app.run("paint.freeform.selectPoint", json!({"index": 1})).unwrap();
|
||||
click_tile(&mut app, &ctx, "Red", Modifiers::ALT, 0.0);
|
||||
let (fill, stroke) = crate::panels::current_paints(&app);
|
||||
let Paint::Gradient(g) = fill else { panic!("still a gradient: {fill:?}") };
|
||||
assert_eq!(g.freeform.expect("placed points").points[1].color.to_hex(), "#ed1c24");
|
||||
assert_eq!(stroke.color().map(|c| c.to_hex()).as_deref(), Some("#000000"), "the stroke keeps its color");
|
||||
assert_eq!(app.session.selected_freeform_point(), Some(1), "the point stays selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_asks_then_removes_the_selection_and_unlinks_art() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -163,7 +163,9 @@ tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value:
|
||||
samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a
|
||||
swatch, a Fill/Stroke proxy or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the
|
||||
active proxy) with the paint's params and the object's `ids`; a colour dropped on the panel's ramp adds or recolours
|
||||
a stop.
|
||||
a stop. Alt-clicking a color swatch in the Swatches panel recolors the selected stop as a drop on it does
|
||||
(`paint.editGradient {stops}`), or the selected point of a freeform gradient (`paint.freeform.setPoint`); with no stop
|
||||
or point selected, it paints the inactive Fill or Stroke.
|
||||
|
||||
Freeform gradients: double-clicking a point with the Gradient tool opens the same `gradientStop` popover on the selected
|
||||
point (`paint.freeform.selectPoint`); its fields are `color` (hex), `opacity` and `spread` (percentages), applied by
|
||||
|
||||
@@ -579,6 +579,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Sample Color from Screen:** an eyedropper button in the Color Picker and `ui.sampleScreenColor` (which can be given a key in Keyboard Shortcuts) pick a color anywhere on the screen with the system's sampler: `NSColorSampler` on macOS, the EyeDropper API in browsers that have it. The color becomes the fill or the stroke as one undo step, or the Color Picker's color, converted from display sRGB into the working RGB space. Windows, Linux and other browsers have no sampler yet; the button is gray there.
|
||||
- **Stroke visibility (#1253):** adding paint to a previously unpainted zero-width stroke restores the existing 1 pt default (`PaintDefaults::default`, `Appearance::set_stroke`), for the selected appearance item, the new-art defaults and Swap Fill/Stroke. Positive widths are kept; recoloring a painted zero-width stroke keeps its explicit zero. A real Eyedropper-to-new-shape sequence reproduces the zero-width inheritance. Five engine regressions cover creation, defaults, targeted items, preserved widths and one-step Undo/Redo (`crates/engine/tests/stroke_visibility.rs`).
|
||||
- **Gradients:** lossless, validated gradient params; gradients follow rotate, reflect, shear, non-uniform scale and distortions; the Gradient tool edits the fill or stroke of the active proxy, type included; an interactive on-canvas annotator (move, length/angle, rotate, add/move/delete/duplicate stops, midpoints, a stop popover, Delete and arrow keys). The Gradient panel has the Fill/Stroke proxy, a gradient-swatch dropdown with Save to Swatches, midpoint locations, Alt-drag to copy or swap stops, swatch drops on the ramp and a stop eyedropper; copied appearances and graphic styles place gradients on each target's own bounds; gradient handles snap and honour Constrain Angle. Gradient stops stay linked to global and spot swatches. Freeform gradients (points and lines) are edited on the canvas; radial gradients have an extent ellipse, an aspect handle and an off-centre focal point (SVG `fx`/`fy`, PDF two-point radials). A stroke's gradient runs within, along or across it. Object ▸ Expand turns gradients into N objects or a gradient mesh.
|
||||
With a gradient stop or a freeform gradient point selected, Alt-clicking a color swatch in the Swatches panel recolors it instead of the inactive Fill or Stroke.
|
||||
- **Strokes:** one stroke-geometry module shared by the canvas, SVG, PDF and Outline Stroke; dotted lines (zero-length dashes draw dots or squares); arrowheads with hollow outlines, tip-on-end or extend-past-end alignment and one opacity for line and head. SVG and PDF export arrowheads, width profiles, brushes and aligned strokes exactly as the canvas draws them; Outline Stroke and the live Outline Stroke effect outline every stroke item as drawn (dashes, heads, profiles, alignment); dashes can be fitted to corners and path ends; width profiles carry through dashes, and their corners take the join. The Stroke panel edits type's character strokes, follows the stroke units (its weight presets are round values in that unit: `0.25 mm`, `0.0625 in`, `1 px`, #426; fields show four decimals in mm, cm and inches, three in points), shows mixed values blank and opens from the Control bar. A width profile library, thirty more arrowheads, and Width Point Edit with multi-select and discontinuous points. Visual bounds and clicks cover the whole painted stroke. Scale Strokes & Effects, Scale Corners and Use Preview Bounds work everywhere, and new art inherits stroke options, a graphic style or the whole last appearance.
|
||||
- **Brushes (part of #852):** double-clicking a brush in the Brushes panel (or the panel menu's Brush Options…) opens Calligraphic Brush Options (Name; Angle, Roundness and Size, each Fixed, Random or Pressure with its Variation; live previews of the nib and of a light–heavy–light stroke) or Bristle Brush Options (Shape and the bristle sliders); OK edits the brush and every stroke painted with it. The Paintbrush records the pen pressure along each stroke (kept with the path, saved, and joined when a stroke continues a path); a Calligraphic value on Pressure goes from value − variation at the lightest pressure to value + variation at the heaviest, on the canvas and in SVG, PDF and the other exports. Brushes saved before keep their look. Scatter Brush Options (Size, Spacing, Scatter and Rotation, each Fixed, Random between two values or Pressure from the first at the lightest pressure to the second at the heaviest; Rotation relative to Page or Path), Art Brush Options (Width, Scale Proportionately, Stretch to Fit Stroke Length or Stretch Between Guides with the guides' start and end, the four Directions, Flip Along and Across) and Pattern Brush Options (Scale, Spacing, the five tiles shown, Flip Along and Across, Stretch to fit, Add space to fit or Approximate path) each have Colorization (None, Tints, Tints and Shades, Hue Shift with its Key Color picked from the art's colours) and a live stroke preview; values from files and commands are clamped to their ranges. Still to come: the Art brush's Overlap, auto-generated Pattern brush corners, new tile art picked in Pattern Brush Options, Stylus Wheel, Tilt, Bearing and Rotation, and pen pressure on macOS.
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
|
||||
Reference in New Issue
Block a user