Files
designcraft/crates/tools/src/nav.rs
T
aiongg f11097c3f8 Merge #608: UI: spring-loaded zoom (Cmd+Space / Cmd+Alt+Space)
Conflict: crates/engine/src/tooling.rs: every with_ctx caller (IME preedit, commit, end, caret) takes the PR's `|s, cx| s.tool.…` form; pointer keeps next's end_composition before dispatch and tool_key its composing() early return
Conflict: crates/ui-egui/src/canvas.rs: the cursor block takes `space` from navigation_space, then the zoom branch, then `(space && !cmd) || alt_text`; the spring-zoom pointer events carry next's `clicks`; the PR's tests name their button helper `press` (next's tests have a `primary`) and compare cursors with what cursors::show sets for the engine cursor (next paints or rasterizes cursors per platform)
Conflict: ROADMAP.md, docs/target-app-parity.md, docs/ui-parity.md: status lines list both sides; progress and change-log rows keep both
2026-10-12 18:36:04 +00:00

312 lines
11 KiB
Rust

//! Hand (H) and Zoom (Z) tools.
use designcraft_geom::Point;
use serde_json::json;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
/// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the
/// pointer aims a rectangle of the previous view, and releasing zooms back in there.
#[derive(Default)]
pub struct HandTool {
last: Option<Point>,
power: bool,
}
impl Tool for HandTool {
fn id(&self) -> &'static str {
"hand"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down if ev.mods.alt => {
self.power = true;
self.last = Some(ev.pos);
vec![Action::View(json!({"powerZoom": "start", "at": [ev.pos.x, ev.pos.y]}))]
}
PointerKind::Down => {
self.last = Some(ev.pos);
vec![]
}
PointerKind::Drag if self.power => vec![Action::View(json!({"powerZoom": "move", "at": [ev.pos.x, ev.pos.y]}))],
PointerKind::Up if self.power => {
self.power = false;
self.last = None;
vec![Action::View(json!({"powerZoom": "end", "at": [ev.pos.x, ev.pos.y]}))]
}
PointerKind::Drag => {
// Canvas positions shift as we pan, so convert the delta to screen pixels once.
let Some(l) = self.last else { return vec![] };
let d = (ev.pos - l) * cx.zoom;
vec![Action::View(json!({"pan": [d.x, d.y]}))]
}
PointerKind::Up => {
self.last = None;
vec![]
}
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
if self.last.is_some() { Cursor::HandGrab } else { Cursor::Hand }
}
fn busy(&self) -> bool {
self.last.is_some()
}
}
/// Zoom tool: click zooms in (Alt: out); dragging left or right zooms out or in continuously
/// around the press point (scrubby zoom).
#[derive(Clone, Default)]
pub struct ZoomTool {
/// Press point (canvas), zoom at the press, whether it has been dragged.
press: Option<(Point, f64, bool)>,
}
/// Screen pixels of horizontal drag per e-fold of zoom.
const SCRUB_PX: f64 = 150.0;
impl Tool for ZoomTool {
fn id(&self) -> &'static str {
"zoom"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
self.press = Some((ev.pos, cx.zoom, false));
vec![]
}
PointerKind::Drag => {
let Some((start, z0, _)) = self.press else { return vec![] };
// The press point stays put on screen, so this is the on-screen distance.
let dx = (ev.pos.x - start.x) * cx.zoom;
if dx.abs() < 3.0 && self.press.is_some_and(|p| !p.2) {
return vec![];
}
self.press = Some((start, z0, true));
let want = (z0 * (dx / SCRUB_PX).exp()).clamp(0.05, 40.0);
vec![Action::View(json!({"zoomAt": [start.x, start.y], "factor": want / cx.zoom.max(1e-9)}))]
}
PointerKind::Up => {
let dragged = self.press.take().is_some_and(|p| p.2);
if dragged {
return vec![];
}
let f = if ev.mods.alt { 0.5 } else { 2.0 };
vec![Action::View(json!({"zoomAt": [ev.pos.x, ev.pos.y], "factor": f}))]
}
_ => vec![],
}
}
fn busy(&self) -> bool {
self.press.is_some_and(|p| p.2)
}
fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor {
if m.alt { Cursor::ZoomOut } else { Cursor::ZoomIn }
}
}
/// The loaded place cursor: click places at actual size, drag draws the frame, click on an empty
/// frame places into it.
#[derive(Default)]
pub struct PlaceGun {
start: Option<Point>,
cur: Point,
}
impl Tool for PlaceGun {
fn id(&self) -> &'static str {
"placeGun"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
self.start = Some(ev.pos);
self.cur = ev.pos;
vec![]
}
PointerKind::Drag => {
self.cur = ev.pos;
vec![]
}
PointerKind::Up => {
let Some(a) = self.start.take() else { return vec![] };
let Some((sr, sa)) = cx.layout.spread_at(a) else { return vec![] };
let b = cx.layout.to_spread(sr, ev.pos);
if (ev.pos - a).hypot() < cx.tol(3.0)
&& let Some((_, id)) = cx.hit(a)
&& cx
.doc
.item(id)
.is_some_and(|i| matches!(i.content, designcraft_doc::Content::Unassigned | designcraft_doc::Content::Graphic(_)))
{
return vec![Action::Exec("place.drop".into(), json!({"frame": id.0}))];
}
let r = designcraft_geom::Rect::from_points(sa, b);
vec![Action::Exec(
"place.drop".into(),
json!({"spread": crate::spread_json(sr), "x": sa.x, "y": sa.y, "rect": [r.x0, r.y0, r.x1, r.y1]}),
)]
}
_ => vec![],
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<crate::Overlay> {
match self.start {
Some(a) => vec![crate::Overlay::Marquee(designcraft_geom::Rect::from_points(a, self.cur))],
None => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec<Action> {
match key {
// Escape unloads the place cursor.
crate::ToolKey::Escape => vec![Action::SwitchTool("selection".into())],
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::LoadedGraphic
}
}
/// Page tool (Shift+P): click a page to set its own size.
#[derive(Default)]
pub struct PageTool;
impl Tool for PageTool {
fn id(&self) -> &'static str {
"page"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Up {
return vec![];
}
let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] };
let Some(spread) = cx.doc.spreads.get(si) else { return vec![] };
let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] };
let pg = &spread.pages[pi];
let n = cx.doc.first_page_of_spread(si) + pi + 1;
vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Arrow
}
}
/// Type on a Path tool (Shift+T): click a path to put type on it; typing goes there.
#[derive(Default)]
pub struct PathTypeTool;
impl Tool for PathTypeTool {
fn id(&self) -> &'static str {
"typeOnPath"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Up {
return vec![];
}
let Some((_, id)) = cx.hit(ev.pos) else { return vec![] };
let Some(it) = cx.doc.item(id) else { return vec![] };
match &it.content {
designcraft_doc::Content::Unassigned => vec![Action::Exec("type.onPath".into(), json!({"id": id.0})), Action::SwitchTool("type".into())],
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Text
}
}
/// Gap tool (U): drag the space between objects (or between an object and the page edge); the
/// objects on both sides resize so the gap moves.
#[derive(Default)]
pub struct GapTool {
/// Spread, its spread → canvas transform and the press point in spread coordinates.
start: Option<(designcraft_doc::SpreadRef, designcraft_geom::Affine, Point)>,
active: bool,
}
impl Tool for GapTool {
fn id(&self) -> &'static str {
"gap"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
if let Some((sr, p)) = cx.layout.spread_at(ev.pos) {
self.start = Some((sr, cx.layout.xf(sr), p));
}
self.active = false;
vec![]
}
PointerKind::Drag => {
let Some((sr, xf, p)) = self.start else { return vec![] };
let d = (xf.inverse() * ev.pos) - p;
let mut out = vec![];
if !self.active {
if d.hypot() < cx.tol(2.0) {
return vec![];
}
self.active = true;
out.push(Action::Begin("Move Gap".into()));
}
out.push(Action::Preview(
"gap.move".into(),
serde_json::json!({"spread": crate::spread_json(sr), "at": [p.x, p.y], "dx": d.x, "dy": d.y}),
));
out
}
PointerKind::Up => {
self.start = None;
if std::mem::take(&mut self.active) { vec![Action::Commit] } else { vec![] }
}
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn busy(&self) -> bool {
self.active
}
}
/// Content Collector (B): click objects to put copies on the conveyor. Content Placer: click to
/// place the next collected object there.
pub struct ConveyorTool {
placer: bool,
}
impl ConveyorTool {
pub fn new(placer: bool) -> Self {
Self { placer }
}
}
impl Tool for ConveyorTool {
fn id(&self) -> &'static str {
if self.placer { "contentPlacer" } else { "contentCollector" }
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Down {
return vec![];
}
if self.placer {
let Some((sr, p)) = cx.layout.spread_at(ev.pos) else { return vec![] };
return vec![Action::Exec("conveyor.place".into(), json!({"spread": crate::spread_json(sr), "x": p.x, "y": p.y, "keep": ev.mods.alt}))];
}
match cx.hit(ev.pos) {
Some((_, id)) => vec![Action::Exec("conveyor.collect".into(), json!({"ids": [id.0]}))],
None => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
}