mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 22:21:59 +00:00
- After a drag past either end of the range, the first arrow key now steps back from the value shown instead of doing nothing. - With the Selection tool, a plain double-click on a blend widget no longer edits or isolates the artwork under it, or leaves isolation. Ctrl/Cmd+Shift double-click still releases a guide there. - docs/mcp.md says the lower widget shows only when the steps follow the spine, and the G13 blend easing entry in docs/gaps.md no longer lists on-canvas easing handles as missing.
569 lines
28 KiB
Rust
569 lines
28 KiB
Rust
//! Blend widgets: two handles beside the spine of one selected blend, at the point halfway along
|
||
//! the spine, one 16 screen pixels above it and one 16 pixels below it. Where other artwork crowds
|
||
//! that spot (the boxes of three or more other visible objects hold it), a widget moves further
|
||
//! out in 16-pixel steps, up to 144 pixels, to the first spot that is not crowded, and the line
|
||
//! the canvas draws from the halfway point to the widget grows with it. The upper one sets the
|
||
//! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`,
|
||
//! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine,
|
||
//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press
|
||
//! changes the value by one per screen pixel, starting from the value at the press, and the arrow
|
||
//! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool
|
||
//! cancels the drag and puts the value back. Each drag previews
|
||
//! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a
|
||
//! click changes nothing. The Selection and Blend tools hit the widgets and the canvas draws them
|
||
//! from the same geometry.
|
||
|
||
use serde_json::json;
|
||
use vectorcraft_doc::live::{Spine, blend_spine};
|
||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection};
|
||
use vectorcraft_geom::{Point, Rect, Vec2};
|
||
|
||
use crate::{Action, Overlay, PointerEvent, ToolContext};
|
||
|
||
/// How far (screen px) a widget's center sits above or below the spine's halfway point.
|
||
pub const OFFSET_PX: f64 = 16.0;
|
||
/// The widget's radius (screen px), as the canvas draws it.
|
||
pub const RADIUS_PX: f32 = 4.0;
|
||
/// How near (screen px) the pointer must be to hit a widget.
|
||
const HIT_PX: f64 = 5.0;
|
||
/// Points of value per screen pixel of drag along the spine.
|
||
const PER_PX: f64 = 1.0;
|
||
/// The undo step a drag records, as the Blend panel's changes do.
|
||
const LABEL: &str = "Blend Options";
|
||
/// How many [`OFFSET_PX`] steps further out a widget may move from a crowded spot.
|
||
const CLEAR_STEPS: usize = 8;
|
||
/// A spot is crowded when the boxes of this many other visible objects hold it.
|
||
const CROWDED: usize = 3;
|
||
/// The most nodes looked at to find what crowds the widgets, topmost first.
|
||
const LOOK: usize = 1024;
|
||
|
||
/// What a blend widget sets.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum BlendControl {
|
||
/// The upper widget: the color shift.
|
||
Appearance,
|
||
/// The lower widget: the easing strength.
|
||
Easing,
|
||
}
|
||
|
||
impl BlendControl {
|
||
/// The `object.blend.setEasing` param this widget sets.
|
||
fn key(self) -> &'static str {
|
||
match self {
|
||
Self::Appearance => "colorShift",
|
||
Self::Easing => "ramp",
|
||
}
|
||
}
|
||
|
||
/// The values the param takes.
|
||
fn range(self) -> (f64, f64) {
|
||
match self {
|
||
Self::Appearance => (-100.0, 100.0),
|
||
Self::Easing => (0.0, 100.0),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One widget of the selected blend.
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct BlendWidget {
|
||
pub id: NodeId,
|
||
pub control: BlendControl,
|
||
/// The spine's halfway point, in the document.
|
||
pub on: Point,
|
||
/// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other
|
||
/// artwork crowds that spot.
|
||
pub at: Point,
|
||
/// The spine's direction at `on` (unit length, in the document).
|
||
pub along: Vec2,
|
||
/// The value the widget sets, as it is now.
|
||
pub value: f64,
|
||
}
|
||
|
||
impl BlendWidget {
|
||
/// The widgets of the selection at `zoom` (screen px per document point), with `up` the
|
||
/// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty
|
||
/// unless the selection is one whole blend, visible and unlocked, with two or more keys and a
|
||
/// spine of some length, and no anchors are selected. Each widget sits at the first spot out
|
||
/// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible
|
||
/// objects hold, at most nine steps out; where every spot is crowded it stays at the first.
|
||
pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec<Self> {
|
||
let up_len = up.hypot();
|
||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||
if !selection.anchors.is_empty() || selection.symbol_part.is_some() || !usable(zoom) || !usable(up_len) {
|
||
return vec![];
|
||
}
|
||
let [id] = selection.objects.as_slice() else { return vec![] };
|
||
let Some(NodeKind::Blend { children, spec }) = doc.node(*id).map(|n| &n.kind) else { return vec![] };
|
||
if children.len() < 2 || !doc.is_editable(*id) {
|
||
return vec![];
|
||
}
|
||
let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] };
|
||
if !usable(spine.length()) {
|
||
return vec![];
|
||
}
|
||
let (on, angle) = spine.at(0.5);
|
||
let along = Vec2::new(angle.cos(), angle.sin());
|
||
if !(on.x.is_finite() && on.y.is_finite() && along.x.is_finite() && along.y.is_finite()) {
|
||
return vec![];
|
||
}
|
||
let lift = up * (OFFSET_PX / zoom / up_len);
|
||
let reach = lift.hypot() * (CLEAR_STEPS + 1) as f64 + f64::from(RADIUS_PX) / zoom;
|
||
let boxes = boxes_near(doc, *id, Rect::new(on.x - reach, on.y - reach, on.x + reach, on.y + reach));
|
||
let clear = |side: Vec2| {
|
||
let crowded = |p: Point| boxes.iter().filter(|b| b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1).count() >= CROWDED;
|
||
(1..=CLEAR_STEPS + 1).map(|k| on + side * k as f64).find(|p| !crowded(*p)).unwrap_or(on + side)
|
||
};
|
||
let value = |v: f64, control: BlendControl| {
|
||
let (lo, hi) = control.range();
|
||
if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }
|
||
};
|
||
let shift = value(spec.color_shift, BlendControl::Appearance);
|
||
let mut out = vec![Self { id: *id, control: BlendControl::Appearance, on, at: clear(lift), along, value: shift }];
|
||
if spec.eases_along_spine() {
|
||
let ramp = value(spec.ease.ramp * 100.0, BlendControl::Easing);
|
||
out.push(Self { id: *id, control: BlendControl::Easing, on, at: clear(-lift), along, value: ramp });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The widgets the tool in `cx` can drag.
|
||
pub fn for_tool(cx: &ToolContext) -> Vec<Self> {
|
||
Self::of(cx.doc, cx.selection, cx.zoom, screen_up(cx))
|
||
}
|
||
|
||
/// The widget of `cx` nearest `p` within reach.
|
||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||
let d = |w: &Self| w.at.distance(p);
|
||
Self::for_tool(cx).into_iter().filter(|w| d(w) <= cx.tol(HIT_PX)).min_by(|a, b| d(a).total_cmp(&d(b)))
|
||
}
|
||
}
|
||
|
||
/// The document direction of the screen's up (the page's up without a window).
|
||
fn screen_up(cx: &ToolContext) -> Vec2 {
|
||
cx.screen.map(|s| -s.down).filter(|v| v.x.is_finite() && v.y.is_finite() && v.hypot() > 0.0).unwrap_or(Vec2::new(0.0, -1.0))
|
||
}
|
||
|
||
/// The geometric boxes of the visible objects other than blend `skip` that reach into `region`.
|
||
/// The walk goes into visible layers and groups without a clipping path, topmost first, and looks
|
||
/// at no more than [`LOOK`] nodes; any other object counts by its own box.
|
||
fn boxes_near(doc: &Document, skip: NodeId, region: Rect) -> Vec<Rect> {
|
||
let reaches = |b: &Rect| b.x0 <= region.x1 && region.x0 <= b.x1 && b.y0 <= region.y1 && region.y0 <= b.y1;
|
||
// Each node is queued at most once, so `room` bounds both the queue and the walk; a container
|
||
// with more members than there is room for queues its topmost ones.
|
||
fn queue<'a>(nodes: &'a [std::sync::Arc<Node>], skip: NodeId, room: &mut usize) -> impl Iterator<Item = &'a Node> {
|
||
let take = nodes.len().min(*room);
|
||
*room -= take;
|
||
nodes.iter().skip(nodes.len() - take).filter(move |c| c.visible && c.id != skip).map(|c| &**c)
|
||
}
|
||
let mut room = LOOK;
|
||
let mut stack: Vec<&Node> = queue(&doc.layers, skip, &mut room).collect();
|
||
let mut out = vec![];
|
||
while let Some(n) = stack.pop() {
|
||
match &n.kind {
|
||
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
|
||
stack.extend(queue(children, skip, &mut room));
|
||
}
|
||
_ => out.extend(n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && reaches(b))),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Is `p` over a blend widget?
|
||
pub fn over_widget(cx: &ToolContext, p: Point) -> bool {
|
||
BlendWidget::at_point(cx, p).is_some()
|
||
}
|
||
|
||
/// Dragging a blend widget: its value follows the pointer along the spine's direction at the
|
||
/// press.
|
||
#[derive(Clone, Debug)]
|
||
pub struct BlendWidgetDrag {
|
||
widget: BlendWidget,
|
||
start: Point,
|
||
/// The pointer's last position.
|
||
at: Point,
|
||
/// What the arrow keys added.
|
||
keys: f64,
|
||
began: bool,
|
||
value: f64,
|
||
}
|
||
|
||
impl BlendWidgetDrag {
|
||
/// The drag has left the click: the value is changing.
|
||
pub fn adjusting(&self) -> bool {
|
||
self.began
|
||
}
|
||
|
||
/// Start a drag when the press `ev` is on a blend widget of the selection.
|
||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||
let widget = BlendWidget::at_point(cx, ev.pos)?;
|
||
Some(Self { widget, start: ev.pos, at: ev.pos, keys: 0.0, began: false, value: widget.value })
|
||
}
|
||
|
||
/// The value at the press plus the pointer's move along the spine, before the keys' steps
|
||
/// and the range.
|
||
fn dragged(&self, cx: &ToolContext) -> f64 {
|
||
let moved = (self.at - self.start).dot(self.widget.along) * cx.zoom;
|
||
self.widget.value + if moved.is_finite() { (moved * PER_PX).round() } else { 0.0 }
|
||
}
|
||
|
||
/// The value for the pointer at `self.at` and the keys pressed, within the range.
|
||
fn value_now(&self, cx: &ToolContext) -> f64 {
|
||
let (lo, hi) = self.widget.control.range();
|
||
(self.dragged(cx) + self.keys).clamp(lo, hi)
|
||
}
|
||
|
||
/// The actions that show the value now: the drag's start when it begins, then a preview.
|
||
fn preview(&mut self, cx: &ToolContext) -> Vec<Action> {
|
||
let mut out = vec![];
|
||
if !self.began {
|
||
self.began = true;
|
||
out.push(Action::Begin(LABEL.into()));
|
||
}
|
||
self.value = self.value_now(cx);
|
||
let mut params = json!({ "id": self.widget.id.0 });
|
||
params[self.widget.control.key()] = json!(self.value);
|
||
out.push(Action::Preview("object.blend.setEasing".into(), params));
|
||
out
|
||
}
|
||
|
||
pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec<Action> {
|
||
self.at = p;
|
||
if !self.began && p.distance(self.start) < cx.tol(3.0) {
|
||
return vec![];
|
||
}
|
||
self.preview(cx)
|
||
}
|
||
|
||
/// An arrow key during the drag: one point more (`more`) or less, ten with Shift.
|
||
pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec<Action> {
|
||
let by = if big { 10.0 } else { 1.0 };
|
||
// The step goes from the value shown, so past either end (by keys or by the drag) the way
|
||
// back starts at once.
|
||
let (lo, hi) = self.widget.control.range();
|
||
let stepped = (self.value_now(cx) + if more { by } else { -by }).clamp(lo, hi);
|
||
self.keys = stepped - self.dragged(cx);
|
||
self.preview(cx)
|
||
}
|
||
|
||
pub fn finish(self) -> Vec<Action> {
|
||
if self.began { vec![Action::Commit] } else { vec![] }
|
||
}
|
||
|
||
/// The value next to the pointer.
|
||
pub fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||
if !self.began || !cx.measurement_labels {
|
||
return vec![];
|
||
}
|
||
vec![Overlay::Measure { p: self.at, text: format!("{:.0}%", self.value) }]
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::select::SelectionTool;
|
||
use crate::testutil::*;
|
||
use crate::{Mods, PointerKind, Tool, ToolKey};
|
||
use std::sync::Arc;
|
||
use vectorcraft_doc::live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpec};
|
||
use vectorcraft_doc::{Appearance, Node};
|
||
use vectorcraft_geom::{PathData, Rect, SubPath, shapes};
|
||
|
||
const UP: Vec2 = Vec2::new(0.0, -1.0);
|
||
|
||
fn square(d: &mut Document, x: f64, y: f64) -> Arc<Node> {
|
||
let id = d.alloc_id();
|
||
Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art()))
|
||
}
|
||
|
||
/// A blend of squares centered at `centers`, with `spec`, selected.
|
||
fn blend_doc(centers: &[(f64, f64)], spec: BlendSpec) -> (Document, NodeId, Selection) {
|
||
let mut d = Document::new(800.0, 800.0);
|
||
let l = d.layers[0].id;
|
||
let children = centers.iter().map(|(x, y)| square(&mut d, x - 10.0, y - 10.0)).collect();
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(spec) })).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
(d, id, s)
|
||
}
|
||
|
||
/// Steps along a spine (Align to Path), eased In Out at 40%, color shift -20.
|
||
fn eased() -> BlendSpec {
|
||
BlendSpec {
|
||
orientation: BlendOrientation::AlignToPath,
|
||
ease: BlendEase { kind: BlendEaseType::InOut, ramp: 0.4 },
|
||
color_shift: -20.0,
|
||
..Default::default()
|
||
}
|
||
}
|
||
|
||
fn ev(kind: PointerKind, p: Point) -> PointerEvent {
|
||
PointerEvent::new(kind, p.x, p.y)
|
||
}
|
||
|
||
fn near(a: Point, b: Point) -> bool {
|
||
a.distance(b) < 1e-9
|
||
}
|
||
|
||
#[test]
|
||
fn the_widgets_sit_above_and_below_the_spines_halfway_point() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||
assert_eq!(w.iter().map(|w| w.control).collect::<Vec<_>>(), [BlendControl::Appearance, BlendControl::Easing]);
|
||
assert!(w.iter().all(|w| w.id == id && near(w.on, Point::new(200.0, 200.0))), "{w:?}");
|
||
assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||
assert_eq!((w[0].value, w[1].value), (-20.0, 40.0));
|
||
// 16 screen pixels at any zoom, and above on the screen in a turned view.
|
||
assert!(near(BlendWidget::of(&d, &s, 4.0, UP)[0].at, Point::new(200.0, 196.0)));
|
||
let turned = BlendWidget::of(&d, &s, 1.0, Vec2::new(1.0, 0.0));
|
||
assert!(near(turned[0].at, Point::new(216.0, 200.0)) && near(turned[1].at, Point::new(184.0, 200.0)), "{turned:?}");
|
||
// Halfway along by length: three keys spaced unevenly.
|
||
let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], eased());
|
||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].on, Point::new(100.0, 100.0)));
|
||
}
|
||
|
||
/// Adds a filled rectangle from `(x0, y0)` to `(x1, y1)` on top of the first layer.
|
||
fn cover(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64) -> NodeId {
|
||
let l = d.layers[0].id;
|
||
let id = d.alloc_id();
|
||
let at = d.layers[0].children().map_or(0, |c| c.len());
|
||
d.insert(Some(l), at, Node::path(id, shapes::rectangle(Rect::new(x0, y0, x1, y1)), Appearance::default_art())).unwrap();
|
||
id
|
||
}
|
||
|
||
#[test]
|
||
fn a_widget_moves_out_from_crowded_artwork_and_its_line_follows() {
|
||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
// Two objects under the Appearance widget's spot do not crowd it.
|
||
for _ in 0..2 {
|
||
cover(&mut d, 190.0, 175.0, 210.0, 195.0);
|
||
}
|
||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||
// A third does: the widget moves 16 px further out, past them, and the Easing widget below
|
||
// stays where it is. The halfway point it joins stays the same.
|
||
let third = cover(&mut d, 190.0, 175.0, 210.0, 195.0);
|
||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||
assert!(near(w[0].at, Point::new(200.0, 168.0)) && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}");
|
||
assert!(near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||
// A hidden object does not count.
|
||
d.node_mut(third).unwrap().visible = false;
|
||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||
// The walk looks at the topmost 1024 nodes only: with more objects above them, the covers
|
||
// below are not looked at.
|
||
d.node_mut(third).unwrap().visible = true;
|
||
let mut many = d.clone();
|
||
for i in 0..LOOK {
|
||
let x = (i % 30) as f64 * 25.0;
|
||
cover(&mut many, x, 500.0, x + 10.0, 510.0);
|
||
}
|
||
assert!(near(BlendWidget::of(&many, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 168.0)));
|
||
// Crowded all the way out (past 144 px): the widget stays at the first spot.
|
||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
for _ in 0..3 {
|
||
cover(&mut d, 150.0, 0.0, 250.0, 199.0);
|
||
}
|
||
assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0)));
|
||
// The blend's own keys never crowd it: three 100 pt keys that all hold both spots.
|
||
let mut d = Document::new(800.0, 800.0);
|
||
let l = d.layers[0].id;
|
||
let big = |d: &mut Document, x: f64| {
|
||
let id = d.alloc_id();
|
||
Arc::new(Node::path(id, shapes::rectangle(Rect::new(x - 50.0, 150.0, x + 50.0, 250.0)), Appearance::default_art()))
|
||
};
|
||
let children = vec![big(&mut d, 150.0), big(&mut d, 200.0), big(&mut d, 250.0)];
|
||
let id = d.alloc_id();
|
||
d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(eased()) })).unwrap();
|
||
let mut s = Selection::default();
|
||
s.add(id);
|
||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||
assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn the_easing_widget_needs_steps_that_follow_a_spine() {
|
||
// The straight lines between the keys with Align to Page: the steps do not follow a spine.
|
||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec::default());
|
||
assert_eq!(BlendWidget::of(&d, &s, 1.0, UP).iter().map(|w| w.control).collect::<Vec<_>>(), [BlendControl::Appearance]);
|
||
// A replaced spine, closed, with the keys spread along it.
|
||
let spine = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 200.0)], true));
|
||
let (d, _, s) = blend_doc(&[(0.0, 0.0), (200.0, 0.0)], BlendSpec { spine: Some(spine), ..Default::default() });
|
||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||
assert_eq!(w.len(), 2, "{w:?}");
|
||
assert!(w[0].on.x.is_finite() && w[0].on.y.is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn no_widgets_for_a_selection_they_cannot_edit() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
assert!(BlendWidget::of(&d, &Selection::default(), 1.0, UP).is_empty(), "nothing selected");
|
||
assert!(BlendWidget::of(&d, &s, 0.0, UP).is_empty() && BlendWidget::of(&d, &s, f64::NAN, UP).is_empty(), "no zoom");
|
||
assert!(BlendWidget::of(&d, &s, 1.0, Vec2::ZERO).is_empty(), "no up");
|
||
// Two blends, or a blend and another object.
|
||
let (mut two, a, _) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let l = two.layers[0].id;
|
||
let keys = vec![square(&mut two, 0.0, 400.0), square(&mut two, 200.0, 400.0)];
|
||
let b = two.alloc_id();
|
||
two.insert(Some(l), 0, Node::new(b, NodeKind::Blend { children: keys, spec: Box::new(eased()) })).unwrap();
|
||
let mut both = Selection::default();
|
||
both.add(a);
|
||
both.add(b);
|
||
assert!(BlendWidget::of(&two, &both, 1.0, UP).is_empty(), "two blends");
|
||
// Locked or hidden.
|
||
for hide in [false, true] {
|
||
let mut d = d.clone();
|
||
let n = d.node_mut(id).unwrap();
|
||
(n.visible, n.locked) = (!hide, !hide);
|
||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "hidden: {hide}");
|
||
}
|
||
// A key object selected on its own is not the whole blend.
|
||
let NodeKind::Blend { children, .. } = &d.node(id).unwrap().kind else { panic!("not a blend") };
|
||
let mut key = Selection::default();
|
||
key.add(children[0].id);
|
||
assert!(BlendWidget::of(&d, &key, 1.0, UP).is_empty(), "a key");
|
||
// Keys on one spot: the spine has no length.
|
||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], eased());
|
||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no length");
|
||
// One key.
|
||
let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased());
|
||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key");
|
||
// A blend made without a spine (Create Spine › Never), Align to Path included.
|
||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() });
|
||
assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine");
|
||
}
|
||
|
||
/// A drag of the Easing widget along the spine changes the ramp by one per pixel from its
|
||
/// value at the press, in one undo step, without changing the type or the color shift.
|
||
#[test]
|
||
fn dragging_the_easing_widget_sets_the_ramp_from_its_value() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
let at = Point::new(200.0, 216.0);
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty());
|
||
// A small move first: no jump, the value stays at the press's 40.
|
||
assert!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(1.0, 1.0))).is_empty());
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(3.0, 30.0)));
|
||
assert_eq!(a[0], Action::Begin(LABEL.into()));
|
||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 43.0})), "across the spine counts for nothing");
|
||
assert!(t.transforming());
|
||
assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "43%")));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-70.0, 0.0)));
|
||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 0.0}))], "clamped at 0");
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(500.0, 0.0)));
|
||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 100.0}))], "clamped at 100");
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at)), vec![Action::Commit]);
|
||
// A click that doesn't move sends nothing.
|
||
assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty());
|
||
assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty());
|
||
}
|
||
|
||
/// The Appearance widget sets the color shift alone, and at 4× zoom a pixel is a quarter point.
|
||
#[test]
|
||
fn dragging_the_appearance_widget_sets_the_color_shift() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let p = paint();
|
||
let c = ToolContext { zoom: 4.0, ..cx(&d, &s, &p) };
|
||
let mut t = SelectionTool::default();
|
||
let at = Point::new(200.0, 196.0);
|
||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-5.0, 0.0)));
|
||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -40.0})));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-50.0, 0.0)));
|
||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -100.0}))]);
|
||
}
|
||
|
||
/// The drag follows the spine's direction at the press: along a vertical spine, down adds.
|
||
#[test]
|
||
fn the_drag_follows_the_spines_direction() {
|
||
let (d, id, s) = blend_doc(&[(200.0, 100.0), (200.0, 300.0)], eased());
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let w = BlendWidget::of(&d, &s, 1.0, UP);
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, w[1].at));
|
||
let a = t.pointer(&c, &ev(PointerKind::Drag, w[1].at + Vec2::new(40.0, 10.0)));
|
||
assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 50.0})));
|
||
}
|
||
|
||
/// Switching away from the Selection or Blend tool during a drag cancels it, and after a press
|
||
/// that has not moved there is nothing to put back.
|
||
#[test]
|
||
fn a_tool_switch_cancels_the_drag() {
|
||
let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let at = Point::new(200.0, 216.0);
|
||
let tools: [Box<dyn Tool>; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())];
|
||
for mut t in tools {
|
||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(20.0, 0.0))).len(), 2);
|
||
assert_eq!(t.deactivate(&c), vec![Action::Cancel]);
|
||
assert!(!t.busy() && !t.transforming());
|
||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||
assert!(t.busy());
|
||
assert!(t.deactivate(&c).is_empty());
|
||
assert!(!t.busy());
|
||
}
|
||
}
|
||
|
||
/// The arrow keys during a drag change the value by one, ten with Shift, and Escape cancels.
|
||
#[test]
|
||
fn arrow_keys_step_and_escape_cancels() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let mut t = SelectionTool::default();
|
||
let at = Point::new(200.0, 216.0);
|
||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||
let a = t.key(&c, ToolKey::Up, Mods::default());
|
||
assert_eq!(a, vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 41.0}))]);
|
||
let a = t.key(&c, ToolKey::Left, Mods { shift: true, ..Default::default() });
|
||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 31.0}))]);
|
||
for _ in 0..10 {
|
||
t.key(&c, ToolKey::Down, Mods { shift: true, ..Default::default() });
|
||
}
|
||
let a = t.key(&c, ToolKey::Right, Mods::default());
|
||
assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 1.0}))], "steps past 0 are not kept");
|
||
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||
assert!(!t.busy() && !t.transforming());
|
||
assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing");
|
||
}
|
||
|
||
/// A double-click on a widget with the Selection tool does nothing to the artwork under it.
|
||
#[test]
|
||
fn a_double_click_on_a_widget_leaves_the_artwork_under_it_alone() {
|
||
let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
cover(&mut d, 190.0, 206.0, 210.0, 226.0);
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let at = Point::new(200.0, 216.0);
|
||
assert!(over_widget(&c, at));
|
||
let mut t = SelectionTool::default();
|
||
assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, at)).is_empty());
|
||
// Beside the widget, the double-click reaches the artwork.
|
||
assert!(!t.pointer(&c, &ev(PointerKind::DoubleClick, Point::new(192.0, 224.0))).is_empty());
|
||
}
|
||
|
||
/// After a drag past either end, the first arrow key steps back from the value shown.
|
||
#[test]
|
||
fn an_arrow_key_after_a_drag_past_an_end_steps_from_the_value_shown() {
|
||
let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased());
|
||
let p = paint();
|
||
let c = cx(&d, &s, &p);
|
||
let at = Point::new(200.0, 216.0);
|
||
let ramp = |v: f64| vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": v}))];
|
||
for (moved, back, shown) in [(200.0, ToolKey::Left, 99.0), (-200.0, ToolKey::Right, 1.0)] {
|
||
let mut t = SelectionTool::default();
|
||
t.pointer(&c, &ev(PointerKind::Down, at));
|
||
t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(moved, 0.0)));
|
||
assert_eq!(t.key(&c, back, Mods::default()), ramp(shown), "{moved}");
|
||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(moved, 0.0))), vec![Action::Commit]);
|
||
}
|
||
}
|
||
}
|