mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 02:37:00 -06:00
# Conflicts: # crates/tools/src/lib.rs # crates/tools/src/tests.rs # crates/ui-egui/src/canvas.rs
434 lines
13 KiB
Rust
434 lines
13 KiB
Rust
//! DesignCraft tools: pointer events in → commands and overlays out.
|
|
//!
|
|
//! Tools never mutate the document. They emit [`Action`]s the engine executes: `Begin` snapshots
|
|
//! the document, each `Preview` re-applies one command on top of the snapshot, `Commit` records
|
|
//! the last preview as one undo step. `Exec` runs a command immediately. Every gesture is therefore
|
|
//! replayable by the control channel and MCP, and tools are testable without a UI.
|
|
//!
|
|
//! Pointer positions are **canvas** coordinates (see [`layout::CanvasLayout`]).
|
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
|
#![forbid(unsafe_code)]
|
|
|
|
mod anchors;
|
|
pub mod catalog;
|
|
mod frame;
|
|
mod gradient;
|
|
pub mod layout;
|
|
mod nav;
|
|
mod pen;
|
|
mod pencil;
|
|
pub mod select;
|
|
pub mod snap;
|
|
mod text;
|
|
mod xform;
|
|
|
|
use designcraft_compose::Cache;
|
|
use designcraft_doc::{Document, Item, ItemId, Selection, SpreadRef};
|
|
use designcraft_geom::{Affine, BezPath, Point, Rect, Unit, Vec2};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_for_shortcut, tool_info};
|
|
pub use layout::CanvasLayout;
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Mods {
|
|
#[serde(default)]
|
|
pub shift: bool,
|
|
#[serde(default)]
|
|
pub alt: bool,
|
|
/// Command on macOS, Ctrl elsewhere.
|
|
#[serde(default)]
|
|
pub cmd: bool,
|
|
#[serde(default)]
|
|
pub ctrl: bool,
|
|
#[serde(default)]
|
|
pub space: bool,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum PointerKind {
|
|
Down,
|
|
Drag,
|
|
Up,
|
|
Move,
|
|
DoubleClick,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct PointerEvent {
|
|
pub kind: PointerKind,
|
|
/// Canvas coordinates.
|
|
pub pos: Point,
|
|
#[serde(default)]
|
|
pub mods: Mods,
|
|
/// Presses in quick succession at about the same spot, this one included: 2 on a
|
|
/// `DoubleClick`, 3 or more on a `Down` (the Type tool selects a sentence, paragraph, story).
|
|
/// 0 or 1 is a single press.
|
|
#[serde(default)]
|
|
pub clicks: u8,
|
|
}
|
|
|
|
impl PointerEvent {
|
|
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
|
|
Self { kind, pos: Point::new(x, y), mods: Mods::default(), clicks: 0 }
|
|
}
|
|
pub fn with_mods(mut self, m: Mods) -> Self {
|
|
self.mods = m;
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ToolKey {
|
|
Enter,
|
|
Escape,
|
|
Backspace,
|
|
Delete,
|
|
Up,
|
|
Down,
|
|
Left,
|
|
Right,
|
|
Home,
|
|
End,
|
|
Tab,
|
|
PageUp,
|
|
PageDown,
|
|
}
|
|
|
|
/// What a tool asks the engine to do.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Action {
|
|
Begin(String),
|
|
Preview(String, Value),
|
|
Commit,
|
|
Cancel,
|
|
Exec(String, Value),
|
|
/// Ask the UI to open a dialog.
|
|
Dialog(String, Value),
|
|
SwitchTool(String),
|
|
/// Ask the UI to pan/zoom: `{"pan":[dx,dy]}` (screen px) or `{"zoomAt":[x,y], "factor":f}` (canvas).
|
|
View(Value),
|
|
}
|
|
|
|
/// Which snap categories are on, and the snap zone in screen pixels.
|
|
///
|
|
/// Each flag is its own view switch, not one combined mode.
|
|
#[allow(clippy::struct_excessive_bools)]
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct SnapView {
|
|
pub snap_to_guides: bool,
|
|
pub snap_to_document_grid: bool,
|
|
pub show_guides: bool,
|
|
pub smart_guides: bool,
|
|
pub align_edges: bool,
|
|
pub align_centers: bool,
|
|
pub smart_dimensions: bool,
|
|
pub smart_spacing: bool,
|
|
pub zone_px: f64,
|
|
}
|
|
|
|
impl SnapView {
|
|
pub const FACTORY: Self = Self {
|
|
snap_to_guides: true,
|
|
snap_to_document_grid: false,
|
|
show_guides: true,
|
|
smart_guides: true,
|
|
align_edges: true,
|
|
align_centers: true,
|
|
smart_dimensions: true,
|
|
smart_spacing: true,
|
|
zone_px: 4.0,
|
|
};
|
|
pub const OFF: Self = Self {
|
|
snap_to_guides: false,
|
|
snap_to_document_grid: false,
|
|
show_guides: true,
|
|
smart_guides: false,
|
|
align_edges: false,
|
|
align_centers: false,
|
|
smart_dimensions: false,
|
|
smart_spacing: false,
|
|
zone_px: 4.0,
|
|
};
|
|
|
|
/// Guides, the document grid, or smart guides are on.
|
|
pub fn any(self) -> bool {
|
|
self.snap_to_guides || self.snap_to_document_grid || self.smart_guides
|
|
}
|
|
}
|
|
|
|
impl Default for SnapView {
|
|
fn default() -> Self {
|
|
Self::FACTORY
|
|
}
|
|
}
|
|
|
|
/// The gesture asking for a snap.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Gesture {
|
|
Move,
|
|
Resize,
|
|
Create,
|
|
Rotate,
|
|
Point,
|
|
}
|
|
|
|
pub struct SnapRequest<'a> {
|
|
pub spread: SpreadRef,
|
|
pub gesture: Gesture,
|
|
pub rect: Rect,
|
|
/// Left, center, right. All false means the axis is locked.
|
|
pub x_edges: [bool; 3],
|
|
/// Top, center, bottom.
|
|
pub y_edges: [bool; 3],
|
|
pub exclude: &'a [ItemId],
|
|
pub copying: bool,
|
|
/// Proposed width and height for the dimensions pass. None when that length is not being set.
|
|
pub lengths: [Option<f64>; 2],
|
|
/// Proposed rotation in the same degrees `transform.rotate` already receives. None unless rotating.
|
|
pub angle: Option<f64>,
|
|
pub radius: f64,
|
|
pub pointer: Point,
|
|
}
|
|
|
|
/// Read-only context a tool sees.
|
|
pub struct ToolContext<'a> {
|
|
pub doc: &'a Document,
|
|
pub selection: &'a Selection,
|
|
pub cache: &'a Cache,
|
|
pub layout: &'a CanvasLayout,
|
|
/// Screen pixels per point.
|
|
pub zoom: f64,
|
|
/// Layer new items go on.
|
|
pub layer: designcraft_doc::LayerId,
|
|
pub snap: SnapView,
|
|
pub unit: Unit,
|
|
}
|
|
|
|
impl ToolContext<'_> {
|
|
pub fn tol(&self, px: f64) -> f64 {
|
|
px / self.zoom.max(1e-9)
|
|
}
|
|
|
|
/// Frontmost unlocked item under a canvas point.
|
|
pub fn hit(&self, p: Point) -> Option<(SpreadRef, ItemId)> {
|
|
let (sr, sp) = self.layout.spread_at(p)?;
|
|
let SpreadRef::Doc(si) = sr else {
|
|
return self.hit_parent(sr, sp);
|
|
};
|
|
self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id))
|
|
}
|
|
|
|
/// The parent item showing (not yet overridden) on the document page under `p`:
|
|
/// (absolute page, parent item).
|
|
pub fn hit_parent_on_page(&self, p: Point) -> Option<(usize, ItemId)> {
|
|
let (SpreadRef::Doc(si), sp) = self.layout.spread_at(p)? else { return None };
|
|
let spread = self.doc.spreads.get(si)?;
|
|
let pi = spread.page_at_x(sp.x)?;
|
|
let page = self.doc.first_page_of_spread(si) + pi;
|
|
let (ppi, ppg) = self.doc.parent_page_for(page)?;
|
|
let parent = &self.doc.parents[ppi];
|
|
let pg = &spread.pages[pi];
|
|
let at = sp - designcraft_geom::Vec2::new(pg.x - parent.pages[ppg].x, 0.0);
|
|
parent
|
|
.items
|
|
.iter()
|
|
.rev()
|
|
.find(|it| !it.locked && !pg.overridden.contains(&it.id) && designcraft_doc::edit_hit(it, at, self.tol(4.0)))
|
|
.map(|it| (page, it.id))
|
|
}
|
|
|
|
fn hit_parent(&self, sr: SpreadRef, sp: Point) -> Option<(SpreadRef, ItemId)> {
|
|
let s = self.doc.spread(sr)?;
|
|
s.items.iter().rev().find(|it| !it.locked && designcraft_doc::edit_hit(it, sp, self.tol(4.0))).map(|it| (sr, it.id))
|
|
}
|
|
|
|
/// Spread-space → canvas transform of the spread holding item `id`.
|
|
pub fn item_canvas_xf(&self, id: ItemId) -> Option<Affine> {
|
|
let loc = self.doc.find(id)?;
|
|
Some(self.layout.xf(loc.spread) * self.doc.parent_xf(&loc))
|
|
}
|
|
|
|
pub fn item(&self, id: ItemId) -> Option<&Item> {
|
|
self.doc.item(id)
|
|
}
|
|
|
|
/// Canvas bounds of the selected items' paths. Handles and resize use this geometric box.
|
|
pub fn selection_bounds(&self) -> Option<Rect> {
|
|
let mut r: Option<Rect> = None;
|
|
for id in &self.selection.items {
|
|
let (Some(it), Some(xf)) = (self.doc.item(*id), self.item_canvas_xf(*id)) else { continue };
|
|
let b = xf.transform_rect_bbox(it.bounds());
|
|
r = Some(r.map_or(b, |r| r.union(b)));
|
|
}
|
|
r
|
|
}
|
|
|
|
/// Canvas union of the selection's visible bounds. A group uses the alignment box.
|
|
pub fn selection_visible_bounds(&self) -> Option<Rect> {
|
|
let mut acc: Option<Rect> = None;
|
|
for id in &self.selection.items {
|
|
let (Some(it), Some(xf)) = (self.doc.item(*id), self.item_canvas_xf(*id)) else { continue };
|
|
let Some(local) = crate::snap::moving_bounds(it) else { continue };
|
|
let b = xf.transform_rect_bbox(local);
|
|
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
|
|
continue;
|
|
}
|
|
acc = Some(acc.map_or(b, |have| have.union(b)));
|
|
}
|
|
acc
|
|
}
|
|
}
|
|
|
|
/// Visual feedback (canvas coordinates) drawn by the UI.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Overlay {
|
|
Marquee(Rect),
|
|
/// Preview outline of a frame being drawn.
|
|
Path {
|
|
path: BezPath,
|
|
color: [u8; 3],
|
|
dashed: bool,
|
|
},
|
|
Line {
|
|
a: Point,
|
|
b: Point,
|
|
color: [u8; 3],
|
|
dashed: bool,
|
|
},
|
|
/// Measurement pill near the cursor.
|
|
Measure {
|
|
p: Point,
|
|
text: String,
|
|
},
|
|
/// Smart guide line in the smart-guide colour.
|
|
Guide {
|
|
a: Point,
|
|
b: Point,
|
|
},
|
|
/// Spacing segment in canvas coordinates, with a measurement label.
|
|
Gap {
|
|
a: Point,
|
|
b: Point,
|
|
label: String,
|
|
},
|
|
}
|
|
|
|
/// Correction from a snap, in spread coordinates for `delta`.
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct Snap {
|
|
pub delta: Vec2,
|
|
pub angle: Option<f64>,
|
|
pub guides: Vec<Overlay>,
|
|
/// Set when the dimensions pass won that axis. The value is the matched length minus the
|
|
/// proposed length (positive grows the size). `delta` on that axis is the moving edge's shift.
|
|
pub length_delta: [Option<f64>; 2],
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Cursor {
|
|
#[default]
|
|
Arrow,
|
|
ArrowHollow,
|
|
Move,
|
|
Crosshair,
|
|
ResizeH,
|
|
ResizeV,
|
|
ResizeNwSe,
|
|
ResizeNeSw,
|
|
Rotate,
|
|
Pen,
|
|
/// The Pen over an open path's end point: a click continues the path.
|
|
PenContinue,
|
|
/// Over a type-on-a-path bracket: dragging it moves where the text starts or ends.
|
|
PathBracket,
|
|
Text,
|
|
Hand,
|
|
HandGrab,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
Eyedropper,
|
|
LoadedText,
|
|
LoadedGraphic,
|
|
NotAllowed,
|
|
}
|
|
|
|
pub trait Tool: Send {
|
|
fn id(&self) -> &'static str;
|
|
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action>;
|
|
fn key(&mut self, _cx: &ToolContext, _key: ToolKey, _mods: Mods) -> Vec<Action> {
|
|
vec![]
|
|
}
|
|
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
|
|
vec![]
|
|
}
|
|
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
|
Cursor::Arrow
|
|
}
|
|
fn busy(&self) -> bool {
|
|
false
|
|
}
|
|
/// The tool takes typed text (Type tool with a caret): single-key shortcuts are suppressed.
|
|
fn wants_text(&self, _cx: &ToolContext) -> bool {
|
|
false
|
|
}
|
|
}
|
|
|
|
pub fn create(id: &str) -> Box<dyn Tool> {
|
|
match id {
|
|
"selection" => Box::new(select::SelectionTool::new(false)),
|
|
"directSelection" => Box::new(select::SelectionTool::new(true)),
|
|
"rectangleFrame" | "ellipseFrame" | "polygonFrame" | "rectangle" | "ellipse" | "polygon" | "line" => Box::new(frame::FrameTool::new(id)),
|
|
"type" => Box::new(text::TypeTool::default()),
|
|
"verticalType" => Box::new(text::TypeTool::vertical()),
|
|
"pen" => Box::new(pen::PenTool::default()),
|
|
"gradientSwatch" => Box::new(gradient::GradientTool::default()),
|
|
"gradientFeather" => Box::new(gradient::GradientTool::feather()),
|
|
"pencil" => Box::new(pencil::PencilTool::default()),
|
|
"smooth" => Box::new(pencil::PathDragTool::new(false)),
|
|
"erase" => Box::new(pencil::PathDragTool::new(true)),
|
|
"addAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Add)),
|
|
"deleteAnchor" => Box::new(anchors::AnchorTool::new(anchors::Kind::Delete)),
|
|
"convertDirection" => Box::new(anchors::AnchorTool::new(anchors::Kind::Convert)),
|
|
"scissors" => Box::new(anchors::AnchorTool::new(anchors::Kind::Scissors)),
|
|
"rotate" => Box::new(xform::XformTool::new("rotate")),
|
|
"scale" => Box::new(xform::XformTool::new("scale")),
|
|
"shear" => Box::new(xform::XformTool::new("shear")),
|
|
"eyedropper" => Box::new(xform::EyedropperTool),
|
|
"hand" => Box::new(nav::HandTool::default()),
|
|
"placeGun" => Box::new(nav::PlaceGun::default()),
|
|
"zoom" => Box::new(nav::ZoomTool::default()),
|
|
"page" => Box::new(nav::PageTool),
|
|
"typeOnPath" => Box::new(nav::PathTypeTool),
|
|
"gap" => Box::new(nav::GapTool::default()),
|
|
"contentCollector" => Box::new(nav::ConveyorTool::new(false)),
|
|
"contentPlacer" => Box::new(nav::ConveyorTool::new(true)),
|
|
other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))),
|
|
}
|
|
}
|
|
|
|
/// Placeholder for tools whose behaviour hasn't landed yet.
|
|
pub struct NoopTool(&'static str);
|
|
|
|
impl Tool for NoopTool {
|
|
fn id(&self) -> &'static str {
|
|
self.0
|
|
}
|
|
fn pointer(&mut self, _cx: &ToolContext, _ev: &PointerEvent) -> Vec<Action> {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
pub(crate) fn rect_json(r: Rect) -> Value {
|
|
serde_json::json!([r.x0, r.y0, r.x1, r.y1])
|
|
}
|
|
|
|
pub(crate) fn spread_json(r: SpreadRef) -> Value {
|
|
serde_json::to_value(r).unwrap_or(Value::Null)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|