Merge wp/B2
# Conflicts: # crates/doc/src/lib.rs # crates/engine/src/cmd/style.rs # crates/engine/src/lib.rs # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/menus.rs # crates/ui-egui/src/panels/swatches.rs # docs/control-protocol.md
This commit is contained in:
commit
0f169b5c5d
|
|
@ -168,6 +168,18 @@ pub fn duplicate_stop(stops: &[GradientStop], i: usize, offset: f32) -> (Vec<Gra
|
||||||
(v, ni)
|
(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 %).
|
/// 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> {
|
pub fn set_midpoint(stops: &[GradientStop], i: usize, m: f32) -> Vec<GradientStop> {
|
||||||
let mut v = stops.to_vec();
|
let mut v = stops.to_vec();
|
||||||
|
|
@ -268,6 +280,18 @@ impl GradientGeom {
|
||||||
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
|
self.aspect = if len > 1e-12 { other / len } else { 1.0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Move the placement from box `from` to box `to`: the start and end keep their position
|
||||||
|
/// relative to the box (along a side `from` has no extent on, their offset from its centre).
|
||||||
|
/// The aspect ratio is kept.
|
||||||
|
pub fn rebase(&mut self, from: Rect, to: Rect) {
|
||||||
|
let axis = |v: f64, a0: f64, a1: f64, b0: f64, b1: f64| {
|
||||||
|
if (a1 - a0).abs() > 1e-12 { b0 + (v - a0) / (a1 - a0) * (b1 - b0) } else { v - (a0 + a1) / 2.0 + (b0 + b1) / 2.0 }
|
||||||
|
};
|
||||||
|
let map = |p: Point| Point::new(axis(p.x, from.x0, from.x1, to.x0, to.x1), axis(p.y, from.y0, from.y1, to.y0, to.y1));
|
||||||
|
self.start = map(self.start);
|
||||||
|
self.end = map(self.end);
|
||||||
|
}
|
||||||
|
|
||||||
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
/// The gradient parameter (0 at the start, 1 at the end) at document point `p`: the projection
|
||||||
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
|
/// onto the vector for linear gradients, the elliptical radius (honouring `aspect`) for radial.
|
||||||
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
pub fn param_at(&self, kind: GradientKind, p: Point) -> f64 {
|
||||||
|
|
@ -320,6 +344,12 @@ impl GradientPaint {
|
||||||
self.geom = Some(self.resolve(bounds));
|
self.geom = Some(self.resolve(bounds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Move a placed gradient from box `from` to box `to` (see [`GradientGeom::rebase`]).
|
||||||
|
pub fn rebase(&mut self, from: Rect, to: Rect) {
|
||||||
|
if let Some(g) = &mut self.geom {
|
||||||
|
g.rebase(from, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
|
/// Map a placed gradient through `a` (see [`GradientGeom::transform`]).
|
||||||
pub fn transform(&mut self, a: Affine) {
|
pub fn transform(&mut self, a: Affine) {
|
||||||
if let Some(g) = &mut self.geom {
|
if let Some(g) = &mut self.geom {
|
||||||
|
|
@ -480,6 +510,16 @@ mod tests {
|
||||||
assert_eq!(duplicate_stop(&g.stops, 7, 0.5).0, g.stops);
|
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]
|
#[test]
|
||||||
fn midpoint_math() {
|
fn midpoint_math() {
|
||||||
let g = g3();
|
let g = g3();
|
||||||
|
|
@ -492,6 +532,21 @@ mod tests {
|
||||||
assert_eq!(v[0].midpoint, 0.87);
|
assert_eq!(v[0].midpoint, 0.87);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebase_keeps_relative_positions() {
|
||||||
|
let mut g = GradientGeom { start: Point::new(110.0, 150.0), end: Point::new(190.0, 120.0), aspect: 0.5 };
|
||||||
|
g.rebase(Rect::new(100.0, 100.0, 200.0, 200.0), Rect::new(500.0, 0.0, 700.0, 50.0));
|
||||||
|
assert!(close(g.start, Point::new(520.0, 25.0)) && close(g.end, Point::new(680.0, 10.0)) && g.aspect == 0.5, "{g:?}");
|
||||||
|
// A box without height (a horizontal line) keeps the offset from its centre line.
|
||||||
|
let mut h = GradientGeom { start: Point::new(0.0, 12.0), end: Point::new(10.0, 10.0), aspect: 1.0 };
|
||||||
|
h.rebase(Rect::new(0.0, 10.0, 10.0, 10.0), Rect::new(0.0, 0.0, 20.0, 40.0));
|
||||||
|
assert!(close(h.start, Point::new(0.0, 22.0)) && close(h.end, Point::new(20.0, 20.0)), "{h:?}");
|
||||||
|
// Unplaced paints have nothing to move.
|
||||||
|
let mut p = GradientPaint::new(Gradient::default());
|
||||||
|
p.rebase(Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 9.0, 9.0));
|
||||||
|
assert_eq!(p.geom, None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pin_fixes_the_fit_once() {
|
fn pin_fixes_the_fit_once() {
|
||||||
let mut p = GradientPaint::new(Gradient::default());
|
let mut p = GradientPaint::new(Gradient::default());
|
||||||
|
|
|
||||||
|
|
@ -540,14 +540,49 @@ impl Appearance {
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/// Does a fill or stroke carry a gradient that is fitted to the bounds on each render?
|
/// Gradient paints of the fills and strokes.
|
||||||
pub fn has_unplaced_gradient(&self) -> bool {
|
fn gradients(&self) -> impl Iterator<Item = &vectorcraft_color::GradientPaint> {
|
||||||
|
self.items.iter().filter_map(|i| match i {
|
||||||
|
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
||||||
|
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => Some(&**g),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/// Is `self` equal to `other`, placed gradients within rounding (1e-6 pt) of each other, as
|
||||||
|
/// moving them between boxes leaves them?
|
||||||
|
pub fn approx_eq(&self, other: &Appearance) -> bool {
|
||||||
|
if self == other {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut a = self.clone();
|
||||||
|
for (g, o) in a.gradients_mut().zip(other.gradients()) {
|
||||||
|
if let (Some(ga), Some(go)) = (&mut g.geom, &o.geom)
|
||||||
|
&& ga.start.distance(go.start) <= 1e-6
|
||||||
|
&& ga.end.distance(go.end) <= 1e-6
|
||||||
|
&& (ga.aspect - go.aspect).abs() <= 1e-6
|
||||||
|
{
|
||||||
|
*ga = *go;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a == *other
|
||||||
|
}
|
||||||
|
/// Does a fill or stroke carry a gradient whose placement `placed` is (some: placed in document
|
||||||
|
/// space; none: fitted to the bounds on each render)?
|
||||||
|
fn has_gradient(&self, placed: bool) -> bool {
|
||||||
self.items.iter().any(|i| match i {
|
self.items.iter().any(|i| match i {
|
||||||
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. })
|
||||||
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_none(),
|
| AppearanceItem::Stroke(StrokeLayer { paint: Paint::Gradient(g), .. }) => g.geom.is_some() == placed,
|
||||||
_ => false,
|
_ => false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
/// Does a fill or stroke carry a gradient that is fitted to the bounds on each render?
|
||||||
|
pub fn has_unplaced_gradient(&self) -> bool {
|
||||||
|
self.has_gradient(false)
|
||||||
|
}
|
||||||
|
/// Does a fill or stroke carry a placed gradient (start and end in document space)?
|
||||||
|
pub fn has_placed_gradient(&self) -> bool {
|
||||||
|
self.has_gradient(true)
|
||||||
|
}
|
||||||
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
|
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
|
||||||
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
|
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
|
||||||
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
|
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
|
||||||
|
|
@ -564,6 +599,23 @@ impl Appearance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/// Move placed gradients from an object whose geometric bounds are `from` to one whose bounds
|
||||||
|
/// are `to`, keeping them at the same place relative to the box (strokes: relative to their
|
||||||
|
/// stroke-inflated boxes, as they fit).
|
||||||
|
pub fn rebase_gradients(&mut self, from: vectorcraft_geom::Rect, to: vectorcraft_geom::Rect) {
|
||||||
|
for i in &mut self.items {
|
||||||
|
match i {
|
||||||
|
AppearanceItem::Fill(FillLayer { paint: Paint::Gradient(g), .. }) => g.rebase(from, to),
|
||||||
|
AppearanceItem::Stroke(s) => {
|
||||||
|
let (f, t) = (s.paint_bounds(from), s.paint_bounds(to));
|
||||||
|
if let Paint::Gradient(g) = &mut s.paint {
|
||||||
|
g.rebase(f, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Map placed gradients through `a`.
|
/// Map placed gradients through `a`.
|
||||||
pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) {
|
pub fn transform_gradients(&mut self, a: vectorcraft_geom::Affine) {
|
||||||
for g in self.gradients_mut() {
|
for g in self.gradients_mut() {
|
||||||
|
|
@ -594,6 +646,25 @@ mod tests {
|
||||||
assert_eq!(a.stroke_width(), 1.0);
|
assert_eq!(a.stroke_width(), 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebase_moves_fills_by_the_box_and_strokes_by_their_inflated_box() {
|
||||||
|
use vectorcraft_color::{Gradient, GradientGeom, GradientPaint};
|
||||||
|
use vectorcraft_geom::{Point, Rect};
|
||||||
|
let placed = |x: f64| {
|
||||||
|
let mut g = GradientPaint::new(Gradient::default());
|
||||||
|
g.geom = Some(GradientGeom { start: Point::new(x, 0.0), end: Point::new(x + 10.0, 0.0), aspect: 1.0 });
|
||||||
|
Paint::Gradient(Box::new(g))
|
||||||
|
};
|
||||||
|
let mut a = Appearance::basic(placed(0.0), placed(-5.0), 10.0);
|
||||||
|
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
|
||||||
|
let start = |p: Paint| match p {
|
||||||
|
Paint::Gradient(g) => g.geom.unwrap().start,
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
|
||||||
|
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn items_by_index() {
|
fn items_by_index() {
|
||||||
let mut a = Appearance::default_art();
|
let mut a = Appearance::default_art();
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,11 @@ pub struct GraphicStyle {
|
||||||
pub isolate: bool,
|
pub isolate: bool,
|
||||||
#[serde(default, skip_serializing_if = "skip::is_default")]
|
#[serde(default, skip_serializing_if = "skip::is_default")]
|
||||||
pub knockout: Knockout,
|
pub knockout: Knockout,
|
||||||
|
/// Placed gradients are stored relative to the unit box (0, 0)–(1, 1), so applying the style
|
||||||
|
/// places them on each object's own bounds. Styles saved before this kept document
|
||||||
|
/// coordinates (false).
|
||||||
|
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||||
|
pub unit_box: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one() -> f32 {
|
fn one() -> f32 {
|
||||||
|
|
@ -292,14 +297,40 @@ fn one() -> f32 {
|
||||||
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
|
pub const DEFAULT_GRAPHIC_STYLE: &str = "Default Graphic Style";
|
||||||
|
|
||||||
impl GraphicStyle {
|
impl GraphicStyle {
|
||||||
/// A style with `appearance` and default transparency (no id yet).
|
/// The box a style's placed gradients are stored relative to.
|
||||||
|
pub const UNIT_BOX: Rect = Rect::new(0.0, 0.0, 1.0, 1.0);
|
||||||
|
|
||||||
|
/// A style with `appearance` (its placed gradients, if any, in unit-box space) and default
|
||||||
|
/// transparency (no id yet).
|
||||||
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
|
pub fn new(name: impl Into<String>, appearance: Appearance) -> Self {
|
||||||
Self { name: name.into(), appearance, id: 0, opacity: 1.0, blend: Default::default(), isolate: false, knockout: Knockout::Neutral }
|
Self {
|
||||||
|
name: name.into(),
|
||||||
|
appearance,
|
||||||
|
id: 0,
|
||||||
|
opacity: 1.0,
|
||||||
|
blend: Default::default(),
|
||||||
|
isolate: false,
|
||||||
|
knockout: Knockout::Neutral,
|
||||||
|
unit_box: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/// A style capturing `n`'s transparency and `appearance`.
|
/// A style capturing `n`'s transparency and `appearance` (in unit-box space).
|
||||||
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
|
pub fn of(name: impl Into<String>, appearance: Appearance, n: &Node) -> Self {
|
||||||
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
|
Self { opacity: n.opacity, blend: n.blend, isolate: n.isolate, knockout: n.knockout, ..Self::new(name, appearance) }
|
||||||
}
|
}
|
||||||
|
/// The style's appearance as object `n` takes it: placed gradients stored in unit-box space
|
||||||
|
/// land at the same place relative to `n`'s geometric bounds.
|
||||||
|
pub fn appearance_on(&self, n: &Node) -> std::borrow::Cow<'_, Appearance> {
|
||||||
|
let placed = self.unit_box && self.appearance.has_placed_gradient();
|
||||||
|
match n.geometric_bounds().filter(|_| placed) {
|
||||||
|
Some(b) => {
|
||||||
|
let mut ap = self.appearance.clone();
|
||||||
|
ap.rebase_gradients(Self::UNIT_BOX, b);
|
||||||
|
std::borrow::Cow::Owned(ap)
|
||||||
|
}
|
||||||
|
None => std::borrow::Cow::Borrowed(&self.appearance),
|
||||||
|
}
|
||||||
|
}
|
||||||
/// Give `n` this style's transparency.
|
/// Give `n` this style's transparency.
|
||||||
pub fn apply_transparency(&self, n: &mut Node) {
|
pub fn apply_transparency(&self, n: &mut Node) {
|
||||||
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
|
(n.opacity, n.blend, n.isolate, n.knockout) = (self.opacity, self.blend, self.isolate, self.knockout);
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Eyedropper",
|
"Eyedropper",
|
||||||
[],
|
[],
|
||||||
None,
|
None,
|
||||||
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
|
"{source: id, ids?, appearance?: bool, transparency?: bool (default: eyedropper.setOptions)} copy fill, stroke, weight and effects (appearance) and opacity and blend (transparency) from `source` to ids (default: selection), the appearance also to the paint defaults; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art)",
|
||||||
has_doc,
|
has_doc,
|
||||||
copy_from
|
copy_from
|
||||||
),
|
),
|
||||||
|
|
@ -461,18 +461,31 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
|
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
|
||||||
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
|
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
|
||||||
let appearance = match &src.kind {
|
// The appearance and the box its placed gradients are relative to (type: its first run's
|
||||||
NodeKind::Text(t) => match t.runs.first() {
|
// paints, in text space).
|
||||||
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
|
let (appearance, src_box) = match &src.kind {
|
||||||
None => src.appearance.clone(),
|
NodeKind::Text(t) if !t.runs.is_empty() => {
|
||||||
},
|
let r = &t.runs[0];
|
||||||
_ => src.appearance.clone(),
|
(Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width), Some(t.local_bounds()))
|
||||||
|
}
|
||||||
|
_ => (src.appearance.clone(), src.geometric_bounds()),
|
||||||
};
|
};
|
||||||
s.paint.fill = appearance.fill_paint();
|
// `appearance` placed on an object whose box (in its paint space) is `to`.
|
||||||
s.paint.stroke = appearance.stroke_paint();
|
let placed = |to: Option<vectorcraft_geom::Rect>| {
|
||||||
s.remember_paint(&s.paint.fill.clone());
|
let mut ap = appearance.clone();
|
||||||
if appearance.stroke().is_some() {
|
if let (Some(f), Some(t)) = (src_box, to) {
|
||||||
s.paint.stroke_width = appearance.stroke_width();
|
ap.rebase_gradients(f, t);
|
||||||
|
}
|
||||||
|
ap
|
||||||
|
};
|
||||||
|
let opts = s.eyedropper.with(p);
|
||||||
|
if opts.appearance {
|
||||||
|
s.paint.fill = super::gradient::unplaced(&appearance.fill_paint());
|
||||||
|
s.paint.stroke = super::gradient::unplaced(&appearance.stroke_paint());
|
||||||
|
s.remember_paint(&s.paint.fill.clone());
|
||||||
|
if appearance.stroke().is_some() {
|
||||||
|
s.paint.stroke_width = appearance.stroke_width();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let ids = match ids_param(p, "ids") {
|
let ids = match ids_param(p, "ids") {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
|
|
@ -480,24 +493,30 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
};
|
};
|
||||||
let mut targets = leaf_targets(s, &ids)?;
|
let mut targets = leaf_targets(s, &ids)?;
|
||||||
targets.retain(|id| *id != src_id);
|
targets.retain(|id| *id != src_id);
|
||||||
if targets.is_empty() {
|
if targets.is_empty() || !(opts.appearance || opts.transparency) {
|
||||||
return Ok(json!({ "ids": [] }));
|
return Ok(json!({ "ids": [] }));
|
||||||
}
|
}
|
||||||
let (opacity, blend) = (src.opacity, src.blend);
|
let (opacity, blend) = (src.opacity, src.blend);
|
||||||
s.edit("Eyedropper", |d, _| {
|
s.edit("Eyedropper", |d, _| {
|
||||||
for id in &targets {
|
for id in &targets {
|
||||||
let Some(n) = d.node_mut(*id) else { continue };
|
let Some(n) = d.node_mut(*id) else { continue };
|
||||||
n.opacity = opacity;
|
if opts.transparency {
|
||||||
n.blend = blend;
|
n.opacity = opacity;
|
||||||
|
n.blend = blend;
|
||||||
|
}
|
||||||
|
if !opts.appearance {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let NodeKind::Text(t) = &mut n.kind {
|
if let NodeKind::Text(t) = &mut n.kind {
|
||||||
|
let ap = placed(Some(t.local_bounds()));
|
||||||
for r in &mut t.runs {
|
for r in &mut t.runs {
|
||||||
r.style.fill = appearance.fill_paint();
|
r.style.fill = ap.fill_paint();
|
||||||
r.style.stroke = appearance.stroke_paint();
|
r.style.stroke = ap.stroke_paint();
|
||||||
r.style.stroke_width = appearance.stroke_width();
|
r.style.stroke_width = ap.stroke_width();
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
n.appearance = appearance.clone();
|
n.appearance = placed(n.geometric_bounds());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
|
|
@ -346,3 +346,6 @@ pub(crate) fn parse_range(s: &str, count: usize) -> std::result::Result<Vec<usiz
|
||||||
|
|
||||||
/// The Transparency panel's state ([`Session::transparency_info`]).
|
/// The Transparency panel's state ([`Session::transparency_info`]).
|
||||||
pub use opacitymask::TransparencyInfo;
|
pub use opacitymask::TransparencyInfo;
|
||||||
|
|
||||||
|
/// What the Eyedropper copies (`Session::eyedropper`).
|
||||||
|
pub use xform::EyedropperOptions;
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@
|
||||||
//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays
|
//! Applying a style links the targeted objects to it ([`Node::graphic_style`]). An object stays
|
||||||
//! linked while it keeps the style's look; editing its appearance or transparency breaks the link
|
//! linked while it keeps the style's look; editing its appearance or transparency breaks the link
|
||||||
//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked.
|
//! ([`in_sync`]), and Redefine Graphic Style updates only the objects still linked.
|
||||||
|
//!
|
||||||
|
//! Styles keep placed gradients relative to the unit box ([`GraphicStyle::unit_box`]): a new or
|
||||||
|
//! redefined style stores them relative to its source's bounds, and each object a style is applied
|
||||||
|
//! to gets them at the same place relative to its own.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
|
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
|
||||||
|
use vectorcraft_geom::Rect;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::EngineError;
|
use crate::EngineError;
|
||||||
|
|
@ -20,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Apply Graphic Style",
|
"Apply Graphic Style",
|
||||||
["Window", "Graphic Styles"],
|
["Window", "Graphic Styles"],
|
||||||
None,
|
None,
|
||||||
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it; add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
|
"{name, add?: bool, ids?} give the objects (default: the selection; a group's contents) the style's appearance, and themselves its opacity, blend mode, isolate and knockout, and link them to it (placed gradients land at the same place relative to each object's bounds); add: true (Alt-click) adds its fills, strokes and effects on top of the existing appearance instead and unlinks",
|
||||||
has_doc,
|
has_doc,
|
||||||
style_apply
|
style_apply
|
||||||
),
|
),
|
||||||
|
|
@ -29,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"New Graphic Style",
|
"New Graphic Style",
|
||||||
["Window", "Graphic Styles"],
|
["Window", "Graphic Styles"],
|
||||||
None,
|
None,
|
||||||
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'); links the object. Names are made unique → {name}",
|
"{name?, id?} a style from object `id` or the first selected object: its appearance, opacity, blend mode, isolate and knockout (a group without fills or strokes of its own lends its topmost object's, type its characters'; placed gradients are kept relative to its bounds); links the object. Names are made unique → {name}",
|
||||||
has_doc,
|
has_doc,
|
||||||
style_new
|
style_new
|
||||||
),
|
),
|
||||||
|
|
@ -136,7 +141,7 @@ fn paint_mut(n: &mut Node, top: bool, f: &mut impl FnMut(&mut Node)) {
|
||||||
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
|
pub(crate) fn in_sync(n: &Node, g: &GraphicStyle) -> bool {
|
||||||
let mut leaves = vec![];
|
let mut leaves = vec![];
|
||||||
painted(n, true, &mut leaves);
|
painted(n, true, &mut leaves);
|
||||||
g.transparency_matches(n) && leaves.iter().all(|l| l.appearance == g.appearance)
|
g.transparency_matches(n) && leaves.iter().all(|l| g.appearance_on(l).approx_eq(&l.appearance))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The style `n` is linked to (and still looks like).
|
/// The style `n` is linked to (and still looks like).
|
||||||
|
|
@ -170,18 +175,29 @@ fn links(d: &Document) -> HashMap<u32, Vec<NodeId>> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `ap` with its placed gradients moved from box `from` (an object's bounds) to the unit box.
|
||||||
|
fn unitized(mut ap: Appearance, from: Option<Rect>) -> Appearance {
|
||||||
|
if let Some(b) = from.filter(|_| ap.has_placed_gradient()) {
|
||||||
|
ap.rebase_gradients(b, GraphicStyle::UNIT_BOX);
|
||||||
|
}
|
||||||
|
ap
|
||||||
|
}
|
||||||
|
|
||||||
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
|
/// The appearance a new or redefined style takes from `n`: its own. A group without fills or
|
||||||
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
|
/// strokes of its own lends those of its topmost painted object, and type its characters' fill
|
||||||
/// and stroke.
|
/// and stroke. Placed gradients come relative to the unit box (from the bounds of the object
|
||||||
|
/// they're on).
|
||||||
fn captured(n: &Node) -> Appearance {
|
fn captured(n: &Node) -> Appearance {
|
||||||
let mut ap = n.appearance.clone();
|
let mut ap = n.appearance.clone();
|
||||||
if !ap.items.is_empty() {
|
if !ap.items.is_empty() {
|
||||||
return ap;
|
return unitized(ap, n.geometric_bounds());
|
||||||
}
|
}
|
||||||
match &n.kind {
|
match &n.kind {
|
||||||
NodeKind::Text(t) => {
|
NodeKind::Text(t) => {
|
||||||
if let Some(r) = t.runs.first() {
|
if let Some(r) = t.runs.first() {
|
||||||
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
|
ap.items = Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width).items;
|
||||||
|
// Type paints its characters in text space.
|
||||||
|
ap = unitized(ap, Some(t.local_bounds()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|
@ -201,7 +217,7 @@ fn captured(n: &Node) -> Appearance {
|
||||||
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
|
/// Give `id` style `g` (its painted objects the appearance, itself the transparency) and link it.
|
||||||
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
|
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle) {
|
||||||
if let Some(n) = d.node_mut(id) {
|
if let Some(n) = d.node_mut(id) {
|
||||||
paint_mut(n, true, &mut |l| l.appearance = g.appearance.clone());
|
paint_mut(n, true, &mut |l| l.appearance = g.appearance_on(l).into_owned());
|
||||||
g.apply_transparency(n);
|
g.apply_transparency(n);
|
||||||
n.graphic_style = Some(g.id);
|
n.graphic_style = Some(g.id);
|
||||||
}
|
}
|
||||||
|
|
@ -251,12 +267,13 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
}
|
}
|
||||||
s.edit("Apply Graphic Style", |d, _| {
|
s.edit("Apply Graphic Style", |d, _| {
|
||||||
if add {
|
if add {
|
||||||
let ap = d.graphic_styles[i].appearance.clone();
|
let g = d.graphic_styles[i].clone();
|
||||||
for id in &ids {
|
for id in &ids {
|
||||||
if let Some(n) = d.node_mut(*id) {
|
if let Some(n) = d.node_mut(*id) {
|
||||||
paint_mut(n, true, &mut |l| {
|
paint_mut(n, true, &mut |l| {
|
||||||
l.appearance.items.extend(ap.items.iter().cloned());
|
let ap = g.appearance_on(l).into_owned();
|
||||||
l.appearance.effects.extend(ap.effects.iter().cloned());
|
l.appearance.items.extend(ap.items);
|
||||||
|
l.appearance.effects.extend(ap.effects);
|
||||||
});
|
});
|
||||||
n.graphic_style = None;
|
n.graphic_style = None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
use vectorcraft_color::Paint;
|
||||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||||
|
|
||||||
|
|
@ -26,10 +27,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
"Sample Color",
|
"Sample Color",
|
||||||
[],
|
[],
|
||||||
None,
|
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,
|
has_doc,
|
||||||
sample_color
|
sample_color
|
||||||
),
|
),
|
||||||
|
cmd!(
|
||||||
|
"eyedropper.setOptions",
|
||||||
|
"Eyedropper Options",
|
||||||
|
[],
|
||||||
|
None,
|
||||||
|
"{appearance?: bool (fill, stroke and effects), transparency?: bool (opacity and blend mode)} what the Eyedropper copies (appearance.copyFrom); {} reads them → {appearance, transparency}",
|
||||||
|
always,
|
||||||
|
eyedropper_options
|
||||||
|
),
|
||||||
cmd!(
|
cmd!(
|
||||||
"artboard.move",
|
"artboard.move",
|
||||||
"Move Artboard",
|
"Move Artboard",
|
||||||
|
|
@ -161,14 +171,57 @@ fn distort(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
|
||||||
// ---------- eyedropper ----------
|
// ---------- eyedropper ----------
|
||||||
|
|
||||||
|
/// What the Eyedropper copies from the object it clicks (`appearance.copyFrom`).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct EyedropperOptions {
|
||||||
|
/// The fill, stroke and effects (the appearance stack; type: its run paints).
|
||||||
|
pub appearance: bool,
|
||||||
|
/// The opacity and blend mode.
|
||||||
|
pub transparency: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EyedropperOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { appearance: true, transparency: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EyedropperOptions {
|
||||||
|
/// These options with any `appearance` / `transparency` booleans in `p` applied.
|
||||||
|
pub(crate) fn with(mut self, p: &Value) -> Self {
|
||||||
|
self.appearance = bool_or(p, "appearance", self.appearance);
|
||||||
|
self.transparency = bool_or(p, "transparency", self.transparency);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
s.eyedropper = s.eyedropper.with(p);
|
||||||
|
Ok(serde_json::to_value(s.eyedropper).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
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 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 c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
|
||||||
let mut q = json!({ "color": c });
|
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") {
|
if let Some(ids) = p.get("ids") {
|
||||||
q["ids"] = ids.clone();
|
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()))?;
|
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||||
(spec.run)(s, &q)
|
(spec.run)(s, &q)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ use vectorcraft_doc::{Document, NodeId, NodeKind, Selection};
|
||||||
use vectorcraft_geom::Affine;
|
use vectorcraft_geom::Affine;
|
||||||
use vectorcraft_tools::{PaintDefaults, Tool};
|
use vectorcraft_tools::{PaintDefaults, Tool};
|
||||||
|
|
||||||
|
pub use cmd::EyedropperOptions;
|
||||||
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
pub use cmd::rasterfx::{export_pdf, flatten_raster_effects};
|
||||||
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
|
||||||
pub use tooling::{UiRequest, ViewInfo};
|
pub use tooling::{UiRequest, ViewInfo};
|
||||||
|
|
@ -459,6 +460,8 @@ pub struct Session {
|
||||||
pub recent_colors: Vec<Color>,
|
pub recent_colors: Vec<Color>,
|
||||||
/// A paint applied by a live preview: remembered when the interaction commits.
|
/// A paint applied by a live preview: remembered when the interaction commits.
|
||||||
pub(crate) pending_paint: Option<Paint>,
|
pub(crate) pending_paint: Option<Paint>,
|
||||||
|
/// What the Eyedropper copies (`eyedropper.setOptions`); not saved.
|
||||||
|
pub eyedropper: EyedropperOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Session {
|
impl Default for Session {
|
||||||
|
|
@ -490,6 +493,7 @@ impl Session {
|
||||||
last_gradient: GradientPaint::new(Default::default()),
|
last_gradient: GradientPaint::new(Default::default()),
|
||||||
recent_colors: vec![],
|
recent_colors: vec![],
|
||||||
pending_paint: None,
|
pending_paint: None,
|
||||||
|
eyedropper: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -754,6 +758,8 @@ mod tests_file;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_gradient;
|
mod tests_gradient;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_gradpanel;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_knockout;
|
mod tests_knockout;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_live;
|
mod tests_live;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
//! Gradient panel and gradient behaviour: sampling a colour into a gradient stop (M3.33) and
|
||||||
|
//! copied appearances placing their gradients on the target's own bounds (M3.34) and the
|
||||||
|
//! Gradient tool's snapping and Eyedropper Options (M3.35).
|
||||||
|
|
||||||
|
use serde_json::json;
|
||||||
|
use vectorcraft_color::{GradientGeom, Paint};
|
||||||
|
use vectorcraft_geom::Point;
|
||||||
|
|
||||||
|
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()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fill_geom(s: &Session, id: NodeId) -> Option<GradientGeom> {
|
||||||
|
match s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() {
|
||||||
|
Paint::Gradient(g) => g.geom,
|
||||||
|
p => panic!("expected a gradient, got {p:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(a: Point, b: Point) -> bool {
|
||||||
|
a.distance(b) < 1e-9
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A 100 × 100 source at (10, 10) with a placed gradient from 20 % to 80 % across its middle,
|
||||||
|
/// and a 200 × 100 target at x = 500.
|
||||||
|
fn source_and_target() -> (Session, NodeId, NodeId) {
|
||||||
|
let mut s = session();
|
||||||
|
let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||||||
|
s.execute("paint.setFill", &json!({"gradient": {"start": [30, 60], "end": [90, 60]}})).unwrap();
|
||||||
|
let dst = rect(&mut s, 500.0, 10.0, 200.0, 100.0);
|
||||||
|
(s, src, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_eyedropper_places_copied_gradients_on_the_target() {
|
||||||
|
let (mut s, src, dst) = source_and_target();
|
||||||
|
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
|
||||||
|
let g = fill_geom(&s, dst).expect("placed");
|
||||||
|
assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}");
|
||||||
|
// The source keeps its own; new art fits the gradient to itself.
|
||||||
|
assert!(close(fill_geom(&s, src).unwrap().start, Point::new(30.0, 60.0)));
|
||||||
|
assert!(matches!(&s.paint.fill, Paint::Gradient(g) if g.geom.is_none()));
|
||||||
|
// The eyedropper tool's click runs the same command.
|
||||||
|
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
s.select_tool("eyedropper", ViewInfo::default()).unwrap();
|
||||||
|
let ev = vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::Down, 50.0, 50.0);
|
||||||
|
s.pointer(&ev, ViewInfo::default()).unwrap();
|
||||||
|
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(540.0, 60.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn graphic_styles_keep_gradients_relative_to_the_bounds() {
|
||||||
|
let (mut s, src, dst) = source_and_target();
|
||||||
|
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||||
|
s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap();
|
||||||
|
let style = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap();
|
||||||
|
let Paint::Gradient(g) = style.appearance.fill_paint() else { panic!() };
|
||||||
|
assert!(style.unit_box && close(g.geom.unwrap().start, Point::new(0.2, 0.5)), "stored in unit-box space: {g:?}");
|
||||||
|
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||||||
|
s.execute("graphicStyle.apply", &json!({"name": "Glow"})).unwrap();
|
||||||
|
let g = fill_geom(&s, dst).unwrap();
|
||||||
|
assert!(close(g.start, Point::new(540.0, 60.0)) && close(g.end, Point::new(660.0, 60.0)), "{g:?}");
|
||||||
|
// Both stay linked (each has the style's look on its own bounds); redefining the style from an
|
||||||
|
// edited source moves the new placement onto the linked target's bounds too.
|
||||||
|
let linked = |s: &mut Session| {
|
||||||
|
let list = s.execute("graphicStyle.list", &json!({})).unwrap();
|
||||||
|
list["styles"].as_array().unwrap().iter().find(|g| g["name"] == "Glow").unwrap()["linked"].clone()
|
||||||
|
};
|
||||||
|
assert_eq!(linked(&mut s), json!([src.0, dst.0]));
|
||||||
|
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
|
||||||
|
s.execute("paint.setFill", &json!({"gradient": {"start": [13.3, 47.7], "end": [97.1, 71.9]}})).unwrap();
|
||||||
|
assert_eq!(linked(&mut s), json!([dst.0]), "the edited source is no longer linked");
|
||||||
|
s.execute("graphicStyle.redefine", &json!({"name": "Glow"})).unwrap();
|
||||||
|
let g = fill_geom(&s, dst).unwrap();
|
||||||
|
assert!(close(g.start, Point::new(506.6, 47.7)) && close(g.end, Point::new(674.2, 71.9)), "{g:?}");
|
||||||
|
assert_eq!(linked(&mut s), json!([src.0, dst.0]));
|
||||||
|
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
|
||||||
|
// The flag survives a save; a style from an older file (no flag) keeps document coordinates.
|
||||||
|
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||||
|
let back = vectorcraft_format::load(&bytes).unwrap();
|
||||||
|
assert_eq!(back.graphic_styles, s.doc().unwrap().doc.graphic_styles);
|
||||||
|
let legacy: vectorcraft_doc::GraphicStyle = serde_json::from_value(json!({"name": "Old", "appearance": style.appearance})).unwrap();
|
||||||
|
assert!(!legacy.unit_box);
|
||||||
|
s.edit("Add", |d, _| {
|
||||||
|
d.graphic_styles.push(legacy);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
s.execute("graphicStyle.apply", &json!({"name": "Old"})).unwrap();
|
||||||
|
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn eyedropper_options_choose_what_copy_from_takes() {
|
||||||
|
let mut s = session();
|
||||||
|
let src = rect(&mut s, 10.0, 10.0, 100.0, 100.0);
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||||
|
s.execute("transparency.set", &json!({"opacity": 40})).unwrap();
|
||||||
|
let dst = rect(&mut s, 200.0, 10.0, 100.0, 100.0);
|
||||||
|
s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap();
|
||||||
|
assert_eq!(s.execute("eyedropper.setOptions", &json!({})).unwrap(), json!({"appearance": true, "transparency": true}));
|
||||||
|
assert_eq!(s.execute("eyedropper.setOptions", &json!({"appearance": false})).unwrap(), json!({"appearance": false, "transparency": true}));
|
||||||
|
let n = |s: &Session| s.doc().unwrap().doc.node(dst).unwrap().clone();
|
||||||
|
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0]})).unwrap();
|
||||||
|
assert_eq!((n(&s).opacity, n(&s).appearance.fill_paint().color().unwrap().to_hex()), (0.4, "#ffffff".into()));
|
||||||
|
// Params override the options for one call.
|
||||||
|
s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "appearance": true})).unwrap();
|
||||||
|
assert_eq!(n(&s).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
||||||
|
// Nothing to copy: no edit.
|
||||||
|
let undo = s.doc().unwrap().history.undo.len();
|
||||||
|
assert_eq!(s.execute("appearance.copyFrom", &json!({"source": src.0, "ids": [dst.0], "transparency": false})).unwrap(), json!({"ids": []}));
|
||||||
|
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_gradient_tool_reads_the_constrain_angle_preference() {
|
||||||
|
let mut s = session();
|
||||||
|
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
|
||||||
|
s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap();
|
||||||
|
s.select_tool("gradient", ViewInfo::default()).unwrap();
|
||||||
|
let ev = |kind, x, y, shift| vectorcraft_tools::PointerEvent::new(kind, x, y).with_mods(vectorcraft_tools::Mods { shift, ..Default::default() });
|
||||||
|
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||||
|
s.pointer(&ev(Down, 120.0, 180.0, false), ViewInfo::default()).unwrap();
|
||||||
|
s.pointer(&ev(Drag, 190.0, 140.0, true), ViewInfo::default()).unwrap();
|
||||||
|
s.pointer(&ev(Up, 190.0, 140.0, true), ViewInfo::default()).unwrap();
|
||||||
|
let id = s.doc().unwrap().selection.objects[0];
|
||||||
|
assert!((fill_geom(&s, id).unwrap().angle_deg() - 30.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,7 @@ impl Session {
|
||||||
fill_active: self.fill_active,
|
fill_active: self.fill_active,
|
||||||
gradient_stop: self.selected_stop(),
|
gradient_stop: self.selected_stop(),
|
||||||
appearance_item: self.appearance_item(),
|
appearance_item: self.appearance_item(),
|
||||||
|
constrain_angle: self.prefs.constrain_angle,
|
||||||
};
|
};
|
||||||
f(self.tool.as_mut(), &cx)
|
f(self.tool.as_mut(), &cx)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,18 @@ pub fn normalize_deg(mut a: f64) -> f64 {
|
||||||
|
|
||||||
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
|
/// Snap a vector to the nearest multiple of `step_deg` (used for Shift-constrained drags).
|
||||||
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
|
pub fn constrain_angle(v: Vec2, step_deg: f64) -> Vec2 {
|
||||||
|
constrain_angle_from(v, step_deg, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`constrain_angle`] in steps counted from `base_deg` (the Constrain Angle preference,
|
||||||
|
/// counter-clockwise as seen on the y-down page).
|
||||||
|
pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 {
|
||||||
let len = v.hypot();
|
let len = v.hypot();
|
||||||
if len < EPS {
|
if len < EPS {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
let step = step_deg.to_radians();
|
let (step, base) = (step_deg.to_radians(), -base_deg.to_radians());
|
||||||
let a = (v.y.atan2(v.x) / step).round() * step;
|
let a = base + ((v.y.atan2(v.x) - base) / step).round() * step;
|
||||||
Vec2::new(a.cos() * len, a.sin() * len)
|
Vec2::new(a.cos() * len, a.sin() * len)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,6 +89,15 @@ mod tests {
|
||||||
assert!((v.x - v.y).abs() < 1e-9);
|
assert!((v.x - v.y).abs() < 1e-9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constrain_from_a_base_angle() {
|
||||||
|
// 30° base: 45° steps land on 30°, 75°, … (counter-clockwise on the page, y down).
|
||||||
|
let deg = |v: Vec2| (-v.y).atan2(v.x).to_degrees();
|
||||||
|
assert!((deg(constrain_angle_from(Vec2::new(10.0, -4.0), 45.0, 30.0)) - 30.0).abs() < 1e-9);
|
||||||
|
assert!((deg(constrain_angle_from(Vec2::new(3.0, -10.0), 45.0, 30.0)) - 75.0).abs() < 1e-9);
|
||||||
|
assert!((deg(constrain_angle_from(Vec2::new(10.0, 1.0), 45.0, 30.0)) + 15.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reference_points() {
|
fn reference_points() {
|
||||||
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
|
let r = Rect::new(0.0, 0.0, 10.0, 20.0);
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ pub struct ToolContext<'a> {
|
||||||
/// The Appearance panel's active fill/stroke (paint-order index into the first selected
|
/// The Appearance panel's active fill/stroke (paint-order index into the first selected
|
||||||
/// object's stack); paint tools show and edit that item.
|
/// object's stack); paint tools show and edit that item.
|
||||||
pub appearance_item: Option<usize>,
|
pub appearance_item: Option<usize>,
|
||||||
|
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
|
||||||
|
pub constrain_angle: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolContext<'_> {
|
impl ToolContext<'_> {
|
||||||
|
|
@ -346,6 +348,7 @@ pub(crate) mod testutil {
|
||||||
fill_active: true,
|
fill_active: true,
|
||||||
gradient_stop: None,
|
gradient_stop: None,
|
||||||
appearance_item: None,
|
appearance_item: None,
|
||||||
|
constrain_angle: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||||
"scale" => Box::new(TransformTool::new(TransformKind::Scale)),
|
"scale" => Box::new(TransformTool::new(TransformKind::Scale)),
|
||||||
"shear" => Box::new(TransformTool::new(TransformKind::Shear)),
|
"shear" => Box::new(TransformTool::new(TransformKind::Shear)),
|
||||||
"freeTransform" => Box::new(FreeTransformTool::default()),
|
"freeTransform" => Box::new(FreeTransformTool::default()),
|
||||||
"eyedropper" => Box::new(EyedropperTool),
|
"eyedropper" => Box::new(EyedropperTool::default()),
|
||||||
"gradient" => Box::new(GradientTool::default()),
|
"gradient" => Box::new(GradientTool::default()),
|
||||||
"artboard" => Box::new(ArtboardTool::default()),
|
"artboard" => Box::new(ArtboardTool::default()),
|
||||||
"magicWand" => Box::new(MagicWandTool),
|
"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).
|
/// 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) {
|
match doc.parent_of(leaf) {
|
||||||
Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p,
|
Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p,
|
||||||
_ => leaf,
|
_ => leaf,
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,12 @@
|
||||||
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
|
//! 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
|
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
|
||||||
//! the active fill/stroke (`paint.sampleColor`).
|
//! 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_color::{Color, Paint};
|
||||||
use vectorcraft_doc::hit::{HitKind, hit_test};
|
use vectorcraft_doc::hit::{HitKind, hit_test};
|
||||||
use vectorcraft_geom::Point;
|
use vectorcraft_geom::Point;
|
||||||
|
|
@ -12,7 +16,11 @@ use vectorcraft_geom::Point;
|
||||||
use super::paint_owner;
|
use super::paint_owner;
|
||||||
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
|
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
|
/// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring
|
||||||
/// their aspect ratio).
|
/// 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 Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] };
|
||||||
let src = paint_owner(cx.doc, h.leaf);
|
let src = paint_owner(cx.doc, h.leaf);
|
||||||
let Some(n) = cx.doc.node(src) else { return vec![] };
|
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 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![] };
|
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.
|
// 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 };
|
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) {
|
let Some(c) = paint_color_at(paint, Some(bounds), p) else { return vec![] };
|
||||||
Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))],
|
let Some((back, i)) = stop else { return vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))] };
|
||||||
None => vec![],
|
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 }))]
|
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 {
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
|
||||||
Cursor::Eyedropper
|
Cursor::Eyedropper
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +91,7 @@ mod tests {
|
||||||
let s = Selection::default();
|
let s = Selection::default();
|
||||||
let p = paint();
|
let p = paint();
|
||||||
let cx = cx(&d, &s, &p);
|
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));
|
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}))]);
|
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
|
||||||
let shift = Mods { shift: true, ..Default::default() };
|
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());
|
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]
|
#[test]
|
||||||
fn gradient_sampled_along_vector() {
|
fn gradient_sampled_along_vector() {
|
||||||
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
let g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
|
||||||
|
|
@ -109,7 +149,7 @@ mod tests {
|
||||||
let (s, p) = (Selection::default(), paint());
|
let (s, p) = (Selection::default(), paint());
|
||||||
let shift = Mods { shift: true, ..Default::default() };
|
let shift = Mods { shift: true, ..Default::default() };
|
||||||
// 80 % along the baseline (text space (80, -5)).
|
// 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"}))]);
|
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#333333"}))]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
//! Gradient tool (G) and the gradient annotator.
|
//! Gradient tool (G) and the gradient annotator.
|
||||||
//!
|
//!
|
||||||
//! Drag across selected objects to set the gradient vector of the paint behind the active proxy
|
//! Drag across selected objects to set the gradient vector of the paint behind the active proxy
|
||||||
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. Shift
|
//! (fill or stroke; type objects' runs), a solid paint becoming the default gradient. With nothing
|
||||||
//! constrains the angle to 45°. With nothing selected, the press targets the object under the
|
//! selected, the press targets the object under the
|
||||||
//! pointer; a click inside selected art applies the gradient from that point.
|
//! pointer; a click inside selected art applies the gradient from that point.
|
||||||
//!
|
//!
|
||||||
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
|
//! The annotator is a bar from a round start handle to a square end handle. Drag the start handle
|
||||||
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
|
//! (or the bar) to move the gradient, the end handle to change its length and angle, and just past
|
||||||
//! the end to rotate it (Shift snaps to 45°). Colour stops sit under the bar: click the bar to add
|
//! the end to rotate it. Dragged handles snap to anchors, edges and smart guides; Shift instead
|
||||||
|
//! constrains the vector to 45° steps from the Constrain Angle preference. Colour stops sit under the bar: click the bar to add
|
||||||
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
|
//! one, drag one to move it (Alt drags a copy), drag it off the bar to delete it, and drag a
|
||||||
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
|
//! diamond above the bar to move a midpoint. Double-clicking a stop opens its popover. The
|
||||||
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
|
//! selected stop (`gradient.selectStop`) is shared with the Gradient and Color panels: Delete or
|
||||||
|
|
@ -18,9 +19,10 @@ use vectorcraft_color::gradient::{duplicate_stop, insert_stop, midpoint_from_pos
|
||||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientStop};
|
||||||
use vectorcraft_doc::NodeId;
|
use vectorcraft_doc::NodeId;
|
||||||
use vectorcraft_doc::hit::hit_test;
|
use vectorcraft_doc::hit::hit_test;
|
||||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
use vectorcraft_geom::{Affine, Point, Vec2, constrain_angle_from};
|
||||||
|
|
||||||
use super::paint_owner;
|
use super::paint_owner;
|
||||||
|
use crate::guides::snap_draw;
|
||||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||||
|
|
||||||
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
|
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
|
||||||
|
|
@ -181,6 +183,8 @@ struct Gesture {
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct GradientTool {
|
pub struct GradientTool {
|
||||||
gesture: Option<Gesture>,
|
gesture: Option<Gesture>,
|
||||||
|
/// Smart-guide feedback of the handle being dragged.
|
||||||
|
guides: Vec<Overlay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
|
/// Overlays of an annotator: the bar, the handles, the midpoint diamonds and a chip per stop.
|
||||||
|
|
@ -260,7 +264,9 @@ impl GradientTool {
|
||||||
Grab::Art
|
Grab::Art
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.gesture = Some(Gesture { grab, at: p, began: false, vector: None, stop: None });
|
// A vector drawn on the art starts on the snapped point.
|
||||||
|
let at = if grab == Grab::Art { snap_draw(cx, p, &[]).0 } else { p };
|
||||||
|
self.gesture = Some(Gesture { grab, at, began: false, vector: None, stop: None });
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,20 +280,33 @@ impl GradientTool {
|
||||||
g.began = true;
|
g.began = true;
|
||||||
out.push(Action::Begin("Gradient".into()));
|
out.push(Action::Begin("Gradient".into()));
|
||||||
}
|
}
|
||||||
let snap = |from: Point, to: Point| if mods.shift { from + vectorcraft_geom::constrain_angle(to - from, 45.0) } else { to };
|
let constrain = |v: Vec2| constrain_angle_from(v, 45.0, cx.constrain_angle);
|
||||||
|
// Where a handle dragged to `to` lands: Shift constrains the vector from `from`, else it
|
||||||
|
// snaps (keeping the guides to show).
|
||||||
|
let mut guides = vec![];
|
||||||
|
let mut place = |from: Point, to: Point| {
|
||||||
|
if mods.shift {
|
||||||
|
from + constrain(to - from)
|
||||||
|
} else {
|
||||||
|
let (q, o) = snap_draw(cx, to, &[]);
|
||||||
|
guides = o;
|
||||||
|
q
|
||||||
|
}
|
||||||
|
};
|
||||||
let (cmd, params) = match &g.grab {
|
let (cmd, params) = match &g.grab {
|
||||||
Grab::Art => {
|
Grab::Art => {
|
||||||
let end = snap(g.at, p);
|
let end = place(g.at, p);
|
||||||
g.vector = Some((g.at, end));
|
g.vector = Some((g.at, end));
|
||||||
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
|
("paint.setGradientGeom", Self::geom_params(cx, g.at, end))
|
||||||
}
|
}
|
||||||
Grab::Move { geom, .. } => {
|
Grab::Move { geom, .. } => {
|
||||||
let d = p - g.at;
|
// The start handle snaps; Shift constrains the move.
|
||||||
("paint.setGradientGeom", Self::geom_params(cx, geom.start + d, geom.end + d))
|
let start = place(geom.start, geom.start + (p - g.at));
|
||||||
|
("paint.setGradientGeom", Self::geom_params(cx, start, start + (geom.end - geom.start)))
|
||||||
}
|
}
|
||||||
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, snap(geom.start, p))),
|
Grab::End(geom) => ("paint.setGradientGeom", Self::geom_params(cx, geom.start, place(geom.start, p))),
|
||||||
Grab::Rotate(geom) => {
|
Grab::Rotate(geom) => {
|
||||||
let dir = snap(geom.start, p) - geom.start;
|
let dir = if mods.shift { constrain(p - geom.start) } else { p - geom.start };
|
||||||
let len = dir.hypot();
|
let len = dir.hypot();
|
||||||
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
|
let end = if len < 1e-12 { geom.end } else { geom.start + dir * (geom.length() / len) };
|
||||||
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
|
("paint.setGradientGeom", Self::geom_params(cx, geom.start, end))
|
||||||
|
|
@ -313,11 +332,13 @@ impl GradientTool {
|
||||||
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
|
("paint.editGradient", Self::stops_params(cx, &set_midpoint(stops, *index, m)))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.guides = guides;
|
||||||
out.push(Action::Preview(cmd.into(), params));
|
out.push(Action::Preview(cmd.into(), params));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
fn release(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||||||
|
self.guides.clear();
|
||||||
let Some(g) = self.gesture.take() else { return vec![] };
|
let Some(g) = self.gesture.take() else { return vec![] };
|
||||||
if g.began {
|
if g.began {
|
||||||
let mut out = vec![Action::Commit];
|
let mut out = vec![Action::Commit];
|
||||||
|
|
@ -386,6 +407,7 @@ impl Tool for GradientTool {
|
||||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec<Action> {
|
||||||
if key == ToolKey::Escape && self.busy() {
|
if key == ToolKey::Escape && self.busy() {
|
||||||
self.gesture = None;
|
self.gesture = None;
|
||||||
|
self.guides.clear();
|
||||||
return vec![Action::Cancel];
|
return vec![Action::Cancel];
|
||||||
}
|
}
|
||||||
if self.busy() || !self.claims_key(cx, key) {
|
if self.busy() || !self.claims_key(cx, key) {
|
||||||
|
|
@ -411,14 +433,16 @@ impl Tool for GradientTool {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||||
match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
|
let mut o = match (Annotator::of(cx), self.gesture.as_ref().and_then(|g| g.vector)) {
|
||||||
(Some(a), _) => annotator_overlays(cx, &a),
|
(Some(a), _) => annotator_overlays(cx, &a),
|
||||||
// A vector drawn on a paint that isn't a gradient yet.
|
// A vector drawn on a paint that isn't a gradient yet.
|
||||||
(None, Some((s, e))) => {
|
(None, Some((s, e))) => {
|
||||||
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
|
annotator_overlays(cx, &Annotator::with(GradientGeom { start: s, end: e, aspect: 1.0 }, &Gradient::default().stops))
|
||||||
}
|
}
|
||||||
_ => vec![],
|
_ => vec![],
|
||||||
}
|
};
|
||||||
|
o.extend(self.guides.iter().cloned());
|
||||||
|
o
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
|
||||||
|
|
@ -581,6 +605,39 @@ mod tests {
|
||||||
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
|
assert!(end.distance(Point::new(100.0, 50.0)) < 1e-9, "{end:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_snap_to_anchors_and_shift_uses_the_constrain_angle() {
|
||||||
|
let (d, s) = graded(two());
|
||||||
|
let p = paint();
|
||||||
|
let mut cx = cx(&d, &s, &p);
|
||||||
|
let mut t = GradientTool::default();
|
||||||
|
// The end handle dragged near the rectangle's corner lands on it, with a guide label.
|
||||||
|
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
|
||||||
|
let v = preview(&t.pointer(&cx, &ev(PointerKind::Drag, Point::new(202.0, 197.0)))).clone();
|
||||||
|
assert_eq!(point(&v["end"]), Point::new(200.0, 200.0));
|
||||||
|
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "anchor")));
|
||||||
|
t.pointer(&cx, &ev(PointerKind::Up, Point::new(202.0, 197.0)));
|
||||||
|
assert!(!t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { .. })), "the guides go with the drag");
|
||||||
|
// A vector drawn from near a corner starts on it.
|
||||||
|
let (d2, id) = doc_with_rect();
|
||||||
|
let mut s2 = Selection::default();
|
||||||
|
s2.add(id);
|
||||||
|
let cx2 = crate::testutil::cx(&d2, &s2, &p);
|
||||||
|
t.pointer(&cx2, &ev(PointerKind::Down, Point::new(101.0, 102.0)));
|
||||||
|
let v = preview(&t.pointer(&cx2, &ev(PointerKind::Drag, Point::new(150.0, 130.0)))).clone();
|
||||||
|
assert_eq!(point(&v["start"]), Point::new(100.0, 100.0));
|
||||||
|
t.pointer(&cx2, &ev(PointerKind::Up, Point::new(150.0, 130.0)));
|
||||||
|
// Constrain Angle 30: Shift gives 30 and 75 degrees (and no snapping).
|
||||||
|
cx.constrain_angle = 30.0;
|
||||||
|
let angle = |v: &Value| GradientGeom { start: point(&v["start"]), end: point(&v["end"]), aspect: 1.0 }.angle_deg();
|
||||||
|
for (to, want) in [(Point::new(190.0, 100.0), 30.0), (Point::new(125.0, 60.0), 75.0)] {
|
||||||
|
t.pointer(&cx, &ev(PointerKind::Down, Point::new(200.0, 150.0)));
|
||||||
|
let v = preview(&t.pointer(&cx, &with(SHIFT, ev(PointerKind::Drag, to)))).clone();
|
||||||
|
assert!((angle(&v) - want).abs() < 1e-9, "{want}: {v}");
|
||||||
|
t.pointer(&cx, &ev(PointerKind::Up, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_bar_click_adds_a_stop_and_selects_it() {
|
fn a_bar_click_adds_a_stop_and_selects_it() {
|
||||||
let (d, s) = graded(two());
|
let (d, s) = graded(two());
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
handle_input(app, ui, &resp, rect);
|
handle_input(app, ui, &resp, rect);
|
||||||
let v = *app.view().unwrap_or(&View::default());
|
let v = *app.view().unwrap_or(&View::default());
|
||||||
let xf = Xf::new(rect, &v);
|
let xf = Xf::new(rect, &v);
|
||||||
paint_drop(app, ui, &resp, &xf);
|
panel_drop(app, ui, &resp, &xf);
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
let Some(st) = app.session.active() else { return };
|
let Some(st) = app.session.active() else { return };
|
||||||
let doc = st.doc.clone();
|
let doc = st.doc.clone();
|
||||||
|
|
@ -674,21 +674,33 @@ fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option<vectorcraft_doc::
|
||||||
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
vectorcraft_doc::hit::hit_test(&app.session.active()?.doc, p, opt)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Swatches and Fill/Stroke proxies dropped on art paint the object under the pointer (the active
|
/// A panel drag ([`widgets::PanelDrag`]) dropped on art acts on the object under the pointer,
|
||||||
/// proxy's fill or stroke); a chip of the dragged paint follows the pointer meanwhile.
|
/// selected or not: a paint (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail) goes
|
||||||
fn paint_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
|
/// to its active proxy (`paint.setFill`/`paint.setStroke` with its `ids`; a gradient fits it). A
|
||||||
use crate::panels::swatches;
|
/// chip of a dragged paint follows the pointer meanwhile.
|
||||||
swatches::drag_preview(app, ui.ctx());
|
fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) {
|
||||||
|
crate::panels::swatches::drag_preview(app, ui.ctx());
|
||||||
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
let Some(pos) = ui.input(|i| i.pointer.interact_pos()) else { return };
|
||||||
// Not through a floating panel over the canvas.
|
// Not through a floating panel over the canvas.
|
||||||
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
|
if ui.ctx().layer_id_at(pos).is_some_and(|l| l != resp.layer_id) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(mut params) = swatches::released_paint(app, resp) else { return };
|
let Some(d) = resp.dnd_release_payload::<widgets::PanelDrag>() else { return };
|
||||||
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return };
|
||||||
params["ids"] = json!([hit.leaf.0]);
|
let Some(st) = app.session.active() else { return };
|
||||||
params["focus"] = json!(false);
|
let (cmd, params) = match &*d {
|
||||||
if let Err(e) = app.run(crate::panels::proxy_cmd(app, false), params) {
|
widgets::PanelDrag::Paint { params, .. } => {
|
||||||
|
// Colour groups paint nothing.
|
||||||
|
if params.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mut params = params.clone();
|
||||||
|
params["ids"] = json!([vectorcraft_tools::xform::paint_owner(&st.doc, hit.leaf).0]);
|
||||||
|
params["focus"] = json!(false);
|
||||||
|
(crate::panels::proxy_cmd(app, false), params)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Err(e) = app.run(cmd, params) {
|
||||||
app.status(e);
|
app.status(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1109,8 +1121,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
|
fn swatches_and_proxy_paints_dropped_on_art_fill_the_object_hit() {
|
||||||
use crate::panels::swatches::SwatchDrag;
|
use vectorcraft_color::{Color, GradientGeom, GradientPaint, Paint};
|
||||||
use vectorcraft_color::{Color, Paint};
|
use widgets::{PanelDrag, SwatchRows};
|
||||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||||
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||||
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||||
|
|
@ -1118,25 +1130,29 @@ mod tests {
|
||||||
let ctx = egui::Context::default();
|
let ctx = egui::Context::default();
|
||||||
frame(&mut app, &ctx, vec![]);
|
frame(&mut app, &ctx, vec![]);
|
||||||
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap());
|
||||||
let release = |at: Pos2| {
|
let drop = |app: &mut VectorcraftApp, d: PanelDrag, at: Point| {
|
||||||
vec![
|
let at = xf.to_screen(at);
|
||||||
egui::Event::PointerMoved(at),
|
egui::DragAndDrop::set_payload(&ctx, d);
|
||||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
|
let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() };
|
||||||
]
|
frame(app, &ctx, vec![egui::Event::PointerMoved(at), up]);
|
||||||
};
|
};
|
||||||
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
|
let fill = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.fill_paint();
|
||||||
let drag = SwatchDrag { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
|
let red = Paint::solid(Color::from_hex("#ed1c24").unwrap());
|
||||||
|
let rows = SwatchRows { grabbed: "Red".into(), names: vec!["Red".into()], groups: false };
|
||||||
|
let swatch = PanelDrag::Paint { paint: red.clone(), params: json!({"swatch": "Red"}), rows: Some(rows) };
|
||||||
// Off the art nothing happens.
|
// Off the art nothing happens.
|
||||||
egui::DragAndDrop::set_payload(&ctx, drag.clone());
|
drop(&mut app, swatch.clone(), Point::new(300.0, 250.0));
|
||||||
frame(&mut app, &ctx, release(xf.to_screen(Point::new(300.0, 250.0))));
|
|
||||||
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
|
assert_eq!(fill(&app), Paint::solid(Color::WHITE));
|
||||||
egui::DragAndDrop::set_payload(&ctx, drag);
|
drop(&mut app, swatch, Point::new(100.0, 100.0));
|
||||||
frame(&mut app, &ctx, release(xf.to_screen(Point::new(100.0, 100.0))));
|
assert_eq!(fill(&app), red, "paint.setFill with the hit id");
|
||||||
assert_eq!(fill(&app), Paint::solid(Color::from_hex("#ed1c24").unwrap()), "paint.setFill with the hit id");
|
|
||||||
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
|
assert!(app.session.active().unwrap().selection.is_empty(), "the selection stays as it was");
|
||||||
// A proxy's paint works the same.
|
// A proxy's paint works the same.
|
||||||
egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
drop(&mut app, PanelDrag::paint(Paint::solid(Color::rgb(0.0, 0.0, 1.0))), Point::new(60.0, 60.0));
|
||||||
frame(&mut app, &ctx, release(xf.to_screen(Point::new(60.0, 60.0))));
|
|
||||||
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
assert_eq!(fill(&app), Paint::solid(Color::rgb(0.0, 0.0, 1.0)));
|
||||||
|
// A dragged gradient (the Gradient panel's thumbnail, a proxy) fits the object it lands on.
|
||||||
|
let mut g = GradientPaint::new(Default::default());
|
||||||
|
g.geom = Some(GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0 });
|
||||||
|
drop(&mut app, PanelDrag::paint(Paint::Gradient(Box::new(g))), Point::new(100.0, 100.0));
|
||||||
|
assert!(matches!(fill(&app), Paint::Gradient(g) if g.geom.is_none()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator edits it
|
//! The gradient stop popover: double-clicking a stop on the Gradient tool's annotator (or the
|
||||||
//! next to its chip — its colour (the Color panel's controls, or a document swatch), opacity and
|
//! Gradient panel's slider) edits it next to its chip — its colour (the Color panel's controls,
|
||||||
//! location. It edits the selected stop (`gradient.selectStop`) through `paint.editGradient`, and
|
//! or a document swatch), opacity and location. It edits the selected stop
|
||||||
//! closes on Escape or a click elsewhere.
|
//! (`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.
|
//! Agents can set `color` (hex), `opacity` and `location` (percentages) and confirm.
|
||||||
|
|
||||||
use egui::{Sense, vec2};
|
use egui::{Sense, vec2};
|
||||||
|
|
@ -15,8 +16,8 @@ use vectorcraft_geom::Point;
|
||||||
use super::{DialogResult, DialogSpec};
|
use super::{DialogResult, DialogSpec};
|
||||||
use crate::VectorcraftApp;
|
use crate::VectorcraftApp;
|
||||||
use crate::canvas::Xf;
|
use crate::canvas::Xf;
|
||||||
use crate::panels::gradient::stops_json;
|
use crate::panels::active_paint;
|
||||||
use crate::panels::{active_paint, live_run};
|
use crate::panels::gradient::set_stops;
|
||||||
use crate::state::Dialog;
|
use crate::state::Dialog;
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
use crate::widgets::{self, Live};
|
use crate::widgets::{self, Live};
|
||||||
|
|
@ -30,15 +31,6 @@ fn selected(app: &VectorcraftApp) -> Option<(Vec<GradientStop>, usize)> {
|
||||||
Some((g.gradient.stops, i))
|
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`.
|
/// `stops` with stop `i` given `color`.
|
||||||
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
|
fn recolor(mut stops: Vec<GradientStop>, i: usize, color: Color) -> Vec<GradientStop> {
|
||||||
stops[i].color = color;
|
stops[i].color = color;
|
||||||
|
|
@ -60,9 +52,13 @@ fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let t = Tokens::get(ctx);
|
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 chip = Point::new(d.f64("x", 0.0), d.f64("y", 0.0));
|
||||||
let pos =
|
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));
|
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 swatches = d.str("tab") == "swatches";
|
||||||
let mut tab = None;
|
let mut tab = None;
|
||||||
let resp = egui::Area::new(egui::Id::new("gradient-stop-popover"))
|
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) {
|
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();
|
let mut v2 = stops.clone();
|
||||||
v2[i].opacity = (v / 100.0).clamp(0.0, 1.0) as f32;
|
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:");
|
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) {
|
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);
|
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 {
|
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") {
|
if d.fields.contains_key("location") {
|
||||||
(stops, i) = move_stop(&stops, i, (d.f64("location", 0.0) / 100.0) as f32);
|
(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;
|
app.ui.dialog = None;
|
||||||
Ok(json!({ "index": i }))
|
Ok(json!({ "index": i }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||||
"",
|
"",
|
||||||
"{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)",
|
"{name?} open Graphic Style Options (dialog `graphicStyleOptions`, field `name`): for style `name` OK renames it (graphicStyle.rename); without, OK makes a new style of that name from the selection (graphicStyle.new)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"tool.options",
|
||||||
|
"Tool Options…",
|
||||||
|
"",
|
||||||
|
"{tool: id} what double-clicking a tool button opens: gradient → the Gradient panel (window.panel), eyedropper → Eyedropper Options (dialog `command` running eyedropper.setOptions, fields appearance, transparency)",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||||
|
|
@ -531,6 +537,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||||
}
|
}
|
||||||
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
|
"ui.colorPicker" => crate::dialogs::open_color_picker(app, p),
|
||||||
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
|
"ui.graphicStyleOptions" => crate::dialogs::graphic_style_options::open(app, s("name").as_deref()),
|
||||||
|
"tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(r)
|
Some(r)
|
||||||
|
|
|
||||||
|
|
@ -299,8 +299,7 @@ fn apply(app: &mut VectorcraftApp, tgt: &Target, c: Color, phase: Live, behind:
|
||||||
if let Some(s) = stops.get_mut(*index) {
|
if let Some(s) = stops.get_mut(*index) {
|
||||||
s.color = c;
|
s.color = c;
|
||||||
}
|
}
|
||||||
let params = json!({"stroke": !app.session.fill_active, "stops": super::gradient::stops_json(&stops)});
|
super::gradient::set_stops(app, &stops, None, phase);
|
||||||
live_run(app, "Gradient", "paint.editGradient", params, phase);
|
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
let cmd = super::proxy_cmd(app, behind);
|
let cmd = super::proxy_cmd(app, behind);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,41 @@
|
||||||
//! Gradient panel: type buttons, angle / aspect ratio / reverse, the gradient slider with draggable
|
//! Gradient panel: the gradient thumbnail (drag it onto art; its menu lists the document's
|
||||||
//! stops and midpoint diamonds (click below the ramp adds a stop, drag a stop off to remove it),
|
//! gradient swatches and Save to Swatches), type buttons, the Fill/Stroke proxy, angle and aspect
|
||||||
//! and stop Opacity / Location fields. Stop colours are edited with the Color panel.
|
//! 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 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::{
|
||||||
use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
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 super::{active_paint, live_run, pstate, set_pstate};
|
||||||
|
use crate::state::Dialog;
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
use crate::widgets::{self, Live, menu_item};
|
use crate::widgets::{self, Live, PanelDrag, menu_item};
|
||||||
use crate::{VectorcraftApp, icons};
|
use crate::{VectorcraftApp, icons};
|
||||||
|
|
||||||
/// Dragging a stop this far below the ramp removes it.
|
/// 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
|
/// 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.
|
/// Color panel and agents), or none.
|
||||||
fn select_stop(app: &mut VectorcraftApp, i: usize) {
|
fn select_stop(app: &mut VectorcraftApp, i: Option<usize>) {
|
||||||
app.run("gradient.selectStop", json!({ "index": i })).ok();
|
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.
|
/// Stops as `paint.editGradient` JSON.
|
||||||
pub use vectorcraft_tools::params::stops_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> {
|
fn current(app: &VectorcraftApp) -> Option<GradientPaint> {
|
||||||
match active_paint(app) {
|
match active_paint(app) {
|
||||||
Paint::Gradient(g) => Some(*g),
|
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) {
|
fn edit(app: &mut VectorcraftApp, params: Value, phase: Live) {
|
||||||
let mut p = params;
|
let mut p = params;
|
||||||
p["stroke"] = json!(!app.session.fill_active);
|
p["stroke"] = json!(!app.session.fill_active);
|
||||||
live_run(app, "Gradient", "paint.editGradient", p, phase);
|
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.
|
/// Drag state of the ramp: which handle is being dragged.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||||
enum Drag {
|
enum Drag {
|
||||||
#[default]
|
#[default]
|
||||||
None,
|
None,
|
||||||
Stop(usize),
|
/// Stop `index`; `copy` (Alt held when the drag began) drags a copy.
|
||||||
|
Stop {
|
||||||
|
index: usize,
|
||||||
|
copy: bool,
|
||||||
|
},
|
||||||
Mid(usize),
|
Mid(usize),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let gp = current(app);
|
let gp = current(app);
|
||||||
let fallback = GradientPaint::new(Gradient::default());
|
|
||||||
let g = gp.clone().unwrap_or(fallback);
|
|
||||||
let is_grad = gp.is_some();
|
let is_grad = gp.is_some();
|
||||||
|
let g = gp.unwrap_or_else(|| GradientPaint::new(Gradient::default()));
|
||||||
let kind = g.gradient.kind;
|
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| {
|
ui.horizontal(|ui| {
|
||||||
let (r, resp) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::click());
|
thumbnail(app, ui, &g, is_grad);
|
||||||
widgets::paint_chip(ui, r, &Paint::Gradient(Box::new(g.clone())));
|
if hidden {
|
||||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside);
|
ui.add_space(6.0);
|
||||||
if resp.on_hover_text("Gradient Fill — click to apply").clicked() && !is_grad {
|
super::proxy(app, ui, 36.0);
|
||||||
edit(app, json!({}), Live::Released);
|
return;
|
||||||
}
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
widgets::dim_label(ui, "Type:");
|
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() {
|
||||||
if widgets::flat_button(ui, "Edit Gradient", 96.0).clicked() {
|
app.select_tool("gradient");
|
||||||
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.add_space(4.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
widgets::dim_label(ui, "Stroke:");
|
widgets::dim_label(ui, "Stroke:");
|
||||||
|
|
@ -101,87 +196,170 @@ 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);
|
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| {
|
ui.horizontal(|ui| {
|
||||||
icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle");
|
super::proxy(app, ui, 36.0);
|
||||||
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);
|
|
||||||
}
|
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
let radial = kind == GradientKind::Radial;
|
ui.vertical(|ui| {
|
||||||
icons::icon(ui, "scaling", 15.0, if radial { t.icon } else { t.text_disabled }).on_hover_text("Aspect Ratio");
|
ui.horizontal(|ui| {
|
||||||
let asp = g.geom.map(|x| x.aspect * 100.0).unwrap_or(100.0);
|
icons::icon(ui, "rotate-ccw", 15.0, t.icon).on_hover_text("Angle");
|
||||||
if radial {
|
let angle = g.geom.map_or(g.angle, |x| x.angle_deg());
|
||||||
if let Some(a) = widgets::plain_field(ui, "grad-aspect", asp, "%", 1, 60.0) {
|
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!({"aspect": a}), Live::Released);
|
edit(app, json!({"angle": a}), Live::Released);
|
||||||
}
|
}
|
||||||
} else {
|
if widgets::icon_button_enabled(ui, "dc-reverse", "Reverse Gradient", false, is_grad, 22.0).clicked() {
|
||||||
ui.add_enabled(false, egui::Label::new(egui::RichText::new(format!("{asp:.0}%")).color(t.text_disabled)));
|
edit(app, json!({"reverse": true}), 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);
|
ui.add_space(6.0);
|
||||||
ramp(app, ui, &g.gradient, is_grad);
|
ramp(app, ui, &g.gradient, is_grad);
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
// Stop fields.
|
stop_fields(app, ui, &g.gradient, is_grad);
|
||||||
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());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if !is_grad {
|
if !is_grad {
|
||||||
widgets::dim_label(ui, "Click the ramp or a type button to apply a gradient.");
|
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::drag_source(ui, &resp, || PanelDrag::paint(Paint::Gradient(Box::new(g.clone()))));
|
||||||
|
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.
|
/// The gradient slider: ramp, stops below, midpoint diamonds above.
|
||||||
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let w = ui.available_width();
|
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));
|
let bar = Rect::from_min_size(area.min + vec2(8.0, 10.0), vec2(w - 16.0, 18.0));
|
||||||
// Checkerboard under the ramp (opacity).
|
// Checkerboard under the ramp (opacity).
|
||||||
let cell = 6.0;
|
let cell = 6.0;
|
||||||
|
|
@ -208,37 +386,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()));
|
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_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::<PanelDrag>().is_some_and(|d| d.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 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 sel = app.session.selected_stop();
|
||||||
let mut drag: Drag = pstate(ui.ctx(), "grad-drag");
|
|
||||||
let stops = &g.stops;
|
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
|
// While dragging, edits are computed against the stops as they were when the drag began (the
|
||||||
// document shows the preview).
|
// document shows the preview).
|
||||||
let origin: Vec<GradientStop> = if drag == Drag::None { stops.clone() } else { pstate::<Vec<GradientStop>>(ui.ctx(), "grad-origin") };
|
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;
|
let mut changed: Option<(Vec<GradientStop>, Live)> = None;
|
||||||
// The stop to select once `changed` is applied.
|
// The stop to select once `changed` is applied.
|
||||||
let mut select: Option<usize> = None;
|
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.
|
// Midpoint diamonds.
|
||||||
for i in 0..stops.len().saturating_sub(1) {
|
for i in 0..stops.len().saturating_sub(1) {
|
||||||
let Some(p) = midpoint_pos(stops, i) else { continue };
|
let Some(p) = midpoint_pos(stops, i) else { continue };
|
||||||
let c = pos2(x_of(p), bar.top() - 5.0);
|
let c = pos2(x_of(p), bar.top() - 5.0);
|
||||||
let rect = Rect::from_center_size(c, vec2(10.0, 10.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 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)];
|
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 {
|
if !is_grad {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if resp.clicked() || resp.drag_started() {
|
||||||
|
pick_mid = Some(i);
|
||||||
|
}
|
||||||
if resp.drag_started() {
|
if resp.drag_started() {
|
||||||
drag = Drag::Mid(i);
|
drag = Drag::Mid(i);
|
||||||
set_pstate(ui.ctx(), "grad-origin", stops.clone());
|
set_pstate(ui.ctx(), "grad-origin", stops.clone());
|
||||||
}
|
}
|
||||||
if (resp.dragged() || resp.drag_stopped())
|
if (resp.dragged() || resp.drag_stopped())
|
||||||
&& let Some(pp) = resp.interact_pointer_pos()
|
&& 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 };
|
let phase = if resp.drag_stopped() { Live::Released } else { Live::Dragging };
|
||||||
changed = Some((set_midpoint(&origin, i, m), phase));
|
changed = Some((set_midpoint(&origin, i, m), phase));
|
||||||
|
|
@ -248,15 +456,19 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Stops (house-shaped markers under the ramp).
|
// 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() {
|
for (i, s) in stops.iter().enumerate() {
|
||||||
let x = x_of(s.offset);
|
let x = x_of(s.offset);
|
||||||
let top = bar.bottom() + 2.0;
|
let m = marker(s.offset);
|
||||||
let marker = Rect::from_min_size(pos2(x - 6.0, top), vec2(12.0, 16.0));
|
let top = m.top();
|
||||||
let resp = ui.interact(marker, ui.id().with(("grad-stop", i)), Sense::click_and_drag());
|
let resp = ui.interact(m, ui.id().with(("grad-stop", i)), Sense::click_and_drag());
|
||||||
hit_stop |= resp.hovered() || resp.dragged();
|
let (dragging_this, copy) = match drag {
|
||||||
let dragging_this = drag == Drag::Stop(i);
|
Drag::Stop { index, copy } => (index == i, copy),
|
||||||
let off = dragging_this && resp.interact_pointer_pos().is_some_and(|p| p.y > bar.bottom() + REMOVE_DISTANCE) && origin.len() > MIN_STOPS;
|
_ => (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 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)];
|
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 {
|
if !off {
|
||||||
|
|
@ -267,89 +479,97 @@ fn ramp(app: &mut VectorcraftApp, ui: &mut Ui, g: &Gradient, is_grad: bool) {
|
||||||
if !is_grad {
|
if !is_grad {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if resp.double_clicked() {
|
||||||
|
popover = Some((i, m.left_bottom() + vec2(0.0, 4.0)));
|
||||||
|
}
|
||||||
if resp.clicked() || resp.drag_started() {
|
if resp.clicked() || resp.drag_started() {
|
||||||
select = Some(i);
|
select = Some(i);
|
||||||
}
|
}
|
||||||
if resp.drag_started() {
|
if resp.drag_started() {
|
||||||
drag = Drag::Stop(i);
|
drag = Drag::Stop { index: i, copy: alt };
|
||||||
set_pstate(ui.ctx(), "grad-origin", stops.clone());
|
set_pstate(ui.ctx(), "grad-origin", stops.clone());
|
||||||
}
|
}
|
||||||
if (resp.dragged() || resp.drag_stopped())
|
if (resp.dragged() || resp.drag_stopped())
|
||||||
&& dragging_this
|
&& dragging_this
|
||||||
&& let Some(pp) = resp.interact_pointer_pos()
|
&& 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() {
|
if resp.drag_stopped() {
|
||||||
drag = Drag::None;
|
drag = Drag::None;
|
||||||
if removing {
|
}
|
||||||
if let Some(v) = remove_stop(&origin, i) {
|
if !off_ramp(pp) {
|
||||||
select = Some(i.min(v.len() - 1));
|
let over = if copy { stop_under(pp, i) } else { None };
|
||||||
changed = Some((v, Live::Released));
|
let (v, ni) = dragged_stops(&origin, i, offset_at(pp), copy, over);
|
||||||
}
|
select = Some(ni);
|
||||||
} else {
|
changed = Some((v, phase));
|
||||||
let (v, ni) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
|
} else if phase == Live::Released {
|
||||||
select = Some(ni);
|
if let Some(v) = remove_stop(&origin, i) {
|
||||||
changed = Some((v, Live::Released));
|
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 {
|
} else {
|
||||||
let (v, _) = move_stop(&origin, i, x_to_offset(pp.x, bar.left(), bar.width()));
|
// Dragged off: show the gradient without the stop moving until release.
|
||||||
changed = Some((v, Live::Dragging));
|
changed = Some((origin.clone(), phase));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Click below the ramp (not on a stop) adds a stop; clicking the ramp applies a gradient.
|
// A colour swatch dropped on the ramp.
|
||||||
let below = Rect::from_min_max(pos2(bar.left(), bar.bottom()), pos2(bar.right(), area.bottom()));
|
if is_grad
|
||||||
let resp = ui.interact(below, ui.id().with("grad-add"), Sense::click());
|
&& let Some(d) = area_resp.dnd_release_payload::<PanelDrag>()
|
||||||
if resp.hovered() && !hit_stop && is_grad {
|
&& let Some(c) = d.color()
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Copy);
|
&& let Some(p) = ui.input(|i| i.pointer.latest_pos())
|
||||||
}
|
|
||||||
if resp.clicked()
|
|
||||||
&& !hit_stop
|
|
||||||
&& let Some(p) = resp.interact_pointer_pos()
|
|
||||||
{
|
{
|
||||||
if is_grad {
|
let (v, i) = drop_color(g, stops.iter().position(|s| marker(s.offset).contains(p)), offset_at(p), c);
|
||||||
let (v, i) = insert_stop(g, x_to_offset(p.x, bar.left(), bar.width()));
|
select = Some(i);
|
||||||
select = Some(i);
|
changed = Some((v, Live::Released));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
set_pstate(ui.ctx(), "grad-drag", drag);
|
set_pstate(ui.ctx(), "grad-drag", drag);
|
||||||
if let Some((v, phase)) = changed {
|
if let Some(i) = pick_mid {
|
||||||
edit(app, json!({"stops": stops_json(&v)}), phase);
|
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 {
|
match changed {
|
||||||
select_stop(app, i);
|
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) {
|
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
let g = current(app);
|
let g = current(app);
|
||||||
menu_item(ui, "Hide Options", false, false);
|
let hidden: bool = pstate(ui.ctx(), HIDE_OPTIONS);
|
||||||
if menu_item(ui, "Add to Swatches", g.is_some(), false)
|
if menu_item(ui, if hidden { "Show Options" } else { "Hide Options" }, true, false) {
|
||||||
&& let Some(g) = g
|
set_pstate(ui.ctx(), HIDE_OPTIONS, !hidden);
|
||||||
{
|
|
||||||
app.run("swatch.new", super::paint_params(&Paint::Gradient(Box::new(g)))).ok();
|
|
||||||
}
|
}
|
||||||
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);
|
edit(app, json!({"reverse": true}), Live::Released);
|
||||||
}
|
}
|
||||||
if menu_item(ui, "Reset to White, Black", true, false) {
|
if menu_item(ui, "Reset to White, Black", true, false) {
|
||||||
let d = Gradient::default();
|
set_stops(app, &Gradient::default().stops, None, Live::Released);
|
||||||
edit(app, json!({"stops": stops_json(&d.stops)}), Live::Released);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use egui::{Event, Modifiers, PointerButton};
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -361,4 +581,251 @@ mod tests {
|
||||||
assert_eq!(j.as_array().unwrap().len(), 2);
|
assert_eq!(j.as_array().unwrap().len(), 2);
|
||||||
assert_eq!(j[1]["midpoint"], json!(0.5));
|
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, PanelDrag::paint(Paint::solid(Color::rgb(1.0, 0.0, 0.0))));
|
||||||
|
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"})));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu.
|
//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu.
|
||||||
//!
|
//!
|
||||||
//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active
|
//! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active
|
||||||
//! proxy's paint command with the object's id); a Fill/Stroke proxy dropped on the panel becomes a
|
//! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke
|
||||||
//! swatch (`swatch.new`).
|
//! proxy or the Gradient panel's thumbnail dropped on the panel becomes a swatch (`swatch.new`).
|
||||||
|
|
||||||
use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
|
use egui::{Color32, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -12,7 +12,7 @@ use vectorcraft_doc::Document;
|
||||||
|
|
||||||
use super::{active_paint, pstate, set_pstate};
|
use super::{active_paint, pstate, set_pstate};
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
use crate::widgets::{self, menu_item, swatch_tile};
|
use crate::widgets::{self, PanelDrag, SwatchRows, menu_item, swatch_tile};
|
||||||
use crate::{VectorcraftApp, icons};
|
use crate::{VectorcraftApp, icons};
|
||||||
|
|
||||||
/// View modes of the swatch list.
|
/// View modes of the swatch list.
|
||||||
|
|
@ -135,18 +135,19 @@ fn entries(app: &VectorcraftApp, kind: Kind, query: &str) -> Vec<Entry> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The paint command params that apply swatch (or Registration) `name`; `None` for colour groups
|
/// The paint command params that apply entry `e` (`paint.setFill`); none for a colour group.
|
||||||
/// and unknown names.
|
fn swatch_params(e: &Entry) -> Option<Value> {
|
||||||
fn swatch_params(d: &Document, name: &str) -> Option<Value> {
|
Some(match e {
|
||||||
if name == REGISTRATION {
|
Entry::Registration => json!({"color": {"c": 1.0, "m": 1.0, "y": 1.0, "k": 1.0}}),
|
||||||
return Some(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}),
|
||||||
Some(if d.swatch(name)?.paint.is_none() { json!({"none": true}) } else { json!({"swatch": name}) })
|
Entry::Folder(_) => return None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
|
/// Apply a clicked swatch to the active proxy (Alt: the inactive one).
|
||||||
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
|
fn apply(app: &mut VectorcraftApp, ui: &Ui, e: &Entry) {
|
||||||
if let Some(params) = app.session.active().and_then(|st| swatch_params(&st.doc, e.name())) {
|
if let Some(params) = swatch_params(e) {
|
||||||
super::apply_click(app, ui, params);
|
super::apply_click(app, ui, params);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -329,32 +330,34 @@ fn click_selection(ui: &Ui, mut sel: Vec<String>, order: &[&str], name: &str, m:
|
||||||
sel
|
sel
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the Swatches panel drags: the swatch, None, Registration or colour group under the pointer
|
|
||||||
/// when the drag started (it paints art it's dropped on) and the names it moves: the panel
|
|
||||||
/// selection when the grabbed one is part of it (only colour groups, or only swatches, like the
|
|
||||||
/// grabbed one), in panel order.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub(crate) struct SwatchDrag {
|
|
||||||
pub grabbed: String,
|
|
||||||
pub names: Vec<String>,
|
|
||||||
/// The names are colour groups.
|
|
||||||
pub groups: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A drag released on the panel.
|
/// A drag released on the panel.
|
||||||
enum Drop {
|
enum Drop {
|
||||||
/// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or
|
/// Swatches (or colour groups) dropped before or `after` row `target`, into folder `target`, or
|
||||||
/// at the end (`None`).
|
/// at the end (`None`).
|
||||||
Move { names: Vec<String>, target: Option<String>, after: bool },
|
Move { names: Vec<String>, target: Option<String>, after: bool },
|
||||||
/// A Fill/Stroke proxy's paint dropped on row `target` (or between rows) becomes a swatch.
|
/// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row
|
||||||
|
/// `target` (or between rows) becomes a swatch.
|
||||||
New { paint: Paint, target: Option<String> },
|
New { paint: Paint, target: Option<String> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A drag payload of type `T` released on `resp`. (egui's release takes any payload, so the type is
|
impl Drop {
|
||||||
/// checked first: a payload of another type stays for the next drop target.)
|
/// What releasing `d` before or `after` row `target` (`None`: at the end) does: move the rows
|
||||||
fn released<T: std::any::Any + Send + Sync>(resp: &Response) -> Option<std::sync::Arc<T>> {
|
/// it carries, or make a swatch of a paint dragged from elsewhere.
|
||||||
resp.dnd_hover_payload::<T>()?;
|
fn of(d: &PanelDrag, target: Option<String>, after: bool) -> Self {
|
||||||
resp.dnd_release_payload::<T>()
|
let PanelDrag::Paint { paint, rows, .. } = d;
|
||||||
|
match rows {
|
||||||
|
Some(r) => Drop::Move { names: r.names.clone(), target, after },
|
||||||
|
None => Drop::New { paint: paint.clone(), target },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The drag a tile or row of `e` starts, moving rows `names`. Registration and colour groups have
|
||||||
|
/// no paint of their own (the chip under the pointer draws Registration's mark).
|
||||||
|
fn drag_of(e: &Entry, names: Vec<String>) -> PanelDrag {
|
||||||
|
let paint = if let Entry::Swatch { paint, .. } = e { paint.clone() } else { Paint::None };
|
||||||
|
let rows = SwatchRows { grabbed: e.name().to_string(), names, groups: e.is_folder() };
|
||||||
|
PanelDrag::Paint { paint, params: swatch_params(e).unwrap_or_default(), rows: Some(rows) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the tiles or rows saw this frame.
|
/// What the tiles or rows saw this frame.
|
||||||
|
|
@ -370,8 +373,7 @@ struct TileEvents {
|
||||||
|
|
||||||
/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e`
|
/// Clicks, double-clicks, tooltips and drag and drop of the tile or row `resp` of entry `e`
|
||||||
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it
|
/// (`items`: the panel's rows, `sel`: its selection, `list`: rows stack downwards). A drag from it
|
||||||
/// starts a [`SwatchDrag`]; a drag held over it shows where it would land (before or after it by
|
/// starts a [`PanelDrag`] carrying the rows it moves; see [`tile_drop`] for drags held over it.
|
||||||
/// the pointer's half, or into a folder) and a release there sets the drop.
|
|
||||||
fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
|
fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String], list: bool, ev: &mut TileEvents) {
|
||||||
let name = e.name();
|
let name = e.name();
|
||||||
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
|
if matches!(e, Entry::Swatch { .. }) && resp.double_clicked() {
|
||||||
|
|
@ -384,36 +386,9 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String
|
||||||
.filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name()))))
|
.filter(|x| x.is_folder() == e.is_folder() && (x.name() == name || (with_sel && sel.iter().any(|s| s == x.name()))))
|
||||||
.map(|x| x.name().to_string())
|
.map(|x| x.name().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
egui::DragAndDrop::set_payload(ui.ctx(), SwatchDrag { grabbed: name.to_string(), names, groups: e.is_folder() });
|
egui::DragAndDrop::set_payload(ui.ctx(), drag_of(e, names));
|
||||||
}
|
|
||||||
let moving = resp.dnd_hover_payload::<SwatchDrag>();
|
|
||||||
// Swatches dropped on one of themselves stay where they are.
|
|
||||||
if moving.as_ref().is_some_and(|d| d.names.iter().any(|n| n == name)) {
|
|
||||||
ev.over = true;
|
|
||||||
released::<SwatchDrag>(&resp);
|
|
||||||
} else if moving.is_some() || resp.dnd_hover_payload::<Paint>().is_some() {
|
|
||||||
ev.over = true;
|
|
||||||
let t = Tokens::get(ui.ctx());
|
|
||||||
let r = resp.rect;
|
|
||||||
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
|
|
||||||
let after = if list { at.y > r.center().y } else { at.x > r.center().x };
|
|
||||||
// A proxy's paint, or swatches over a folder, go into it; otherwise a bar marks the slot.
|
|
||||||
if moving.as_ref().is_none_or(|d| e.is_folder() && !d.groups) {
|
|
||||||
ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
|
|
||||||
} else if list {
|
|
||||||
let y = if after { r.bottom() } else { r.top() };
|
|
||||||
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
|
|
||||||
} else {
|
|
||||||
let x = if after { r.right() + 0.75 } else { r.left() - 0.75 };
|
|
||||||
ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent));
|
|
||||||
}
|
|
||||||
let target = Some(name.to_string());
|
|
||||||
if let Some(d) = moving.and_then(|_| released::<SwatchDrag>(&resp)) {
|
|
||||||
ev.drop = Some(Drop::Move { names: d.names.clone(), target, after });
|
|
||||||
} else if let Some(p) = released::<Paint>(&resp) {
|
|
||||||
ev.drop = Some(Drop::New { paint: (*p).clone(), target });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
tile_drop(ui, &resp, e, list, ev);
|
||||||
let resp = match e {
|
let resp = match e {
|
||||||
Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")),
|
Entry::Folder(n) => resp.on_hover_text(format!("Color Group: {n}")),
|
||||||
Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| {
|
Entry::Swatch { paint, global, spot, .. } if list => resp.on_hover_ui(|ui| {
|
||||||
|
|
@ -426,17 +401,46 @@ fn tile_input(ui: &Ui, resp: Response, e: &Entry, items: &[Entry], sel: &[String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A drag held over the tile or row `resp` of entry `e` shows where it would land (before or after
|
||||||
|
/// it by the pointer's half, or into a folder); a release there sets the drop.
|
||||||
|
fn tile_drop(ui: &Ui, resp: &Response, e: &Entry, list: bool, ev: &mut TileEvents) {
|
||||||
|
let Some(d) = resp.dnd_hover_payload::<PanelDrag>() else { return };
|
||||||
|
let PanelDrag::Paint { rows, .. } = &*d;
|
||||||
|
let name = e.name();
|
||||||
|
ev.over = true;
|
||||||
|
// Swatches dropped on one of themselves stay where they are.
|
||||||
|
if rows.as_ref().is_some_and(|r| r.names.iter().any(|n| n == name)) {
|
||||||
|
resp.dnd_release_payload::<PanelDrag>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let t = Tokens::get(ui.ctx());
|
||||||
|
let r = resp.rect;
|
||||||
|
let at = ui.input(|i| i.pointer.interact_pos()).unwrap_or(r.center());
|
||||||
|
let after = if list { at.y > r.center().y } else { at.x > r.center().x };
|
||||||
|
// A paint from elsewhere, or swatches over a folder, go into it; otherwise a bar marks the slot.
|
||||||
|
if rows.as_ref().is_none_or(|d| e.is_folder() && !d.groups) {
|
||||||
|
ui.painter().rect_stroke(r.expand(1.0), 0.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
|
||||||
|
} else if list {
|
||||||
|
let y = if after { r.bottom() } else { r.top() };
|
||||||
|
ui.painter().line_segment([pos2(r.left(), y), pos2(r.right(), y)], Stroke::new(2.0, t.accent));
|
||||||
|
} else {
|
||||||
|
let x = if after { r.right() + 0.75 } else { r.left() - 0.75 };
|
||||||
|
ui.painter().line_segment([pos2(x, r.top() - 1.0), pos2(x, r.bottom() + 1.0)], Stroke::new(2.0, t.accent));
|
||||||
|
}
|
||||||
|
if resp.dnd_release_payload::<PanelDrag>().is_some() {
|
||||||
|
ev.drop = Some(Drop::of(&d, Some(name.to_string()), after));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped
|
/// Drops between and after the tiles (`zone`: the list's viewport) go to the end of the ungrouped
|
||||||
/// swatches (colour groups to the end of the groups); the list is outlined while one is held there.
|
/// swatches (colour groups to the end of the groups); the list is outlined while one is held there.
|
||||||
fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) {
|
fn zone_input(ui: &Ui, zone: &Response, ev: &mut TileEvents) {
|
||||||
let held = zone.dnd_hover_payload::<SwatchDrag>().is_some() || zone.dnd_hover_payload::<Paint>().is_some();
|
let Some(d) = zone.dnd_hover_payload::<PanelDrag>() else { return };
|
||||||
if held && !ev.over {
|
if !ev.over {
|
||||||
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
|
ui.painter().rect_stroke(zone.rect, 0.0, Stroke::new(1.5, Tokens::get(ui.ctx()).accent), StrokeKind::Inside);
|
||||||
}
|
}
|
||||||
if let Some(d) = released::<SwatchDrag>(zone) {
|
if zone.dnd_release_payload::<PanelDrag>().is_some() {
|
||||||
ev.drop = Some(Drop::Move { names: d.names.clone(), target: None, after: false });
|
ev.drop = Some(Drop::of(&d, None, false));
|
||||||
} else if let Some(p) = released::<Paint>(zone) {
|
|
||||||
ev.drop = Some(Drop::New { paint: (*p).clone(), target: None });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -505,33 +509,16 @@ fn apply_drop(app: &mut VectorcraftApp, drop: Drop) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint command params for swatches or a Fill/Stroke proxy released on `resp`: the grabbed swatch
|
/// While a paint is dragged (swatches, a Fill/Stroke proxy, the Gradient panel's thumbnail), a chip
|
||||||
/// (`None` for a colour group), or the proxy's paint (a gradient fits the new object afresh).
|
/// of it follows the pointer.
|
||||||
pub(crate) fn released_paint(app: &VectorcraftApp, resp: &Response) -> Option<Value> {
|
|
||||||
if let Some(d) = released::<SwatchDrag>(resp) {
|
|
||||||
return swatch_params(&app.session.active()?.doc, &d.grabbed);
|
|
||||||
}
|
|
||||||
let mut paint = (*released::<Paint>(resp)?).clone();
|
|
||||||
if let Paint::Gradient(g) = &mut paint {
|
|
||||||
g.geom = None;
|
|
||||||
}
|
|
||||||
Some(super::paint_params(&paint))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// While swatches or a Fill/Stroke proxy are dragged, a chip of the grabbed paint follows the
|
|
||||||
/// pointer.
|
|
||||||
pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||||
let (paint, registration) = if let Some(d) = egui::DragAndDrop::payload::<SwatchDrag>(ctx) {
|
let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return };
|
||||||
match app.session.active().and_then(|st| st.doc.swatch(&d.grabbed)) {
|
let PanelDrag::Paint { paint, params, rows } = &*d;
|
||||||
Some(w) => (w.paint.clone(), false),
|
// Colour groups paint nothing.
|
||||||
None if d.grabbed == REGISTRATION => (Paint::None, true),
|
if params.is_null() {
|
||||||
None => return,
|
|
||||||
}
|
|
||||||
} else if let Some(p) = egui::DragAndDrop::payload::<Paint>(ctx) {
|
|
||||||
((*p).clone(), false)
|
|
||||||
} else {
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
|
let registration = rows.as_ref().is_some_and(|r| r.grabbed == REGISTRATION);
|
||||||
let Some(at) = ctx.pointer_hover_pos() else { return };
|
let Some(at) = ctx.pointer_hover_pos() else { return };
|
||||||
let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0));
|
let area = egui::Area::new(egui::Id::new("swatch-drag-preview")).order(egui::Order::Tooltip).fixed_pos(at + vec2(12.0, 12.0));
|
||||||
area.interactable(false).show(ctx, |ui| {
|
area.interactable(false).show(ctx, |ui| {
|
||||||
|
|
@ -539,8 +526,8 @@ pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||||
if registration {
|
if registration {
|
||||||
draw_registration(ui, r);
|
draw_registration(ui, r);
|
||||||
} else {
|
} else {
|
||||||
swatch_tile(ui, r, &paint, false, false);
|
swatch_tile(ui, r, paint, false, false);
|
||||||
pattern_thumb(app, ui, r.shrink(1.0), &paint);
|
pattern_thumb(app, ui, r.shrink(1.0), paint);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1184,7 +1171,7 @@ mod tests {
|
||||||
let ctx = context();
|
let ctx = context();
|
||||||
frame(&mut app, &ctx, vec![], 0.0, show);
|
frame(&mut app, &ctx, vec![], 0.0, show);
|
||||||
let at = tile_center(&ctx, "Bright Red");
|
let at = tile_center(&ctx, "Bright Red");
|
||||||
egui::DragAndDrop::set_payload(&ctx, Paint::solid(Color::from_hex("#123456").unwrap()));
|
egui::DragAndDrop::set_payload(&ctx, PanelDrag::paint(Paint::solid(Color::from_hex("#123456").unwrap())));
|
||||||
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
|
let release = Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE };
|
||||||
frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show);
|
frame(&mut app, &ctx, vec![Event::PointerMoved(at), release], 1.0, show);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -1192,7 +1179,7 @@ mod tests {
|
||||||
Some("R=18 G=52 B=86"),
|
Some("R=18 G=52 B=86"),
|
||||||
"dropped on a group's colour: into that group"
|
"dropped on a group's colour: into that group"
|
||||||
);
|
);
|
||||||
assert!(egui::DragAndDrop::payload::<Paint>(&ctx).is_none());
|
assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
} else if resp.clicked() && alt && slot.len() > 1 {
|
} else if resp.clicked() && alt && slot.len() > 1 {
|
||||||
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
|
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
|
||||||
app.select_tool(slot[(idx + 1) % slot.len()]);
|
app.select_tool(slot[(idx + 1) % slot.len()]);
|
||||||
|
} else if resp.double_clicked() {
|
||||||
|
app.select_tool(shown.id);
|
||||||
|
app.run("tool.options", json!({ "tool": shown.id })).ok();
|
||||||
} else if resp.clicked() {
|
} else if resp.clicked() {
|
||||||
app.select_tool(shown.id);
|
app.select_tool(shown.id);
|
||||||
}
|
}
|
||||||
|
|
@ -178,6 +181,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||||
flyout(app, ui.ctx());
|
flyout(app, ui.ctx());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open a tool's options (`tool.options`, a double-click on its button): the Gradient tool's are
|
||||||
|
/// the Gradient panel, the Eyedropper's the Eyedropper Options dialog.
|
||||||
|
pub fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result<serde_json::Value, String> {
|
||||||
|
match tool {
|
||||||
|
"gradient" if app.ui.open_panel.as_deref() == Some("gradient") => Ok(json!({ "open": "gradient" })),
|
||||||
|
"gradient" => app.run("window.panel", json!({ "panel": "gradient" })),
|
||||||
|
"eyedropper" => {
|
||||||
|
let o = app.session.eyedropper;
|
||||||
|
let fields = json!({
|
||||||
|
"__command": "eyedropper.setOptions",
|
||||||
|
"__label": "Eyedropper Options",
|
||||||
|
"appearance": o.appearance,
|
||||||
|
"transparency": o.transparency,
|
||||||
|
});
|
||||||
|
app.ui.dialog = Some(crate::state::Dialog::new("command", fields));
|
||||||
|
Ok(json!({ "dialog": "command" }))
|
||||||
|
}
|
||||||
|
_ if vectorcraft_tools::tool_info(tool).is_none() => Err(format!("unknown tool `{tool}`")),
|
||||||
|
_ => Err(format!("the {tool} tool has no options")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) {
|
||||||
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
|
ui.vertical_centered(|ui| crate::panels::proxy(app, ui, 36.0));
|
||||||
ui.add_space(5.0);
|
ui.add_space(5.0);
|
||||||
|
|
@ -289,3 +314,59 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||||
}
|
}
|
||||||
let _ = theme::semibold;
|
let _ = theme::semibold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use egui::{Event, PointerButton, Pos2};
|
||||||
|
use vectorcraft_engine::Session;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// One headless frame of the toolbar; returns the tool buttons' rects, top to bottom.
|
||||||
|
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec<Event>) -> Vec<egui::Rect> {
|
||||||
|
let input = egui::RawInput {
|
||||||
|
time: Some(time),
|
||||||
|
events,
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 1200.0))),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut out = ctx.run_ui(input, |ui| show(app, ui));
|
||||||
|
out.textures_delta.clear();
|
||||||
|
let mut r: Vec<egui::Rect> = ctx.viewport(|vp| {
|
||||||
|
let size = vec2(36.0, PITCH - 1.0);
|
||||||
|
vp.prev_pass.widgets.layers().flat_map(|(_, w)| w.iter()).filter(|w| w.rect.size() == size).map(|w| w.rect).collect()
|
||||||
|
});
|
||||||
|
r.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A double-click at `at`, `time` seconds in (a second apart from the last).
|
||||||
|
fn double_click(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, at: Pos2) {
|
||||||
|
let b = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||||
|
frame(app, ctx, time, vec![Event::PointerMoved(at), b(true), b(false), b(true), b(false)]);
|
||||||
|
frame(app, ctx, time + 0.1, vec![]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn double_clicking_a_tool_button_opens_its_options() {
|
||||||
|
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||||
|
app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let buttons = frame(&mut app, &ctx, 0.0, vec![]);
|
||||||
|
// The Color category's slots close the list: Gradient, then Eyedropper.
|
||||||
|
let (gradient, eyedropper) = (buttons[buttons.len() - 2], buttons[buttons.len() - 1]);
|
||||||
|
double_click(&mut app, &ctx, 1.0, gradient.center());
|
||||||
|
assert_eq!((app.session.tool_id(), app.ui.open_panel.as_deref()), ("gradient", Some("gradient")));
|
||||||
|
// Again: the panel stays open.
|
||||||
|
double_click(&mut app, &ctx, 2.0, gradient.center());
|
||||||
|
assert_eq!(app.ui.open_panel.as_deref(), Some("gradient"));
|
||||||
|
double_click(&mut app, &ctx, 3.0, eyedropper.center());
|
||||||
|
let d = app.ui.dialog.clone().expect("Eyedropper Options");
|
||||||
|
assert_eq!((app.session.tool_id(), d.kind.as_str(), d.str("__command")), ("eyedropper", "command", "eyedropper.setOptions".into()));
|
||||||
|
// OK applies the options.
|
||||||
|
app.ui.dialog.as_mut().unwrap().fields.insert("transparency".into(), json!(false));
|
||||||
|
crate::dialogs::confirm(&mut app).unwrap();
|
||||||
|
assert!(app.session.eyedropper.appearance && !app.session.eyedropper.transparency);
|
||||||
|
assert!(app.run("tool.options", json!({"tool": "zoom"})).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -259,8 +259,8 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, mixed: (bool
|
||||||
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag());
|
let fill_resp = ui.interact(fill_r, ui.id().with("fill-proxy"), Sense::click_and_drag());
|
||||||
// A proxy drags its paint (onto the Swatches panel or art), unless it shows "?".
|
// A proxy drags its paint (onto the Swatches panel or art), unless it shows "?".
|
||||||
for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] {
|
for (resp, paint, mixed) in [(&fill_resp, fill, mixed.0), (&stroke_resp, stroke, mixed.1)] {
|
||||||
if resp.drag_started() && !mixed {
|
if !mixed {
|
||||||
egui::DragAndDrop::set_payload(ui.ctx(), paint.clone());
|
drag_source(ui, resp, || PanelDrag::paint(paint.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let question = |ui: &Ui, r: Rect, color: Color32| {
|
let question = |ui: &Ui, r: Rect, color: Color32| {
|
||||||
|
|
@ -944,3 +944,52 @@ pub fn opt_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What panels drag onto art and onto each other. One payload type, so the canvas has one drop
|
||||||
|
/// 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.
|
||||||
|
Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows> },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Swatches panel rows a drag from that panel moves: the swatch, None, Registration or colour
|
||||||
|
/// group under the pointer when the drag started (`grabbed`) and `names`, the panel selection when
|
||||||
|
/// the grabbed one is part of it (only colour groups, or only swatches, like the grabbed one), in
|
||||||
|
/// panel order.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct SwatchRows {
|
||||||
|
pub grabbed: String,
|
||||||
|
pub names: Vec<String>,
|
||||||
|
/// The names are colour groups.
|
||||||
|
pub groups: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
pub fn paint(mut paint: Paint) -> Self {
|
||||||
|
if let Paint::Gradient(g) = &mut paint {
|
||||||
|
g.geom = None;
|
||||||
|
}
|
||||||
|
Self::Paint { params: crate::panels::paint_params(&paint), paint, rows: None }
|
||||||
|
}
|
||||||
|
/// The dragged colour (a solid paint's), which the Gradient panel's ramp takes.
|
||||||
|
pub fn color(&self) -> Option<vectorcraft_color::Color> {
|
||||||
|
match self {
|
||||||
|
Self::Paint { paint, .. } => paint.color(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `resp` (which senses drags) a source of the [`PanelDrag`] `drag` builds when a drag starts
|
||||||
|
/// on it.
|
||||||
|
pub fn drag_source(ui: &Ui, resp: &Response, drag: impl FnOnce() -> PanelDrag) {
|
||||||
|
if resp.drag_started() {
|
||||||
|
egui::DragAndDrop::set_payload(ui.ctx(), drag());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,16 @@ The Color Picker is a dialog too: `engine.execute {command: "ui.colorPicker", pa
|
||||||
Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and
|
Graphic styles: `ui.graphicStyleOptions {name}` opens the `graphicStyleOptions` dialog (field `name`) for a style and
|
||||||
`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the
|
`ui.dialog.confirm` renames it with `graphicStyle.rename` (a name another style has returns an error and keeps the
|
||||||
dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`).
|
dialog open); without `name` it names a new style made from the selection (`graphicStyle.new`).
|
||||||
|
|
||||||
|
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, 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.
|
||||||
|
|
||||||
|
Tool options: double-clicking a tool button runs `tool.options {tool}`. For `gradient` it opens the Gradient panel;
|
||||||
|
for `eyedropper` it opens Eyedropper Options, a `command` dialog (fields `appearance`, `transparency`) whose
|
||||||
|
`ui.dialog.confirm` runs `eyedropper.setOptions` (what `appearance.copyFrom` copies). Gradient tool handles snap to
|
||||||
|
anchors, edges and smart guides; Shift constrains them to 45° steps from the `constrainAngle` preference.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue