246 lines
9.3 KiB
Rust
246 lines
9.3 KiB
Rust
//! The place cursor (`file.place.queue` loads it with files): a click places the current file at
|
|
//! 100% with its top-left corner at the click, a drag places it at the dragged size (its aspect
|
|
//! kept), ←/→ and ↑/↓ cycle the files and Esc discards the current one. Each placement is one
|
|
//! `file.place`; after the last file the previous tool comes back.
|
|
|
|
use serde_json::{Value, json};
|
|
use vectorcraft_geom::{Point, Rect};
|
|
|
|
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
|
|
|
/// One loaded file.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
struct Entry {
|
|
/// `file.place` params (the file and its options).
|
|
params: Value,
|
|
name: String,
|
|
/// Its size at 100% (pt).
|
|
width: f64,
|
|
height: f64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct PlaceTool {
|
|
entries: Vec<Entry>,
|
|
current: usize,
|
|
/// The tool to return to when the last file is placed or discarded.
|
|
prev: Option<String>,
|
|
/// Pressed at, and the current drag point.
|
|
drag: Option<(Point, Point)>,
|
|
}
|
|
|
|
impl PlaceTool {
|
|
fn entry(&self) -> Option<&Entry> {
|
|
self.entries.get(self.current)
|
|
}
|
|
|
|
/// The box the current file goes into for a press at `a` released at `b`: its 100% size with
|
|
/// its top-left corner at `a` for a click (under `tol`), else grown from `a` towards `b` with
|
|
/// its aspect kept.
|
|
fn target(&self, a: Point, b: Point, tol: f64) -> Option<Rect> {
|
|
let e = self.entry()?;
|
|
let (dx, dy) = (b.x - a.x, b.y - a.y);
|
|
if dx.abs().max(dy.abs()) < tol || e.width <= 0.0 || e.height <= 0.0 {
|
|
return Some(Rect::new(a.x, a.y, a.x + e.width, a.y + e.height));
|
|
}
|
|
let k = (dx.abs() / e.width).max(dy.abs() / e.height);
|
|
let (w, h) = (e.width * k, e.height * k);
|
|
let x = if dx < 0.0 { a.x - w } else { a.x };
|
|
let y = if dy < 0.0 { a.y - h } else { a.y };
|
|
Some(Rect::new(x, y, x + w, y + h))
|
|
}
|
|
|
|
/// Drop the current file; after the last one, back to the previous tool.
|
|
fn discard(&mut self) -> Vec<Action> {
|
|
if self.current < self.entries.len() {
|
|
self.entries.remove(self.current);
|
|
}
|
|
if self.entries.is_empty() {
|
|
self.current = 0;
|
|
return vec![Action::SwitchTool(self.prev.clone().unwrap_or_else(|| "selection".into()))];
|
|
}
|
|
self.current %= self.entries.len();
|
|
vec![]
|
|
}
|
|
|
|
fn cycle(&mut self, forward: bool) {
|
|
let n = self.entries.len();
|
|
if n > 0 {
|
|
self.current = if forward { (self.current + 1) % n } else { (self.current + n - 1) % n };
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Tool for PlaceTool {
|
|
fn id(&self) -> &'static str {
|
|
"place"
|
|
}
|
|
|
|
fn busy(&self) -> bool {
|
|
self.drag.is_some()
|
|
}
|
|
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
|
match ev.kind {
|
|
PointerKind::Down if self.entry().is_some() => self.drag = Some((ev.pos, ev.pos)),
|
|
PointerKind::Drag => {
|
|
if let Some((a, _)) = self.drag {
|
|
self.drag = Some((a, ev.pos));
|
|
}
|
|
}
|
|
PointerKind::Up => {
|
|
let Some((a, _)) = self.drag.take() else { return vec![] };
|
|
let (Some(r), Some(e)) = (self.target(a, ev.pos, cx.tol(3.0)), self.entry()) else { return vec![] };
|
|
let mut p = e.params.clone();
|
|
p["rect"] = json!([r.x0, r.y0, r.width(), r.height()]);
|
|
let mut acts = vec![Action::Exec("file.place".into(), p)];
|
|
acts.extend(self.discard());
|
|
return acts;
|
|
}
|
|
_ => {}
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
|
match key {
|
|
ToolKey::Escape if self.drag.take().is_some() => vec![],
|
|
ToolKey::Escape => self.discard(),
|
|
ToolKey::Left | ToolKey::Up => {
|
|
self.cycle(false);
|
|
vec![]
|
|
}
|
|
ToolKey::Right | ToolKey::Down => {
|
|
self.cycle(true);
|
|
vec![]
|
|
}
|
|
_ => vec![],
|
|
}
|
|
}
|
|
|
|
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
|
!self.entries.is_empty() && matches!(key, ToolKey::Escape | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down)
|
|
}
|
|
|
|
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
|
let Some((a, b)) = self.drag else { return vec![] };
|
|
self.target(a, b, cx.tol(3.0)).map(Overlay::Marquee).into_iter().collect()
|
|
}
|
|
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
|
Cursor::Crosshair
|
|
}
|
|
|
|
/// `{count, current, name, width, height, names}`: what the cursor holds (no file data).
|
|
fn options(&self) -> Value {
|
|
let e = self.entry();
|
|
json!({
|
|
"count": self.entries.len(),
|
|
"current": self.current,
|
|
"name": e.map(|e| &e.name),
|
|
"width": e.map(|e| e.width),
|
|
"height": e.map(|e| e.height),
|
|
"names": self.entries.iter().map(|e| &e.name).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
/// `queue`: `{entries: [{params, name, width, height}], prev?}` loads the cursor (replacing what
|
|
/// it held); `current`: index of the file to place next.
|
|
fn set_option(&mut self, key: &str, value: &Value) {
|
|
match key {
|
|
"queue" => {
|
|
let entries: Vec<Entry> = value["entries"]
|
|
.as_array()
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|e| {
|
|
Some(Entry {
|
|
params: e.get("params").filter(|p| p.is_object())?.clone(),
|
|
name: e["name"].as_str().unwrap_or_default().to_string(),
|
|
width: e["width"].as_f64().filter(|v| v.is_finite())?,
|
|
height: e["height"].as_f64().filter(|v| v.is_finite())?,
|
|
})
|
|
})
|
|
.collect();
|
|
self.entries = entries;
|
|
self.current = 0;
|
|
self.drag = None;
|
|
if let Some(prev) = value["prev"].as_str() {
|
|
self.prev = Some(prev.to_string());
|
|
}
|
|
}
|
|
"current" => {
|
|
if let Some(i) = value.as_u64().map(|i| i as usize).filter(|i| *i < self.entries.len()) {
|
|
self.current = i;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Switching tools discards what the cursor holds.
|
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
|
self.entries.clear();
|
|
self.drag = None;
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testutil::{cx, doc_with_rect, paint};
|
|
|
|
fn loaded(n: usize) -> PlaceTool {
|
|
let mut t = PlaceTool::default();
|
|
let entries: Vec<Value> =
|
|
(0..n).map(|i| json!({"params": {"path": format!("f{i}.png")}, "name": format!("f{i}.png"), "width": 40.0, "height": 20.0})).collect();
|
|
t.set_option("queue", &json!({"entries": entries, "prev": "pen"}));
|
|
t
|
|
}
|
|
|
|
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
|
PointerEvent::new(kind, x, y)
|
|
}
|
|
|
|
#[test]
|
|
fn click_places_at_100_percent_and_drag_keeps_the_aspect() {
|
|
let (d, _) = doc_with_rect();
|
|
let (s, p) = (Default::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = loaded(3);
|
|
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
|
|
let acts = t.pointer(&c, &ev(PointerKind::Up, 11.0, 10.0));
|
|
assert_eq!(acts, vec![Action::Exec("file.place".into(), json!({"path": "f0.png", "rect": [10.0, 10.0, 40.0, 20.0]}))]);
|
|
// A drag up and to the left grows the box from the press point, aspect kept.
|
|
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
|
|
t.pointer(&c, &ev(PointerKind::Drag, 20.0, 90.0));
|
|
assert_eq!(t.overlays(&c), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]);
|
|
let acts = t.pointer(&c, &ev(PointerKind::Up, 20.0, 90.0));
|
|
assert_eq!(acts[0], Action::Exec("file.place".into(), json!({"path": "f1.png", "rect": [20.0, 60.0, 80.0, 40.0]})));
|
|
assert_eq!(t.options()["count"], 1);
|
|
// Placing the last file returns to the previous tool.
|
|
t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0));
|
|
let acts = t.pointer(&c, &ev(PointerKind::Up, 0.0, 0.0));
|
|
assert_eq!(acts.last(), Some(&Action::SwitchTool("pen".into())));
|
|
assert!(!t.claims_key(&c, ToolKey::Escape), "an empty cursor takes no keys");
|
|
}
|
|
|
|
#[test]
|
|
fn arrows_cycle_and_escape_discards() {
|
|
let (d, _) = doc_with_rect();
|
|
let (s, p) = (Default::default(), paint());
|
|
let c = cx(&d, &s, &p);
|
|
let mut t = loaded(3);
|
|
assert!(t.claims_key(&c, ToolKey::Left) && t.claims_key(&c, ToolKey::Escape));
|
|
t.key(&c, ToolKey::Left, Mods::default());
|
|
assert_eq!(t.options()["name"], "f2.png");
|
|
t.key(&c, ToolKey::Down, Mods::default());
|
|
assert_eq!(t.options()["name"], "f0.png");
|
|
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty());
|
|
assert_eq!(t.options()["names"], json!(["f1.png", "f2.png"]));
|
|
t.key(&c, ToolKey::Escape, Mods::default());
|
|
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("pen".into())]);
|
|
}
|
|
}
|