Files
deckcraft/crates/engine/src/tools.rs
T

398 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Pointer tools. Gestures arrive in slide coordinates (points) through [`Session::pointer`] from
//! the UI, the control channel or MCP alike; each finished gesture is one undo step.
use std::sync::Arc;
use deckcraft_geom::{Affine, Point, Rect, Vec2, Xfrm};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::{self, xfrm_of};
use crate::{HistoryEntry, Result, Session, TextSel};
mod click;
mod drag;
mod geometry;
mod hit;
mod pointer;
pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PointerKind {
#[default]
Down,
Drag,
Up,
Move,
DoubleClick,
TripleClick,
/// Secondary-button press (right click), before the context menu opens: selects what's under
/// the pointer the way PowerPoint does, without editing text or starting a drag.
ContextClick,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Mods {
pub shift: bool,
pub alt: bool,
pub cmd: bool,
}
/// A text selection's shape, table cell, notes flag, anchor and caret.
type TextTarget = (ShapeId, Option<(usize, usize)>, bool, (usize, usize), (usize, usize));
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct PointerEvent {
pub kind: PointerKind,
pub x: f64,
pub y: f64,
pub mods: Mods,
/// Hit tolerance in points (≈ 4 screen px at the current zoom).
pub tol: f64,
/// Pen pressure `0..=1` when the device reported one. `None` is a mouse: the stroke
/// is stored at full pen width.
#[serde(default)]
pub pressure: Option<f32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ToolKind {
#[default]
Select,
/// Draw a preset shape.
Shape {
preset: String,
},
TextBox,
/// Ink: pen (`pen`, `highlighter`) or `eraser`.
Ink {
mode: String,
color: deckcraft_color::Rgba,
width: f64,
},
}
/// What a point hits.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Hit {
/// Resize handle 0..8: NW, N, NE, E, SE, S, SW, W.
Resize {
id: ShapeId,
handle: u8,
},
Rotate {
id: ShapeId,
},
Adjust {
id: ShapeId,
index: usize,
},
/// A control handle (`out`: the outgoing one) of the selected vertex in Edit Points.
Handle {
id: ShapeId,
path: usize,
index: usize,
out: bool,
},
/// A vertex of a shape whose points are being edited.
Vertex {
id: ShapeId,
path: usize,
index: usize,
},
/// WordArt warp adjust (yellow handle). `index` is `adj` / `adjN`.
Warp {
id: ShapeId,
index: usize,
},
/// Line end 0/1 of a line-like shape.
LineEnd {
id: ShapeId,
end: u8,
},
Shape {
id: ShapeId,
text: bool,
},
/// The frame of the box whose text is being edited (a band on either side of its edge):
/// pressing there leaves text editing and drags the box, like PowerPoint.
Frame {
id: ShapeId,
},
/// The right edge of table column `col` (dragging it resizes the column).
TableColumn {
id: ShapeId,
col: usize,
},
/// The bottom edge of table row `row` (dragging it resizes the row).
TableRow {
id: ShapeId,
row: usize,
},
}
#[derive(Clone, Debug)]
enum Drag {
Move {
ids: Vec<ShapeId>,
start: Point,
origs: Vec<(ShapeId, Xfrm)>,
moved: bool,
duplicate: bool,
enter_text: Option<(ShapeId, Point)>,
},
Resize {
id: ShapeId,
handle: u8,
orig: Xfrm,
start: Point,
},
LineEnd {
id: ShapeId,
end: u8,
orig: Xfrm,
},
Rotate {
ids: Vec<ShapeId>,
center: Point,
start: f64,
origs: Vec<(ShapeId, Xfrm)>,
},
Adjust {
id: ShapeId,
index: usize,
orig: Xfrm,
},
Vertex {
id: ShapeId,
path: usize,
index: usize,
},
Handle {
id: ShapeId,
path: usize,
index: usize,
out: bool,
},
/// WordArt: `base` is the adjust list at mouse-down (empty means the preset defaults).
Warp {
id: ShapeId,
index: usize,
name: String,
base: Vec<f64>,
orig: Xfrm,
},
Marquee {
start: Point,
add: bool,
},
/// Dragging drawing guide `index`.
Guide {
index: usize,
},
Create {
start: Point,
},
TextSelect {
shape: ShapeId,
/// The text selection before the press, to tell whether the drag changed it.
before: Option<TextTarget>,
/// The text selection the press made. In a table, a drag that leaves its cell selects
/// whole cells, and coming back to the cell restores this.
press: Option<TextSel>,
},
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
/// drawn height then, `frame` where the table was drawn.
TableBorder {
id: ShapeId,
column: bool,
index: usize,
start: Point,
size: f64,
frame: Affine,
moved: bool,
},
Ink {
points: Vec<(f64, f64, f32)>,
},
/// Freeform drawing (`freeform` polygon, `curve`, `scribble`): placed points; `pressed` while
/// the button is down (dragging adds scribbled points).
Freeform {
mode: String,
points: Vec<Point>,
pressed: bool,
},
}
/// Shape-gallery names that draw a freeform path instead of a preset.
pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")];
/// Draw Table's pencil and eraser, as `ToolKind::Shape` presets (Insert › Table › Draw Table).
pub const DRAW_TABLE: &str = "drawTable";
pub const TABLE_ERASER: &str = "tableEraser";
pub fn is_freeform_tool(name: &str) -> bool {
FREEFORM_TOOLS.iter().any(|f| f.0 == name)
}
/// A snapping guide line to draw (slide coordinates): vertical (x) or horizontal (y).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Guide {
pub vertical: bool,
pub pos: f64,
pub from: f64,
pub to: f64,
}
#[derive(Clone, Debug, Default)]
pub struct ToolState {
pub kind: ToolKind,
/// Keep the tool after one use (double-click on a shape in the gallery: lock drawing mode).
pub sticky: bool,
drag: Option<Drag>,
pub marquee: Option<Rect>,
pub preview: Option<Xfrm>,
pub guides: Vec<Guide>,
pub hover: Option<Hit>,
pub ink_preview: Vec<(f64, f64, f32)>,
/// Connection sites to show (slide coordinates) while drawing or dragging a line end, and the
/// site the end would glue to.
pub sites: Vec<Point>,
pub glue: Option<Point>,
/// Freeform being drawn: points so far (plus the rubber-band point), smoothed, and whether
/// the pointer is over the start (closing the shape).
pub path_preview: Vec<Point>,
pub path_smooth: bool,
pub path_closing: bool,
/// The shape in Edit Points: its vertices are handles instead of the box's.
pub edit_points: Option<ShapeId>,
/// The vertex (path, index) whose control handles show in Edit Points.
pub edit_vertex: Option<(usize, usize)>,
/// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for
/// the segment menu.
pub edit_segment: Option<(usize, usize)>,
/// Where on that segment the right click was (0..1), where Add Point puts the new point.
pub edit_segment_t: Option<f64>,
/// The point type last chosen for a vertex (shape, path, index, type): handle drags follow it.
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
pub guides_visible: bool,
/// The guide being dragged and where it is now (slide points); `None` once dropped.
pub guide_drag: Option<(usize, f64)>,
/// The drawing guide under the pointer, for the cursor.
pub guide_hover: Option<usize>,
/// Pressure from the pointer event being handled, if the device reported one.
pressure: Option<f32>,
}
impl ToolState {
pub fn dragging(&self) -> bool {
self.drag.is_some()
}
/// The shape in Edit Points, while it is still the one selected shape.
pub fn editing_points(&self, st: &crate::DocState) -> Option<ShapeId> {
// Only a freeform has points: after Undo takes the conversion back, Edit Points is over.
self.edit_points.filter(|id| {
st.selection.shapes.as_slice() == [*id]
&& st.selection.text.is_none()
&& st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }))
})
}
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
pub fn drawing_freeform(&self) -> bool {
matches!(self.drag, Some(Drag::Freeform { .. }))
}
}
/// Handle positions in slide coordinates for a box (8 resize + rotate).
pub fn box_handles(x: &Xfrm) -> ([Point; 8], Point) {
let a = x.affine();
let (w, h) = (x.w, x.h);
let local = [(0.0, 0.0), (w / 2.0, 0.0), (w, 0.0), (w, h / 2.0), (w, h), (w / 2.0, h), (0.0, h), (0.0, h / 2.0)];
let pts = local.map(|(px, py)| a * Point::new(px, py));
// The rotation handle sits above the top edge (in the shape's rotated frame).
let up = if x.flip_v { 1.0 } else { -1.0 };
let rot =
Affine::translate(Vec2::new(x.x + w / 2.0, x.y + h / 2.0)) * Affine::rotate(x.rot.to_radians()) * Point::new(0.0, up * (h / 2.0 + 20.0));
(pts, rot)
}
/// WordArt adjust handles for `sh`, in shape-local points (the text rectangle). Empty when the
/// resolved body has no warp DeckCraft draws, or the shape has no text to store one on.
pub fn wordart_handles(
doc: &deckcraft_model::Presentation,
sel: &crate::Selection,
sh: &Shape,
w: f64,
h: f64,
) -> Vec<deckcraft_geom::warp::WarpHandle> {
if sh.text.is_none() {
return Vec::new();
}
let Some(ctx) = crate::cmd::text::ctx_for(doc, sel) else { return Vec::new() };
let bp = deckcraft_model::resolve::body(&ctx, sh);
let Some(name) = bp.warp.as_deref().filter(|n| deckcraft_geom::warp::is_supported(n)) else { return Vec::new() };
let tr = deckcraft_render::shape_geometry(sh, w, h).text_rect;
deckcraft_geom::warp::handles(name, &bp.warp_adj, tr)
}
/// Write one warp adjust, keeping the others from `base` (empty = the preset defaults).
fn set_warp_adj(sh: &mut Shape, name: &str, base: &[f64], index: usize, value: f64) {
let Some(text) = sh.text.as_mut() else { return };
if !value.is_finite() || index > 7 {
return;
}
let defaults = deckcraft_geom::warp::adjust_defaults(name);
let n = defaults.len().max(index + 1).min(8);
if n == 0 {
return;
}
let mut adj = Vec::with_capacity(n);
for i in 0..n {
let v = if i == index { value } else { base.get(i).copied().filter(|v| v.is_finite()).or_else(|| defaults.get(i).copied()).unwrap_or(0.0) };
adj.push(v);
}
text.body.warp = Some(name.to_string());
text.body.warp_adj = adj;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resize_corner_and_flip() {
let o = Xfrm::new(100.0, 100.0, 100.0, 50.0);
let n = resize_box(&o, 4, Vec2::new(20.0, 10.0), false, false);
assert!((n.x - 100.0).abs() < 1e-9 && (n.w - 120.0).abs() < 1e-9 && (n.h - 60.0).abs() < 1e-9);
let n = resize_box(&o, 0, Vec2::new(10.0, 10.0), false, false);
assert!((n.x - 110.0).abs() < 1e-9 && (n.w - 90.0).abs() < 1e-9);
let n = resize_box(&o, 3, Vec2::new(-150.0, 0.0), false, false);
assert!(n.flip_h && (n.w - 50.0).abs() < 1e-9 && (n.x - 50.0).abs() < 1e-9);
let n = resize_box(&o, 4, Vec2::new(100.0, 0.0), true, false);
assert!((n.w / n.h - 2.0).abs() < 1e-9);
let n = resize_box(&o, 3, Vec2::new(10.0, 0.0), false, true);
assert!((n.w - 120.0).abs() < 1e-9 && (n.x - 90.0).abs() < 1e-9);
}
#[test]
fn resize_rotated_keeps_opposite_corner() {
let o = Xfrm { x: 100.0, y: 100.0, w: 100.0, h: 50.0, rot: 90.0, ..Default::default() };
let anchor = o.affine() * Point::new(0.0, 0.0);
let n = resize_box(&o, 4, Vec2::new(-10.0, 30.0), false, false);
let anchor2 = n.affine() * Point::new(0.0, 0.0);
assert!((anchor - anchor2).hypot() < 1e-6, "{anchor:?} {anchor2:?}");
}
}