M3.33: Gradient panel: proxy, swatch menu, diamond location, stop popover, Alt-drag copies and swaps, swatch drops, stop eyedropper

The Gradient panel gains the Fill/Stroke proxy, a thumbnail menu of the document's gradient swatches with Save to
Swatches, Angle and Aspect Ratio fields with preset menus (the shared spin field), Show/Hide Options, and a stop
colour row whose eyedropper samples the next colour clicked on art into the selected stop (Eyedropper tool option
`stop`, `paint.sampleColor {stop}`). On the slider a selected midpoint diamond shows and sets its midpoint in
Location, double-clicking a stop opens the shared stop popover, Alt-drag copies a stop and Alt-dropping it on
another swaps their colours (`swap_stop_colors`), and a dropped colour swatch adds or recolours a stop. Clicking a
stop no longer falls through to the add-stop strip. Swatch tiles and the gradient thumbnail drag onto art, which
paints the object under the pointer through `paint.setFill`/`paint.setStroke {ids}`. The panel, the popover and
the Color panel share one stop-writing helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-04 23:00:38 +07:00
parent 6bd27a05da
commit 059f623410
13 changed files with 897 additions and 205 deletions

View File

@ -168,6 +168,18 @@ pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<Gra
(v, ni)
}
/// Swap the colours of stops `a` and `b` (Alt-dropping one stop on another); offsets, opacities
/// and midpoints stay. Out-of-range indices leave the stops unchanged.
pub fn swap_stop_colors(stops: &[GradientStop], a: usize, b: usize) -> Vec<GradientStop> {
let mut v = stops.to_vec();
if a < v.len() && b < v.len() {
let c = v[a].color;
v[a].color = v[b].color;
v[b].color = c;
}
v
}
/// Set the midpoint between stop `i` and `i + 1` (clamped to the diamond's 13–87 %).
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
let mut v = stops.to_vec();
@ -480,6 +492,16 @@ mod tests {
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
}
#[test]
fn swap_exchanges_only_the_colours() {
let mut g = g3();
g.stops[0].opacity = 0.25;
let v = swap_stop_colors(&g.stops, 0, 1);
assert_eq!((v[0].color.to_hex(), v[1].color.to_hex()), ("#ff0000".to_string(), "#ffffff".to_string()));
assert_eq!((v[0].offset, v[0].opacity, v[1].offset, v[1].opacity), (0.0, 0.25, 0.5, 1.0));
assert_eq!(swap_stop_colors(&g.stops, 0, 9), g.stops);
}
#[test]
fn midpoint_math() {
let g = g3();

View File

@ -3,6 +3,7 @@
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
@ -26,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Sample Color",
[],
None,
"{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)",
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient)",
has_doc,
sample_color
),
@ -162,13 +163,27 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
// ---------- eyedropper ----------
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "paint.sampleColor";
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?;
let mut q = json!({ "color": c });
let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
let (id, mut q) = match p.get("stop") {
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
Some(v) => {
let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize;
let color = color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?;
let (fill, stroke_paint) = s.proxy_paints();
let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else {
return Err(bad(C, "the paint behind the proxy is not a gradient"));
};
let mut stops = g.gradient.stops;
let n = stops.len();
stops.get_mut(i).ok_or_else(|| bad(C, format!("no stop {i} (the gradient has {n})")))?.color = color;
("paint.editGradient", json!({ "stops": vectorcraft_tools::params::stops_json(&stops), "stroke": stroke }))
}
};
if let Some(ids) = p.get("ids") {
q["ids"] = ids.clone();
}
let id = if stroke { "paint.setStroke" } else { "paint.setFill" };
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.run)(s, &q)
}

View File

@ -754,6 +754,8 @@ mod tests_file;
#[cfg(test)]
mod tests_gradient;
#[cfg(test)]
mod tests_gradpanel;
#[cfg(test)]
mod tests_live;
#[cfg(test)]
mod tests_menucmds;

View File

@ -0,0 +1,47 @@
//! The Gradient panel's engine side (M3.33): sampling a colour into a gradient stop.
use serde_json::json;
use vectorcraft_color::Paint;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn stop_hexes(s: &Session, id: NodeId, stroke: bool) -> Vec<String> {
let a = &s.doc().unwrap().doc.node(id).unwrap().appearance;
match if stroke { a.stroke_paint() } else { a.fill_paint() } {
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color.to_hex()).collect(),
p => panic!("expected a gradient, got {p:?}"),
}
}
#[test]
fn sample_color_with_a_stop_recolours_that_stop_in_one_undo_step() {
let mut s = session();
let id = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
// A solid fill: no stop to recolour.
let err = s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 0})).unwrap_err();
assert!(err.to_string().contains("not a gradient"), "{err}");
s.execute("paint.setFill", &json!({"gradient": {}})).unwrap();
s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 1})).unwrap();
assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#ff0000"]);
assert!(s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 2})).unwrap_err().to_string().contains("no stop 2"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#000000"]);
// The stroke's gradient through `stroke`, the fill left alone.
s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap();
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stop": 0, "stroke": true})).unwrap();
assert_eq!(
(stop_hexes(&s, id, true), stop_hexes(&s, id, false)),
(vec!["#00ff00".into(), "#000000".into()], vec!["#ffffff".into(), "#000000".into()])
);
}

View File

@ -33,7 +33,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
"scale" => Box::new(TransformTool::new(TransformKind::Scale)),
"shear" => Box::new(TransformTool::new(TransformKind::Shear)),
"freeTransform" => Box::new(FreeTransformTool::default()),
"eyedropper" => Box::new(EyedropperTool),
"eyedropper" => Box::new(EyedropperTool::default()),
"gradient" => Box::new(GradientTool::default()),
"artboard" => Box::new(ArtboardTool::default()),
"magicWand" => Box::new(MagicWandTool),
@ -79,7 +79,7 @@ pub(crate) fn rect_corners(r: Rect) -> [Point; 4] {
}
/// The node that owns the appearance for a hit leaf (a compound path's child → the compound).
pub(crate) fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId {
pub fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId {
match doc.parent_of(leaf) {
Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p,
_ => leaf,

View File

@ -3,8 +3,12 @@
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
//! the active fill/stroke (`paint.sampleColor`).
//!
//! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click
//! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor
//! {stop}`) and switches back to that tool.
use serde_json::json;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::hit::{HitKind, hit_test};
use vectorcraft_geom::Point;
@ -12,7 +16,11 @@ use vectorcraft_geom::Point;
use super::paint_owner;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
pub struct EyedropperTool;
#[derive(Default)]
pub struct EyedropperTool {
/// Sampling for the selected gradient stop: the tool to switch back to afterwards.
stop: Option<String>,
}
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
/// their aspect ratio).
@ -41,19 +49,31 @@ impl Tool for EyedropperTool {
let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] };
let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] };
if ev.mods.shift {
let stop = self.stop.as_ref().zip(cx.gradient_stop);
if ev.mods.shift || stop.is_some() {
let fill_none = n.proxy_paint(false, None).is_none_or(|(p, ..)| p.is_none());
let Some((paint, to_doc, bounds)) = n.proxy_paint(h.kind == HitKind::Stroke || fill_none, None) else { return vec![] };
// Sample in the paint's own space (text space for type runs) and box.
let p = if to_doc.determinant().abs() > 1e-12 { to_doc.inverse() * ev.pos } else { ev.pos };
return match paint_color_at(paint, Some(bounds), p) {
Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))],
None => vec![],
};
let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
let out = vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex(), "stop": i })), Action::SwitchTool(back.clone())];
self.stop = None;
return out;
}
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
}
fn options(&self) -> Value {
json!({ "stop": self.stop })
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "stop" {
self.stop = value.as_str().map(str::to_string);
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper
}
@ -71,7 +91,7 @@ mod tests {
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = EyedropperTool;
let mut t = EyedropperTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
let shift = Mods { shift: true, ..Default::default() };
@ -83,6 +103,26 @@ mod tests {
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
}
#[test]
fn the_stop_option_samples_into_the_selected_stop_then_switches_back() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
let mut t = EyedropperTool::default();
t.set_option("stop", &json!("gradient"));
assert_eq!(t.options(), json!({"stop": "gradient"}));
// Without a selected stop the click copies the appearance as usual.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom"));
cx.gradient_stop = Some(1);
// Empty canvas: still waiting.
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000", "stop": 1})), Action::SwitchTool("gradient".into())]);
assert_eq!(t.options(), json!({"stop": null}), "one sample only");
}
#[test]
fn gradient_sampled_along_vector() {
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
@ -109,7 +149,7 @@ mod tests {
let (s, p) = (Selection::default(), paint());
let shift = Mods { shift: true, ..Default::default() };
// 80 % along the baseline (text space (80, -5)).
let a = EyedropperTool.pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 105.0, 180.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
}

View File

@ -136,6 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
handle_input(app, ui, &resp, rect);
let v = *app.view().unwrap_or(&View::default());
let xf = Xf::new(rect, &v);
paint_drop(app, &resp, &xf);
let painter = ui.painter_at(rect);
let Some(st) = app.session.active() else { return };
let doc = st.doc.clone();
@ -667,14 +668,30 @@ fn node_outline(n: &Node) -> BezPath {
bp
}
/// The art under the pointer (3 screen pixels of tolerance).
fn hit_under_pointer(app: &VectorcraftApp, xf: &Xf) -> Option<vectorcraft_doc::hit::Hit> {
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false };
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, app.hover_doc?, opt)
}
/// A paint dragged out of a panel (a swatch, the Gradient panel's thumbnail) and dropped on art
/// paints the active proxy of the object under the pointer (`paint.setFill`/`paint.setStroke`
/// with its `ids`), selected or not.
fn paint_drop(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) {
let Some(d) = resp.dnd_release_payload::<widgets::PaintDrag>() else { return };
let Some(hit) = hit_under_pointer(app, xf) else { return };
let Some(st) = app.session.active() else { return };
let mut params = d.params.clone();
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
app.run(crate::panels::proxy_cmd(app, false), params).ok();
}
fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) {
let Some(h) = app.hover_doc else { return };
if app.session.tool_busy() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(st) = app.session.active() else { return };
let opt = vectorcraft_doc::hit::HitOptions { tol: 3.0 / xf.zoom, outline: app.ui.view.outline, path_only: false };
let Some(hit) = vectorcraft_doc::hit::hit_test(&st.doc, h, opt) else { return };
let Some(hit) = hit_under_pointer(app, xf) else { return };
let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf };
if st.selection.contains(id) {
return;
@ -1081,4 +1098,31 @@ mod tests {
assert_eq!(app.view().unwrap().center, after.center);
assert_eq!(app.session.active().unwrap().doc.art_bounds(), None);
}
#[test]
fn a_paint_dropped_on_art_paints_that_object_selected_or_not() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let id = app.session.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
app.session.execute("select.none", &json!({})).unwrap();
let ctx = egui::Context::default();
frame(&mut app, &ctx, vec![]);
let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(150.0, 150.0));
let drop = |app: &mut VectorcraftApp, at: Pos2, params: serde_json::Value| {
egui::DragAndDrop::set_payload(&ctx, widgets::PaintDrag { paint: vectorcraft_color::Paint::None, params });
frame(app, &ctx, vec![egui::Event::PointerMoved(at)]);
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
frame(app, &ctx, vec![up]);
};
drop(&mut app, at, json!({"color": "#ff0000"}));
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
assert_eq!(fill(&app).color().map(|c| c.to_hex()), Some("#ff0000".into()));
assert!(app.session.active().unwrap().selection.is_empty(), "the drop does not select");
// Off the art nothing changes.
drop(&mut app, at - vec2(0.0, 120.0), json!({"color": "#00ff00"}));
assert_eq!(fill(&app).color().map(|c| c.to_hex()), Some("#ff0000".into()));
// A gradient fits the object it lands on.
drop(&mut app, at, json!({"gradient": {}}));
assert!(matches!(fill(&app), vectorcraft_color::Paint::Gradient(g) if g.geom.is_none()));
}
}

View File

@ -1,9 +1,10 @@
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it
//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
//! closes on Escape or a click elsewhere.
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the
//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
//! or a document swatch), opacity and location. It edits the selected stop
//! (`gradient.selectStop`) through `paint.editGradient`, and closes on Escape or a click elsewhere.
//!
//! Fields: `index`, `x`, `y` (the chip, document coordinates) and `tab` (`color` or `swatches`).
//! Fields: `index`, `x`, `y` (the chip, document coordinates) or `screen` ([x, y], screen points,
//! from the panel), and `tab` (`color` or `swatches`).
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
use egui::{Sense, vec2};
@ -15,8 +16,8 @@ use vectorcraft_geom::Point;
use super::{DialogResult, DialogSpec};
use crate::VectorcraftApp;
use crate::canvas::Xf;
use crate::panels::gradient::stops_json;
use crate::panels::{active_paint, live_run};
use crate::panels::active_paint;
use crate::panels::gradient::set_stops;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, Live};
@ -30,15 +31,6 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
Some((g.gradient.stops, i))
}
/// Write `stops` to the gradient and select stop `i`.
fn apply(app: &mut VectorcraftApp, stops: &[GradientStop], i: usize, phase: Live) {
let params = json!({ "stops": stops_json(stops), "stroke": !app.session.fill_active });
live_run(app, "Gradient", "paint.editGradient", params, phase);
if phase == Live::Released && app.session.selected_stop() != Some(i) {
app.run("gradient.selectStop", json!({ "index": i })).ok();
}
}
/// `stops` with stop `i` given `color`.
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
stops[i].color = color;
@ -60,9 +52,13 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
};
let t = Tokens::get(ctx);
// Opened from the Gradient panel: at `screen`; from the annotator: beside the chip.
let screen =
d.fields.get("screen").and_then(Value::as_array).and_then(|a| Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)));
let chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
let pos =
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0));
let pos = screen.unwrap_or_else(|| {
app.canvas_rect.zip(app.view().copied()).map_or(ctx.content_rect().center(), |(r, v)| Xf::new(r, &v).to_screen(chip) + vec2(14.0, 14.0))
});
let swatches = d.str("tab") == "swatches";
let mut tab = None;
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
@ -93,12 +89,12 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
if let Some(v) = widgets::plain_field(ui, "stop-pop-op", s.opacity as f64 * 100.0, "%", 0, 54.0) {
let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
apply(app, &v2, i, Live::Released);
set_stops(app, &v2, Some(i), Live::Released);
}
widgets::dim_label(ui, "Location:");
if let Some(v) = widgets::plain_field(ui, "stop-pop-loc", s.offset as f64 * 100.0, "%", 1, 54.0) {
let (v2, ni) = move_stop(&stops, i, (v / 100.0) as f32);
apply(app, &v2, ni, Live::Released);
set_stops(app, &v2, Some(ni), Live::Released);
}
});
});
@ -133,7 +129,7 @@ fn swatch_grid(app: &mut VectorcraftApp, ui: &mut egui::Ui, stops: &[GradientSto
}
});
if let Some(c) = pick {
apply(app, &recolor(stops.to_vec(), i, c), i, Live::Released);
set_stops(app, &recolor(stops.to_vec(), i, c), Some(i), Live::Released);
}
}
@ -150,7 +146,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> DialogResult {
if d.fields.contains_key("location") {
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
}
apply(app, &stops, i, Live::Released);
set_stops(app, &stops, Some(i), Live::Released);
app.ui.dialog = None;
Ok(json!({ "index": i }))
}

View File

@ -299,8 +299,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
if let Some(s) = stops.get_mut(*index) {
s.color = c;
}
let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)});
live_run(app, "Gradient", "paint.editGradient", params, phase);
super::gradient::set_stops(app, &stops, None, phase);
}
_ => {
let cmd = super::proxy_cmd(app, behind);

View File

@ -1,23 +1,41 @@
//! Gradient panel: type buttons, angle / aspect ratio / reverse, the gradient slider with draggable
//! stops and midpoint diamonds (click below the ramp adds a stop, drag a stop off to remove it),
//! and stop Opacity / Location fields. Stop colours are edited with the Color panel.
//! Gradient panel: the gradient thumbnail (drag it onto art; its menu lists the document's
//! gradient swatches and Save to Swatches), type buttons, the Fill/Stroke proxy, angle and aspect
//! ratio fields with preset menus, Reverse, the gradient slider and the selected stop's fields.
//!
//! On the slider: click below the ramp to add a stop, drag a stop to move it (Alt drags a copy;
//! Alt-dropping it on another stop swaps their colours), drag it off to remove it, double-click it
//! for the stop popover, and drag a diamond to move a midpoint (a selected diamond's midpoint
//! shows in Location). Dropping a colour swatch on the ramp adds a stop of that colour, or
//! recolours the stop it lands on. Hide Options leaves the thumbnail, the proxy and the slider.
use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use egui::{Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::{Value, json};
use vectorcraft_color::gradient::{MIN_STOPS, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint};
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_color::gradient::{
MIN_STOPS, duplicate_stop, insert_stop, midpoint_from_pos, midpoint_pos, move_stop, remove_stop, set_midpoint, swap_stop_colors,
};
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::NodeId;
use super::{active_paint, live_run, pstate, set_pstate};
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::widgets::{self, Live, menu_item};
use crate::widgets::{self, Live, PaintDrag, menu_item};
use crate::{VectorcraftApp, icons};
/// Dragging a stop this far below the ramp removes it.
pub const REMOVE_DISTANCE: f32 = 28.0;
const REMOVE_DISTANCE: f32 = 28.0;
/// The Angle field's preset menu, degrees.
const ANGLE_PRESETS: [f64; 9] = [-180.0, -135.0, -90.0, -45.0, 0.0, 45.0, 90.0, 135.0, 180.0];
/// The Aspect Ratio field's preset menu, percent.
const ASPECT_PRESETS: [f64; 9] = [10.0, 25.0, 50.0, 75.0, 100.0, 150.0, 200.0, 300.0, 400.0];
/// Panel-state key of Show/Hide Options.
const HIDE_OPTIONS: &str = "gradient-hide-options";
/// Panel-state key of the selected midpoint diamond.
const MID_SELECTED: &str = "grad-mid";
/// Select stop `i` (the session's selected stop, shared with the Gradient tool's annotator, the
/// Color panel and agents). Run after the edit that creates it.
fn select_stop(app: &mut VectorcraftApp, i: usize) {
/// Color panel and agents), or none.
fn select_stop(app: &mut VectorcraftApp, i: Option<usize>) {
app.run("gradient.selectStop", json!({ "index": i })).ok();
}
@ -31,8 +49,32 @@ pub fn x_to_offset(x: f32, left: f32, width: f32) -> f32 {
/// Stops as `paint.editGradient` JSON.
pub use vectorcraft_tools::params::stops_json;
// ---------- UI ----------
/// A colour dropped on the ramp: recolours stop `on` (fully opaque), or adds an opaque stop of
/// that colour at `offset`. Returns the stops and the index of the stop that took the colour.
fn drop_color(g: &Gradient, on: Option<usize>, offset: f32, c: Color) -> (Vec<GradientStop>, usize) {
let (mut v, i) = match on.filter(|i| *i < g.stops.len()) {
Some(i) => (g.stops.clone(), i),
None => insert_stop(g, offset),
};
v[i].color = c;
v[i].opacity = 1.0;
(v, i)
}
/// 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.
fn dragged_stops(origin: &[GradientStop], i: usize, offset: f32, copy: bool, over: Option<usize>) -> (Vec<GradientStop>, usize) {
match (copy, over.filter(|j| *j != i)) {
(true, Some(j)) => (swap_stop_colors(origin, i, j), i),
(true, None) => duplicate_stop(origin, i, offset),
(false, _) => move_stop(origin, i, offset),
}
}
// ---------- shared edits ----------
/// The gradient behind the active proxy.
fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
match active_paint(app) {
Paint::Gradient(g) => Some(*g),
@ -40,36 +82,86 @@ fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
}
}
/// Edit the gradient behind the active proxy (`paint.editGradient` params).
fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) {
let mut p = params;
p["stroke"] = json!(!app.session.fill_active);
live_run(app, "Gradient", "paint.editGradient", p, phase);
}
/// Write `stops` to the gradient behind the active proxy (live while dragging) and, once
/// released, select stop `select` (the panel, the stop popover and the Color panel share this).
pub(crate) fn set_stops(app: &mut VectorcraftApp, stops: &[GradientStop], select: Option<usize>, phase: Live) {
edit(app, json!({ "stops": stops_json(stops) }), phase);
if phase == Live::Released
&& let Some(i) = select
&& app.session.selected_stop() != Some(i)
{
select_stop(app, Some(i));
}
}
/// Save the gradient as a swatch.
fn save_to_swatches(app: &mut VectorcraftApp, g: &GradientPaint) {
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g.clone())))).ok();
}
/// The document's gradient swatches (groups included): name and gradient.
fn gradient_swatches(app: &VectorcraftApp) -> Vec<(String, Gradient)> {
let Some(st) = app.session.active() else { return vec![] };
let d = &st.doc;
d.swatches
.iter()
.chain(d.swatch_groups.iter().flat_map(|g| g.swatches.iter()))
.filter_map(|s| match &s.paint {
Paint::Gradient(g) => Some((s.name.clone(), g.gradient.clone())),
_ => None,
})
.collect()
}
/// The selected midpoint diamond: kept for the object it was picked on, and given up when a stop
/// is selected.
fn selected_mid(app: &VectorcraftApp, ctx: &egui::Context, stops: usize) -> Option<usize> {
let (i, owner) = pstate::<Option<(usize, Option<NodeId>)>>(ctx, MID_SELECTED)?;
(app.session.selected_stop().is_none() && i + 1 < stops && owner == first_id(app)).then_some(i)
}
fn first_id(app: &VectorcraftApp) -> Option<NodeId> {
app.session.active().and_then(|d| d.selection.objects.first().copied())
}
// ---------- UI ----------
/// Drag state of the ramp: which handle is being dragged.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Drag {
#[default]
None,
Stop(usize),
/// Stop `index`; `copy` (Alt held when the drag began) drags a copy.
Stop {
index: usize,
copy: bool,
},
Mid(usize),
}
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let gp = current(app);
let fallback = GradientPaint::new(Gradient::default());
let g = gp.clone().unwrap_or(fallback);
let is_grad = gp.is_some();
let g = gp.unwrap_or_else(|| GradientPaint::new(Gradient::default()));
let kind = g.gradient.kind;
// Header: gradient swatch + type buttons + Edit Gradient.
let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS);
// Header: thumbnail + its swatch menu, the type buttons and Edit Gradient.
ui.horizontal(|ui| {
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click());
widgets::paint_chip(ui, r, &Paint::Gradient(Box::new(g.clone())));
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
if resp.on_hover_text("Gradient Fill — click to apply").clicked() && !is_grad {
edit(app, json!({}), Live::Released);
thumbnail(app, ui, &g, is_grad);
if hidden {
ui.add_space(6.0);
super::proxy(app, ui, 36.0);
return;
}
ui.add_space(4.0);
ui.vertical(|ui| {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Type:");
@ -83,13 +175,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
}
}
});
ui.horizontal(|ui| {
if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() {
app.select_tool("gradient");
}
});
if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() {
app.select_tool("gradient");
}
});
});
if hidden {
ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad);
return;
}
ui.add_space(4.0);
ui.horizontal(|ui| {
widgets::dim_label(ui, "Stroke:");
@ -101,87 +196,173 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
widgets::icon_button_enabled(ui, icon, tip, icon == "dc-stroke-center" && !app.session.fill_active, false, 22.0);
}
});
// The proxy beside the angle and aspect ratio fields.
ui.horizontal(|ui| {
icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle");
if let Some(a) = widgets::plain_field(ui, "grad-angle", g.geom.map(|x| x.angle_deg()).unwrap_or(g.angle), "°", 1, 64.0) {
edit(app, json!({"angle": a}), Live::Released);
}
super::proxy(app, ui, 36.0);
ui.add_space(4.0);
let radial = kind == GradientKind::Radial;
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio");
let asp = g.geom.map(|x| x.aspect * 100.0).unwrap_or(100.0);
if radial {
if let Some(a) = widgets::plain_field(ui, "grad-aspect", asp, "%", 1, 60.0) {
edit(app, json!({"aspect": a}), Live::Released);
}
} else {
ui.add_enabled(false, egui::Label::new(egui::RichText::new(format!("{asp:.0}%")).color(t.text_disabled)));
}
if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() {
edit(app, json!({"reverse": true}), Live::Released);
}
ui.vertical(|ui| {
ui.horizontal(|ui| {
icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle");
let angle = g.geom.map_or(g.angle, |x| x.angle_deg());
if let Some(a) = widgets::spin_plain(ui, "grad-angle", (angle * 10.0).round() / 10.0, "°", 1, 104.0, 1.0, -180.0, &ANGLE_PRESETS) {
edit(app, json!({"angle": a}), Live::Released);
}
if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() {
edit(app, json!({"reverse": true}), Live::Released);
}
});
let radial = kind == GradientKind::Radial;
ui.horizontal(|ui| {
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio");
let asp = g.geom.map_or(100.0, |x| (x.aspect * 1000.0).round() / 10.0);
let set = ui.add_enabled_ui(radial, |ui| widgets::spin_plain(ui, "grad-aspect", asp, "%", 1, 104.0, 1.0, 0.5, &ASPECT_PRESETS)).inner;
if let Some(a) = set {
edit(app, json!({"aspect": a}), Live::Released);
}
});
});
});
ui.add_space(6.0);
ramp(app, ui, &g.gradient, is_grad);
ui.add_space(4.0);
// Stop fields.
let sel = app.session.selected_stop().filter(|i| *i < g.gradient.stops.len());
ui.horizontal(|ui| {
let enabled = is_grad && sel.is_some();
let stop = sel.and_then(|i| g.gradient.stops.get(i)).copied();
widgets::dim_label(ui, "Opacity:");
let op = stop.map(|s| s.opacity as f64 * 100.0).unwrap_or(100.0);
if let Some(v) = widgets::plain_field(ui, "grad-op", op, "%", 0, 54.0)
&& enabled
&& let Some(i) = sel
{
let mut stops = g.gradient.stops.clone();
stops[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
}
widgets::dim_label(ui, "Location:");
let loc = stop.map(|s| s.offset as f64 * 100.0).unwrap_or(0.0);
if let Some(v) = widgets::plain_field(ui, "grad-loc", loc, "%", 1, 54.0)
&& enabled
&& let Some(i) = sel
{
let (stops, ni) = move_stop(&g.gradient.stops, i, (v / 100.0) as f32);
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, ni);
}
let can_del = enabled && g.gradient.stops.len() > MIN_STOPS;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
&& let Some(i) = sel
&& let Some(stops) = remove_stop(&g.gradient.stops, i)
{
edit(app, json!({"stops": stops_json(&stops)}), Live::Released);
select_stop(app, i.min(stops.len() - 1));
}
});
if let Some(i) = sel
&& is_grad
{
let c = g.gradient.stops[i].color;
ui.horizontal(|ui| {
widgets::dim_label(ui, "Stop color:");
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(c), false, resp.hovered());
widgets::dim_label(ui, &c.to_hex().to_uppercase());
if resp.on_hover_text("Edit the stop in the Color panel").clicked() {
app.ui.open_panel = Some("color".into());
}
});
}
stop_fields(app, ui, &g.gradient, is_grad);
if !is_grad {
widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient.");
}
}
/// The gradient thumbnail (click: apply a gradient; drag: onto art) and its menu of gradient
/// swatches with Save to Swatches.
fn thumbnail(app: &mut VectorcraftApp, ui: &mut Ui, g: &GradientPaint, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click_and_drag());
widgets::gradient_chip(ui, r, &g.gradient);
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
let resp = resp.on_hover_text("Gradient Fill: click to apply, drag onto art");
if resp.clicked() && !is_grad {
edit(app, json!({}), Live::Released);
}
// The dragged copy carries no placement: it fits the art it lands on.
widgets::paint_drag_source(ui, &resp, || {
let paint = Paint::Gradient(Box::new(GradientPaint { geom: None, ..g.clone() }));
PaintDrag { params: super::paint_params(&paint), paint }
});
let dr = Rect::from_min_size(r.right_top() + vec2(1.0, 0.0), vec2(14.0, 40.0));
ui.advance_cursor_after_rect(dr);
let dresp = ui.interact(dr, ui.id().with("grad-swatches"), Sense::click());
icons::paint(ui, "chevron-down", Rect::from_center_size(dr.center(), vec2(12.0, 12.0)), if dresp.hovered() { t.text_strong } else { t.icon });
let dresp = dresp.on_hover_text("Gradient swatches");
egui::Popup::menu(&dresp).show(|ui| {
ui.set_min_width(200.0);
let mut chosen = None;
let swatches = gradient_swatches(app);
if swatches.is_empty() {
widgets::dim_label(ui, "No gradient swatches");
}
for (name, grad) in &swatches {
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(row, 0.0, t.hover);
}
let chip = Rect::from_min_size(row.left_center() + vec2(4.0, -8.0), vec2(16.0, 16.0));
widgets::gradient_chip(ui, chip, grad);
ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
ui.painter().text(chip.right_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text);
if resp.clicked() {
chosen = Some(name.clone());
}
}
ui.separator();
if menu_item(ui, "Save to Swatches", is_grad, false) {
save_to_swatches(app, g);
}
if let Some(name) = chosen {
super::apply_click(app, ui, json!({ "swatch": name }));
ui.close();
}
});
}
/// The selected stop's colour (click: its popover; the eyedropper samples one from the art),
/// opacity and location (a selected diamond's midpoint instead), and Delete Stop.
fn stop_fields(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let stops = &g.stops;
let sel = app.session.selected_stop().filter(|i| *i < stops.len() && is_grad);
let mid = selected_mid(app, ui.ctx(), stops.len()).filter(|_| is_grad);
let stop = sel.map(|i| stops[i]);
if let (Some(i), Some(s)) = (sel, stop) {
ui.horizontal(|ui| {
widgets::dim_label(ui, "Color:");
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
widgets::swatch_tile(ui, r, &Paint::solid(s.color), false, resp.hovered());
widgets::dim_label(ui, &s.color.to_hex().to_uppercase());
if resp.on_hover_text("Edit the stop").clicked() {
open_popover(app, i, r.left_bottom() + vec2(0.0, 4.0));
}
ui.add_space(ui.available_width() - 22.0);
if widgets::icon_button(ui, "pipette", "Eyedropper: click the art to sample the stop's colour", false, 22.0).clicked() {
stop_eyedropper(app);
}
});
}
ui.horizontal(|ui| {
ui.add_enabled_ui(sel.is_some(), |ui| {
widgets::dim_label(ui, "Opacity:");
if let Some(v) = widgets::plain_field(ui, "grad-op", stop.map_or(100.0, |s| s.opacity as f64 * 100.0), "%", 0, 54.0)
&& let Some(i) = sel
{
let mut v2 = stops.clone();
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
set_stops(app, &v2, Some(i), Live::Released);
}
});
ui.add_enabled_ui(sel.is_some() || mid.is_some(), |ui| {
widgets::dim_label(ui, "Location:");
let loc = match (mid, stop) {
(Some(m), _) => stops[m].midpoint as f64 * 100.0,
(None, Some(s)) => s.offset as f64 * 100.0,
_ => 0.0,
};
if let Some(v) = widgets::plain_field(ui, ("grad-loc", mid.is_some()), loc, "%", 1, 54.0) {
let v = (v / 100.0) as f32;
if let Some(m) = mid {
set_stops(app, &set_midpoint(stops, m, v), None, Live::Released);
} else if let Some(i) = sel {
let (v2, ni) = move_stop(stops, i, v);
set_stops(app, &v2, Some(ni), Live::Released);
}
}
});
let can_del = sel.is_some() && stops.len() > MIN_STOPS;
if widgets::icon_button_enabled(ui, "trash-2", "Delete Stop", false, can_del, 22.0).clicked()
&& let Some(i) = sel
&& let Some(v) = remove_stop(stops, i)
{
let next = i.min(v.len() - 1);
set_stops(app, &v, Some(next), Live::Released);
}
});
}
/// The stop's eyedropper: the Eyedropper tool samples the next colour clicked on the art into the
/// selected stop, then hands back to the current tool.
fn stop_eyedropper(app: &mut VectorcraftApp) {
let back = app.session.tool_id();
app.select_tool("eyedropper");
app.session.set_tool_option("stop", &json!(back));
}
/// Open the stop popover for stop `i` at screen position `at`.
fn open_popover(app: &mut VectorcraftApp, i: usize, at: Pos2) {
select_stop(app, Some(i));
app.ui.dialog = Some(Dialog::new("gradientStop", json!({ "index": i, "screen": [at.x, at.y], "tab": "color" })));
}
/// The gradient slider: ramp, stops below, midpoint diamonds above.
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let (area, _) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover());
let (area, area_resp) = ui.allocate_exact_size(vec2(w, 50.0), Sense::hover());
let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0));
// Checkerboard under the ramp (opacity).
let cell = 6.0;
@ -208,37 +389,67 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
Rect::from_min_size(pos2(bar.left() + i as f32 * bar.width() / n as f32, bar.top()), vec2(bar.width() / n as f32 + 0.5, bar.height()));
ui.painter().rect_filled(rr, 0.0, Color32::from_rgba_unmultiplied(r, gg, b, a));
}
ui.painter().rect_stroke(bar, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
// A colour swatch held over the ramp: outline it as a drop target.
let droppable = is_grad && area_resp.dnd_hover_payload::<PaintDrag>().is_some_and(|d| d.paint.color().is_some());
let edge = if droppable { Stroke::new(2.0, t.accent) } else { Stroke::new(1.0, t.border) };
ui.painter().rect_stroke(bar, 0.0, edge, StrokeKind::Outside);
let x_of = |o: f32| bar.left() + o * bar.width();
let offset_at = |p: Pos2| x_to_offset(p.x, bar.left(), bar.width());
let marker = |o: f32| Rect::from_min_size(pos2(x_of(o) - 6.0, bar.bottom() + 2.0), vec2(12.0, 16.0));
let sel = app.session.selected_stop();
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
let stops = &g.stops;
let mid_sel = selected_mid(app, ui.ctx(), stops.len());
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
// While dragging, edits are computed against the stops as they were when the drag began (the
// document shows the preview).
let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") };
let origin = if origin.len() == stops.len() { origin } else { stops.clone() };
let origin = if origin.is_empty() { stops.clone() } else { origin };
// The stop of `origin` other than `i` whose marker is under `p`.
let stop_under = |p: Pos2, i: usize| origin.iter().enumerate().position(|(j, s)| j != i && marker(s.offset).contains(p));
let mut changed: Option<(Vec<GradientStop>, Live)> = None;
// The stop to select once `changed` is applied.
let mut select: Option<usize> = None;
let mut pick_mid: Option<usize> = None;
let mut popover: Option<(usize, Pos2)> = None;
// The ramp and the strip below it, under the stops and diamonds (interacted first): a click
// below adds a stop, and without a gradient either applies one.
let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom()));
let add_resp = ui.interact(below, ui.id().with("grad-add"), Sense::click());
let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click());
if add_resp.hovered() && is_grad {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if !is_grad && (bar_resp.clicked() || add_resp.clicked()) {
edit(app, json!({}), Live::Released);
} else if add_resp.clicked()
&& let Some(p) = add_resp.interact_pointer_pos()
{
let (v, i) = insert_stop(g, offset_at(p));
select = Some(i);
changed = Some((v, Live::Released));
}
// Midpoint diamonds.
for i in 0..stops.len().saturating_sub(1) {
let Some(p) = midpoint_pos(stops, i) else { continue };
let c = pos2(x_of(p), bar.top() - 5.0);
let rect = Rect::from_center_size(c, vec2(10.0, 10.0));
let resp = ui.interact(rect, ui.id().with(("grad-mid", i)), Sense::click_and_drag());
let active = drag == Drag::Mid(i) || resp.hovered();
let lit = drag == Drag::Mid(i) || mid_sel == Some(i) || resp.hovered();
let pts = vec![c + vec2(0.0, -4.0), c + vec2(4.0, 0.0), c + vec2(0.0, 4.0), c + vec2(-4.0, 0.0)];
ui.painter().add(egui::Shape::convex_polygon(pts, if active { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border)));
ui.painter().add(egui::Shape::convex_polygon(pts, if lit { Color32::WHITE } else { t.icon }, Stroke::new(1.0, t.border)));
if !is_grad {
continue;
}
if resp.clicked() || resp.drag_started() {
pick_mid = Some(i);
}
if resp.drag_started() {
drag = Drag::Mid(i);
set_pstate(ui.ctx(), "grad-origin", stops.clone());
}
if (resp.dragged() || resp.drag_stopped())
&& let Some(pp) = resp.interact_pointer_pos()
&& let Some(m) = midpoint_from_pos(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()))
&& let Some(m) = midpoint_from_pos(&origin, i, offset_at(pp))
{
let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging };
changed = Some((set_midpoint(&origin, i, m), phase));
@ -248,15 +459,19 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
}
}
// Stops (house-shaped markers under the ramp).
let mut hit_stop = false;
let alt = super::alt_held(ui);
for (i, s) in stops.iter().enumerate() {
let x = x_of(s.offset);
let top = bar.bottom() + 2.0;
let marker = Rect::from_min_size(pos2(x - 6.0, top), vec2(12.0, 16.0));
let resp = ui.interact(marker, ui.id().with(("grad-stop", i)), Sense::click_and_drag());
hit_stop |= resp.hovered() || resp.dragged();
let dragging_this = drag == Drag::Stop(i);
let off = dragging_this && resp.interact_pointer_pos().is_some_and(|p| p.y > bar.bottom() + REMOVE_DISTANCE) && origin.len() > MIN_STOPS;
let m = marker(s.offset);
let top = m.top();
let resp = ui.interact(m, ui.id().with(("grad-stop", i)), Sense::click_and_drag());
let (dragging_this, copy) = match drag {
Drag::Stop { index, copy } => (index == i, copy),
_ => (false, false),
};
// A moved stop (not a copy) dragged this far below the ramp is removed on release.
let off_ramp = |p: Pos2| !copy && p.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS;
let off = dragging_this && resp.interact_pointer_pos().is_some_and(off_ramp);
let outline = if sel == Some(i) { t.accent } else { t.border };
let body = vec![pos2(x, top), pos2(x + 6.0, top + 5.0), pos2(x + 6.0, top + 15.0), pos2(x - 6.0, top + 15.0), pos2(x - 6.0, top + 5.0)];
if !off {
@ -267,89 +482,97 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
if !is_grad {
continue;
}
if resp.double_clicked() {
popover = Some((i, m.left_bottom() + vec2(0.0, 4.0)));
}
if resp.clicked() || resp.drag_started() {
select = Some(i);
}
if resp.drag_started() {
drag = Drag::Stop(i);
drag = Drag::Stop { index: i, copy: alt };
set_pstate(ui.ctx(), "grad-origin", stops.clone());
}
if (resp.dragged() || resp.drag_stopped())
&& dragging_this
&& let Some(pp) = resp.interact_pointer_pos()
{
let removing = pp.y > bar.bottom() + REMOVE_DISTANCE && origin.len() > MIN_STOPS;
let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging };
if resp.drag_stopped() {
drag = Drag::None;
if removing {
if let Some(v) = remove_stop(&origin, i) {
select = Some(i.min(v.len() - 1));
changed = Some((v, Live::Released));
}
} else {
let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
select = Some(ni);
changed = Some((v, Live::Released));
}
if !off_ramp(pp) {
let over = if copy { stop_under(pp, i) } else { None };
let (v, ni) = dragged_stops(&origin, i, offset_at(pp), copy, over);
select = Some(ni);
changed = Some((v, phase));
} else if phase == Live::Released {
if let Some(v) = remove_stop(&origin, i) {
select = Some(i.min(v.len() - 1));
changed = Some((v, phase));
}
} else if removing {
// Dragged off: show the gradient without moving the stop until release.
changed = Some((origin.clone(), Live::Dragging));
} else {
let (v, _) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
changed = Some((v, Live::Dragging));
// Dragged off: show the gradient without the stop moving until release.
changed = Some((origin.clone(), phase));
}
}
}
// Click below the ramp (not on a stop) adds a stop; clicking the ramp applies a gradient.
let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom()));
let resp = ui.interact(below, ui.id().with("grad-add"), Sense::click());
if resp.hovered() && !hit_stop && is_grad {
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
}
if resp.clicked()
&& !hit_stop
&& let Some(p) = resp.interact_pointer_pos()
// A colour swatch dropped on the ramp.
if is_grad
&& let Some(d) = area_resp.dnd_release_payload::<PaintDrag>()
&& let Some(c) = d.paint.color()
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
{
if is_grad {
let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width()));
select = Some(i);
changed = Some((v, Live::Released));
} else {
edit(app, json!({}), Live::Released);
}
}
let bar_resp = ui.interact(bar, ui.id().with("grad-bar"), Sense::click());
if bar_resp.clicked() && !is_grad {
edit(app, json!({}), Live::Released);
let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c);
select = Some(i);
changed = Some((v, Live::Released));
}
set_pstate(ui.ctx(), "grad-drag", drag);
if let Some((v, phase)) = changed {
edit(app, json!({"stops": stops_json(&v)}), phase);
if let Some(i) = pick_mid {
set_pstate(ui.ctx(), MID_SELECTED, Some((i, first_id(app))));
if sel.is_some() {
select_stop(app, None);
}
} else if select.is_some() {
set_pstate(ui.ctx(), MID_SELECTED, None::<(usize, Option<NodeId>)>);
}
if let Some(i) = select {
select_stop(app, i);
match changed {
Some((v, phase)) => set_stops(app, &v, select, phase),
None => {
if let Some(i) = select.filter(|i| sel != Some(*i)) {
select_stop(app, Some(i));
}
}
}
if let Some((i, at)) = popover {
open_popover(app, i, at);
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let g = current(app);
menu_item(ui, "Hide Options", false, false);
if menu_item(ui, "Add to Swatches", g.is_some(), false)
&& let Some(g) = g
{
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g)))).ok();
let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS);
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
set_pstate(ui.ctx(), HIDE_OPTIONS, !hidden);
}
if menu_item(ui, "Reverse Gradient", current(app).is_some(), false) {
ui.separator();
if menu_item(ui, "Add to Swatches", g.is_some(), false)
&& let Some(g) = &g
{
save_to_swatches(app, g);
}
if menu_item(ui, "Reverse Gradient", g.is_some(), false) {
edit(app, json!({"reverse": true}), Live::Released);
}
if menu_item(ui, "Reset to White, Black", true, false) {
let d = Gradient::default();
edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released);
set_stops(app, &Gradient::default().stops, None, Live::Released);
}
}
#[cfg(test)]
mod tests {
use egui::{Event, Modifiers, PointerButton};
use vectorcraft_engine::Session;
use super::*;
#[test]
@ -361,4 +584,251 @@ mod tests {
assert_eq!(j.as_array().unwrap().len(), 2);
assert_eq!(j[1]["midpoint"], json!(0.5));
}
#[test]
fn dropped_colours_recolour_or_add_an_opaque_stop() {
let mut g = Gradient::default();
g.stops[1].opacity = 0.5;
let red = Color::rgb(1.0, 0.0, 0.0);
let (v, i) = drop_color(&g, Some(1), 0.3, red);
assert_eq!((v.len(), i, v[1].color, v[1].opacity), (2, 1, red, 1.0));
let (v, i) = drop_color(&g, None, 0.3, red);
assert_eq!((v.len(), i, v[1].color, v[1].offset, v[1].opacity), (3, 1, red, 0.3, 1.0));
}
#[test]
fn dragged_stops_move_copy_or_swap() {
let g = Gradient::default();
let (v, i) = dragged_stops(&g.stops, 0, 0.4, false, Some(1));
assert_eq!((v.len(), i, v[0].offset), (2, 0, 0.4));
let (v, i) = dragged_stops(&g.stops, 0, 0.4, true, None);
assert_eq!((v.len(), i, v[1].offset, v[1].color), (3, 1, 0.4, Color::WHITE));
let (v, i) = dragged_stops(&g.stops, 0, 1.0, true, Some(1));
assert_eq!((v.len(), i, v[0].color, v[1].color), (2, 0, Color::BLACK, Color::WHITE));
// Dropped on itself: a copy.
assert_eq!(dragged_stops(&g.stops, 0, 0.0, true, Some(0)).0.len(), 3);
}
// ---------- headless frames ----------
/// A selected rectangle filled with `fill` (`paint.setFill` params).
fn app(fill: Value) -> VectorcraftApp {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
app.run("paint.setFill", fill).unwrap();
app
}
/// A headless Gradient panel: frames share one context and a clock.
struct Panel {
ctx: egui::Context,
time: f64,
}
impl Panel {
fn new() -> Self {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
Self { ctx, time: 0.0 }
}
/// One frame with `events` (and `modifiers` held); returns the texts drawn and where.
fn frame(&mut self, app: &mut VectorcraftApp, events: Vec<Event>, modifiers: Modifiers) -> Vec<(String, Pos2)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
self.time += 0.1;
let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
let input = egui::RawInput {
time: Some(self.time),
events,
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(260.0, 600.0))),
..Default::default()
};
let mut out = self.ctx.run_ui(input, |ui| show(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// The rect of the widget its parent ui keyed `key` (last frame).
fn widget(&self, key: impl std::hash::Hash + std::fmt::Debug + Copy) -> Rect {
self.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("widget drawn")
}
fn click(&mut self, app: &mut VectorcraftApp, at: Pos2) -> Vec<(String, Pos2)> {
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
self.frame(app, vec![Event::PointerMoved(at), b(true)], Modifiers::NONE);
self.frame(app, vec![b(false)], Modifiers::NONE);
self.frame(app, vec![], Modifiers::NONE)
}
/// Press at `from`, move to `to` in steps and release there, with `m` held.
fn drag(&mut self, app: &mut VectorcraftApp, from: Pos2, to: Pos2, m: Modifiers) {
let b = |at, pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: m };
self.frame(app, vec![Event::PointerMoved(from), b(from, true)], m);
for k in 1..=4 {
self.frame(app, vec![Event::PointerMoved(from + (to - from) * (k as f32 / 4.0))], m);
}
self.frame(app, vec![b(to, false)], m);
self.frame(app, vec![], Modifiers::NONE);
}
}
fn fill(app: &VectorcraftApp) -> GradientPaint {
match crate::panels::current_paints(app).0 {
Paint::Gradient(g) => *g,
p => panic!("expected a gradient fill, got {p:?}"),
}
}
fn hexes(app: &VectorcraftApp) -> Vec<String> {
fill(app).gradient.stops.iter().map(|s| s.color.to_hex()).collect()
}
#[test]
fn the_proxy_toggles_fill_and_stroke_and_stays_with_options_hidden() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "Opacity:"));
let stroke = p.widget("stroke-proxy");
p.click(&mut app, stroke.right_bottom() - vec2(2.0, 2.0));
assert!(!app.session.fill_active, "the Stroke proxy came to the front");
set_pstate(&p.ctx, HIDE_OPTIONS, true);
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(!texts.iter().any(|(t, _)| t == "Opacity:" || t == "Type:"), "{texts:?}");
let fill = p.widget("fill-proxy");
p.click(&mut app, fill.left_top() + vec2(2.0, 2.0));
assert!(app.session.fill_active, "the proxy works with the options hidden");
p.widget(("grad-stop", 1));
}
#[test]
fn a_selected_diamond_shows_and_sets_its_midpoint_in_location() {
let stops = json!([{"offset": 0, "color": "#ffffff", "midpoint": 0.3}, {"offset": 1, "color": "#000000"}]);
let mut app = app(json!({"gradient": {"stops": stops}}));
app.run("gradient.selectStop", json!({"index": 1})).unwrap();
let mut p = Panel::new();
let texts = p.frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "100%"), "the stop's location: {texts:?}");
let diamond = p.widget(("grad-mid", 0));
let texts = p.click(&mut app, diamond.center());
assert_eq!(app.session.selected_stop(), None, "the diamond replaces the stop selection");
assert!(texts.iter().any(|(t, _)| t == "30%"), "the midpoint: {texts:?}");
// Typing a location moves the midpoint.
let field = texts.iter().find(|(t, _)| t == "30%").unwrap().1;
p.click(&mut app, field);
let key = |k, modifiers| Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers };
p.frame(&mut app, vec![key(egui::Key::A, Modifiers::COMMAND), Event::Text("60".into())], Modifiers::NONE);
p.frame(&mut app, vec![key(egui::Key::Enter, Modifiers::NONE)], Modifiers::NONE);
assert!((fill(&app).gradient.stops[0].midpoint - 0.6).abs() < 1e-6, "{:?}", fill(&app).gradient.stops);
// Clicking a stop selects it again.
let stop = p.widget(("grad-stop", 0));
let texts = p.click(&mut app, stop.center());
assert_eq!(app.session.selected_stop(), Some(0));
assert!(texts.iter().any(|(t, _)| t == "0%"));
}
#[test]
fn the_thumbnail_menu_applies_gradient_swatches_and_saves_one() {
let mut app = app(json!({"color": "#336699"}));
let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]);
app.run("swatch.new", json!({"name": "Dusk", "gradient": {"stops": stops}})).unwrap();
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let menu = p.widget("grad-swatches").center();
let texts = p.click(&mut app, menu);
let dusk = texts.iter().find(|(t, _)| t == "Dusk").unwrap_or_else(|| panic!("the menu lists the swatch: {texts:?}")).1;
assert!(texts.iter().any(|(t, _)| t.ends_with("Save to Swatches")));
p.click(&mut app, dusk);
assert_eq!(hexes(&app), ["#ff0000", "#0000ff"]);
// Save to Swatches adds the current gradient.
let before = gradient_swatches(&app).len();
let texts = p.click(&mut app, menu);
let save = texts.iter().find(|(t, _)| t.ends_with("Save to Swatches")).unwrap().1;
p.click(&mut app, save);
assert_eq!(gradient_swatches(&app).len(), before + 1);
}
#[test]
fn alt_drag_copies_a_stop_and_alt_dropping_on_a_stop_swaps_colours() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let mid = pos2((a.x + b.x) / 2.0, a.y);
let offsets = |app: &VectorcraftApp| fill(app).gradient.stops.iter().map(|s| (s.offset * 10.0).round() / 10.0).collect::<Vec<_>>();
// A plain drag moves the stop.
p.drag(&mut app, a, mid, Modifiers::NONE);
assert_eq!(offsets(&app), [0.5, 1.0]);
app.run("edit.undo", json!({})).unwrap();
// Alt leaves the original and drags a copy, selected on release; one undo step.
p.frame(&mut app, vec![], Modifiers::NONE);
p.drag(&mut app, a, mid, Modifiers::ALT);
assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#ffffff".to_string(), "#ffffff".into(), "#000000".into()]));
assert_eq!(app.session.selected_stop(), Some(1));
// Alt-dropping the first stop on the last swaps their colours.
p.drag(&mut app, a, b, Modifiers::ALT);
assert_eq!((offsets(&app), hexes(&app)), (vec![0.0, 0.5, 1.0], vec!["#000000".to_string(), "#ffffff".into(), "#ffffff".into()]));
app.run("edit.undo", json!({})).unwrap();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(hexes(&app), ["#ffffff", "#000000"]);
}
#[test]
fn a_dropped_swatch_adds_or_recolours_a_stop() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let (a, b) = (p.widget(("grad-stop", 0)).center(), p.widget(("grad-stop", 1)).center());
let drop = |p: &mut Panel, app: &mut VectorcraftApp, at: Pos2| {
egui::DragAndDrop::set_payload(&p.ctx, PaintDrag { paint: Paint::solid(Color::rgb(1.0, 0.0, 0.0)), params: json!({"color": "#ff0000"}) });
p.frame(app, vec![Event::PointerMoved(at)], Modifiers::NONE);
let up = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
p.frame(app, vec![up], Modifiers::NONE);
};
drop(&mut p, &mut app, pos2((a.x + b.x) / 2.0, a.y - 20.0));
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#000000"]);
p.frame(&mut app, vec![], Modifiers::NONE);
let last = p.widget(("grad-stop", 2)).center();
drop(&mut p, &mut app, last);
assert_eq!(hexes(&app), ["#ffffff", "#ff0000", "#ff0000"]);
}
#[test]
fn double_clicking_a_stop_opens_the_shared_popover_beside_it() {
let mut app = app(json!({"gradient": {}}));
let mut p = Panel::new();
p.frame(&mut app, vec![], Modifiers::NONE);
let stop = p.widget(("grad-stop", 1));
let b = |pressed| Event::PointerButton { pos: stop.center(), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
p.frame(&mut app, vec![Event::PointerMoved(stop.center()), b(true), b(false), b(true), b(false)], Modifiers::NONE);
let d = app.ui.dialog.clone().expect("the popover opened");
assert_eq!(
(d.kind.as_str(), &d.fields["index"], &d.fields["screen"]),
("gradientStop", &json!(1), &json!([stop.left(), stop.bottom() + 4.0]))
);
assert_eq!(app.session.selected_stop(), Some(1));
}
#[test]
fn the_stop_eyedropper_arms_the_eyedropper_for_the_stop() {
let mut app = app(json!({"gradient": {}}));
app.select_tool("gradient");
app.run("gradient.selectStop", json!({"index": 0})).unwrap();
let texts = Panel::new().frame(&mut app, vec![], Modifiers::NONE);
assert!(texts.iter().any(|(t, _)| t == "#FFFFFF"), "the stop's colour row: {texts:?}");
stop_eyedropper(&mut app);
assert_eq!((app.session.tool_id(), app.session.tool_options()), ("eyedropper", json!({"stop": "gradient"})));
}
}

View File

@ -121,15 +121,28 @@ fn entries(app: &VectorcraftApp, kind: Kind) -> Vec<Entry> {
out
}
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
let params = match e {
/// The paint params (`paint.setFill`) of an entry; none for a group.
fn params(e: &Entry) -> Option<serde_json::Value> {
Some(match e {
Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}),
Entry::Swatch { paint, .. } if paint.is_none() => json!({"none": true}),
Entry::Swatch { name, .. } => json!({"swatch": name}),
Entry::Folder(_) => return,
};
super::apply_click(app, ui, params);
Entry::Folder(_) => return None,
})
}
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
if let Some(p) = params(e) {
super::apply_click(app, ui, p);
}
}
/// Let a swatch tile be dragged onto art or the Gradient panel's ramp.
fn drag_source(ui: &Ui, resp: &egui::Response, e: &Entry) {
if let Entry::Swatch { paint, .. } = e {
widgets::paint_drag_source(ui, resp, || widgets::PaintDrag { paint: paint.clone(), params: params(e).unwrap_or_default() });
}
}
/// A pattern swatch drawn as a rendered tile (cached by the definition's identity and size).
@ -268,7 +281,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.set_width(ui.available_width());
if view.is_list() {
for e in &items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), pitch), Sense::click_and_drag());
drag_source(ui, &resp, e);
let name = e.name();
if is_sel(name) {
ui.painter().rect_filled(r, 0.0, t.row_selected);
@ -335,7 +349,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(w, pitch), Sense::hover());
for (c, e) in row.into_iter().enumerate() {
let cell = Rect::from_min_size(r.min + vec2(1.0 + c as f32 * pitch, (pitch - tile) / 2.0), vec2(tile, tile));
let resp = ui.interact(cell, tile_id(e), Sense::click());
let resp = ui.interact(cell, tile_id(e), Sense::click_and_drag());
drag_source(ui, &resp, e);
let name = e.name();
match e {
Entry::Registration => draw_registration(ui, cell),

View File

@ -187,7 +187,11 @@ pub fn mixed_field(
/// Draw a paint preview (swatch chip) into `rect`.
pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
let p = ui.painter();
draw_paint(ui.painter(), rect, paint);
}
/// [`paint_chip`] on any painter (a layer above the panels).
fn draw_paint(p: &egui::Painter, rect: Rect, paint: &Paint) {
match paint {
Paint::None => {
p.rect_filled(rect, 0.0, Color32::WHITE);
@ -197,7 +201,7 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
let [r, g, b, _] = color.to_rgba8(1.0);
p.rect_filled(rect, 0.0, Color32::from_rgb(r, g, b));
}
Paint::Gradient(gp) => gradient_chip(ui, rect, &gp.gradient),
Paint::Gradient(gp) => draw_gradient(p, rect, &gp.gradient),
Paint::Pattern { .. } => {
p.rect_filled(rect, 0.0, Color32::from_gray(200));
for i in 0..4 {
@ -210,7 +214,10 @@ pub fn paint_chip(ui: &Ui, rect: Rect, paint: &Paint) {
/// Draw a gradient preview into `rect` (linear left to right; radial from the centre).
pub fn gradient_chip(ui: &Ui, rect: Rect, gradient: &vectorcraft_color::Gradient) {
let p = ui.painter();
draw_gradient(ui.painter(), rect, gradient);
}
fn draw_gradient(p: &egui::Painter, rect: Rect, gradient: &vectorcraft_color::Gradient) {
let radial = gradient.kind == vectorcraft_color::GradientKind::Radial;
if radial {
// Outer colour fills the corners beyond the largest circle.
@ -902,3 +909,31 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
None
}
}
/// A paint dragged out of a panel (a swatch, the Gradient panel's thumbnail). Dropped on art it
/// paints the active proxy through `paint.setFill`/`paint.setStroke` with `params` (`{swatch}`,
/// `{color}`, `{gradient}`…); dropped on the Gradient panel's ramp its colour adds or recolours a
/// stop.
#[derive(Clone, Debug, PartialEq)]
pub struct PaintDrag {
pub paint: Paint,
pub params: serde_json::Value,
}
/// Make `resp` (which senses drags) a source of the [`PaintDrag`] `drag` builds when the drag
/// starts, and draw that paint under the pointer while it travels.
pub fn paint_drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PaintDrag) {
if resp.drag_started() {
egui::DragAndDrop::set_payload(ui.ctx(), drag());
}
if resp.dragged()
&& let Some(at) = ui.ctx().pointer_interact_pos()
&& let Some(d) = egui::DragAndDrop::payload::<PaintDrag>(ui.ctx())
{
let t = Tokens::get(ui.ctx());
let p = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("paint-drag")));
let r = Rect::from_min_size(at + vec2(10.0, 10.0), Vec2::splat(18.0));
draw_paint(&p, r, &d.paint);
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
}
}

View File

@ -36,3 +36,10 @@ fill or stroke (fields as in `swatchOptions` plus `group`; a gradient or pattern
`swatches`); `ui.dialog.confirm` runs `swatch.new` / `swatch.newGroup`.
The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", params: {stroke?, color?}}` opens it for the fill (or stroke) proxy; `ui.dialog.set {field: "hex", value: "00FF00"}` (or `color`, `channel`, `webOnly`, `swatches`) then `ui.dialog.confirm` applies the colour through `paint.setFill` / `paint.setStroke`.
Gradient panel: double-clicking a stop on the panel's slider opens the same `gradientStop` popover, with a `screen`
field (`[x, y]`, screen points) in place of `x`/`y`. The panel's stop eyedropper selects the Eyedropper tool with the
tool option `stop` (the tool to return to; `tool.setOption {key: "stop", value: "gradient"}`): its next click on art
samples the colour there into the selected stop (`paint.sampleColor {color, stop}`) and switches back. Dragging a
swatch or the panel's gradient thumbnail onto art runs `paint.setFill` / `paint.setStroke` (the active proxy) with
the swatch's params and the object's `ids`.