441 lines
15 KiB
Rust
441 lines
15 KiB
Rust
//! VectorCraft tools: pointer events in → commands and overlays out.
|
|
//!
|
|
//! Tools never mutate the document directly. They emit [`Action`]s that the engine executes:
|
|
//! `Begin` snapshots the document, each `Preview` re-applies one command on top of that snapshot
|
|
//! (replacing the previous preview), and `Commit` records the last preview as a single undo step
|
|
//! and journal entry. One-shot `Exec` actions run immediately. This makes every gesture replayable
|
|
//! by the control channel and MCP, and keeps tools testable without a UI.
|
|
#![forbid(unsafe_code)]
|
|
|
|
pub mod bbox;
|
|
pub mod builder;
|
|
pub mod catalog;
|
|
pub mod corners;
|
|
pub mod cut;
|
|
pub mod direct;
|
|
pub mod distort;
|
|
pub mod draw2;
|
|
pub mod extra;
|
|
pub mod guides;
|
|
pub mod meshblend;
|
|
pub mod params;
|
|
pub mod pen;
|
|
pub mod place;
|
|
pub mod printtiling;
|
|
pub mod select;
|
|
pub mod settings;
|
|
pub mod shape;
|
|
pub mod slice;
|
|
pub mod symbolism;
|
|
pub mod text;
|
|
pub mod xform;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use vectorcraft_color::Paint;
|
|
use vectorcraft_doc::{Document, NodeId, Selection, Unit};
|
|
use vectorcraft_geom::{BezPath, Point, Rect};
|
|
|
|
pub use catalog::{TOOL_GROUPS, ToolInfo, tool_info};
|
|
|
|
/// Modifier keys.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Mods {
|
|
#[serde(default)]
|
|
pub shift: bool,
|
|
/// Option on macOS.
|
|
#[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 {
|
|
/// Button pressed.
|
|
Down,
|
|
/// Moved with the button held.
|
|
Drag,
|
|
/// Button released.
|
|
Up,
|
|
/// Moved with no button (hover).
|
|
Move,
|
|
DoubleClick,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct PointerEvent {
|
|
pub kind: PointerKind,
|
|
/// Document coordinates.
|
|
pub pos: Point,
|
|
#[serde(default)]
|
|
pub mods: Mods,
|
|
#[serde(default = "one")]
|
|
pub pressure: f32,
|
|
}
|
|
|
|
fn one() -> f32 {
|
|
1.0
|
|
}
|
|
|
|
impl PointerEvent {
|
|
pub fn new(kind: PointerKind, x: f64, y: f64) -> Self {
|
|
Self { kind, pos: Point::new(x, y), mods: Mods::default(), pressure: 1.0 }
|
|
}
|
|
pub fn with_mods(mut self, m: Mods) -> Self {
|
|
self.mods = m;
|
|
self
|
|
}
|
|
/// The pen `pressure` of a pointer event given as JSON (control channel, MCP): 0..1, default 1.
|
|
pub fn json_pressure(e: &Value) -> f32 {
|
|
e.get("pressure").and_then(Value::as_f64).filter(|f| f.is_finite()).map_or(1.0, |f| f.clamp(0.0, 1.0) as f32)
|
|
}
|
|
}
|
|
|
|
/// Keys tools care about.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum ToolKey {
|
|
Enter,
|
|
Escape,
|
|
Backspace,
|
|
Delete,
|
|
Up,
|
|
Down,
|
|
Left,
|
|
Right,
|
|
/// Illustrator: ↑/↓ while drawing a polygon/star change sides/points; `[`/`]` change sizes.
|
|
BracketLeft,
|
|
BracketRight,
|
|
Tab,
|
|
Home,
|
|
End,
|
|
}
|
|
|
|
/// What a tool asks the engine to do.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Action {
|
|
/// Start an interaction (snapshot the document). The label becomes the undo name.
|
|
Begin(String),
|
|
/// Replace the current preview with this command, applied to the snapshot.
|
|
Preview(String, Value),
|
|
/// Finish the interaction: keep the last preview as one undo step.
|
|
Commit,
|
|
/// Abort the interaction: restore the snapshot.
|
|
Cancel,
|
|
/// Execute a command immediately (its own undo step, if it edits).
|
|
Exec(String, Value),
|
|
/// Ask the UI to open a dialog (e.g. click with the Rectangle tool → size dialog).
|
|
Dialog(String, Value),
|
|
/// Switch to another tool (e.g. after placing a text frame).
|
|
SwitchTool(String),
|
|
/// Call the tool's `notify` after the preceding actions ran (e.g. to pick up a created object).
|
|
Notify(String),
|
|
}
|
|
|
|
/// Paint defaults for new art (the fill/stroke proxy) and the rest of the new-art template.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct PaintDefaults {
|
|
pub fill: Paint,
|
|
pub stroke: Paint,
|
|
pub stroke_width: f64,
|
|
/// The appearance new art takes (stroke options, more fills and strokes, effects), its top
|
|
/// fill and stroke repainted with `fill`, `stroke` and `stroke_width`; `None`: one plain fill
|
|
/// and stroke. Its placed gradients are relative to the unit box.
|
|
pub appearance: Option<vectorcraft_doc::Appearance>,
|
|
/// New art's opacity and blend mode.
|
|
pub opacity: f32,
|
|
pub blend: vectorcraft_color::BlendMode,
|
|
/// The graphic style the template came from (by name): new art is linked to it.
|
|
pub style: Option<String>,
|
|
/// The template is the last selection's appearance (New Art Has Basic Appearance off): with
|
|
/// the option on again, new art takes only its basic fill and stroke.
|
|
pub inherited: bool,
|
|
}
|
|
|
|
impl Default for PaintDefaults {
|
|
/// White fill, 1 pt black stroke, nothing else.
|
|
fn default() -> Self {
|
|
Self {
|
|
fill: Paint::solid(vectorcraft_color::Color::WHITE),
|
|
stroke: Paint::solid(vectorcraft_color::Color::BLACK),
|
|
stroke_width: 1.0,
|
|
appearance: None,
|
|
opacity: 1.0,
|
|
blend: Default::default(),
|
|
style: None,
|
|
inherited: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read-only context a tool sees.
|
|
pub struct ToolContext<'a> {
|
|
pub doc: &'a Document,
|
|
pub selection: &'a Selection,
|
|
/// Screen pixels per document point.
|
|
pub zoom: f64,
|
|
pub isolation: Option<NodeId>,
|
|
pub paint: &'a PaintDefaults,
|
|
pub outline: bool,
|
|
pub smart_guides: bool,
|
|
pub snap_to_grid: bool,
|
|
/// Bounding box shown (View → Show/Hide Bounding Box).
|
|
pub show_bbox: bool,
|
|
/// View → Snap to Pixel.
|
|
pub snap_to_pixel: bool,
|
|
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
|
|
pub snap_to_point: bool,
|
|
/// View → Show Corner Widget: live rectangles show draggable Live Corners widgets.
|
|
pub corner_widgets: bool,
|
|
/// Which paint proxy is in front (true = Fill): the one the Gradient tool edits.
|
|
pub fill_active: bool,
|
|
/// The selected gradient stop (`gradient.selectStop`), marked on the gradient annotator.
|
|
pub gradient_stop: Option<usize>,
|
|
/// The Appearance panel's active fill/stroke (paint-order index into the first selected
|
|
/// object's stack); paint tools show and edit that item.
|
|
pub appearance_item: Option<usize>,
|
|
/// General → Constrain Angle (degrees): Shift constrains drags to 45° steps from it.
|
|
pub constrain_angle: f64,
|
|
/// The selected freeform gradient point (`paint.freeform.selectPoint`), marked on the
|
|
/// freeform annotator.
|
|
pub freeform_point: Option<usize>,
|
|
/// Eyedropper Options' raster sample size: the pixels square averaged when the Eyedropper
|
|
/// samples an image (1, 3 or 5).
|
|
pub raster_sample: u32,
|
|
/// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included).
|
|
pub preview_bounds: bool,
|
|
/// Units ▸ General: the unit measurement labels show lengths in.
|
|
pub unit: Unit,
|
|
/// Units ▸ Stroke: the unit measurement labels show stroke widths in.
|
|
pub stroke_unit: Unit,
|
|
/// Clipboard Handling → When pasting text: Keep Plain Text. Text pasted while typing takes
|
|
/// the style at the caret, even the text the Type tool copied with its formatting.
|
|
pub paste_plain_text: bool,
|
|
/// View → Hide Slices: the Slice Selection tool can't pick hidden slices.
|
|
pub slices_hidden: bool,
|
|
/// View → Lock Slices: the Slice Selection tool leaves locked slices alone.
|
|
pub slices_locked: bool,
|
|
}
|
|
|
|
impl ToolContext<'_> {
|
|
/// Tolerance in document units for `px` screen pixels.
|
|
pub fn tol(&self, px: f64) -> f64 {
|
|
px / self.zoom.max(1e-9)
|
|
}
|
|
/// A length as measurement labels show it, in the General unit (`12.50 mm`).
|
|
pub fn len(&self, v: f64) -> String {
|
|
self.unit.readout(v)
|
|
}
|
|
/// A size label: `W: …` over `H: …`.
|
|
pub fn size_label(&self, w: f64, h: f64) -> String {
|
|
format!("W: {}\nH: {}", self.len(w), self.len(h))
|
|
}
|
|
/// A move label: `dX: …` over `dY: …`.
|
|
pub fn offset_label(&self, dx: f64, dy: f64) -> String {
|
|
format!("dX: {}\ndY: {}", self.len(dx), self.len(dy))
|
|
}
|
|
pub fn hit_options(&self) -> vectorcraft_doc::hit::HitOptions {
|
|
vectorcraft_doc::hit::HitOptions { tol: self.tol(3.0), outline: self.outline, path_only: false }
|
|
}
|
|
}
|
|
|
|
/// Visual feedback drawn by the UI in screen space (coordinates here are document space).
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Overlay {
|
|
/// Marquee / rubber band rectangle.
|
|
Marquee(Rect),
|
|
/// A path preview (e.g. pen rubber band) in a colour (RGB).
|
|
Path { path: BezPath, color: [u8; 3], width: f32, dashed: bool },
|
|
/// A line segment (smart guide, handle line).
|
|
Line { a: Point, b: Point, color: [u8; 3], dashed: bool },
|
|
/// An anchor square: filled = selected.
|
|
Anchor { p: Point, color: [u8; 3], filled: bool, size: f32 },
|
|
/// A handle end circle.
|
|
Handle { p: Point, color: [u8; 3] },
|
|
/// Smart-guide style label (e.g. "anchor", "W: 100 pt").
|
|
Label { p: Point, text: String, color: [u8; 3] },
|
|
/// Measurement pill near the cursor (grey box with white text).
|
|
Measure { p: Point, text: String },
|
|
/// Translucent filled quad (text selection highlight), RGBA.
|
|
Highlight { quad: [Point; 4], color: [u8; 4] },
|
|
/// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected.
|
|
Swatch { p: Point, color: [u8; 4], selected: bool },
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Cursor {
|
|
#[default]
|
|
Arrow,
|
|
ArrowHollow,
|
|
Move,
|
|
Crosshair,
|
|
ResizeH,
|
|
ResizeV,
|
|
ResizeNwSe,
|
|
ResizeNeSw,
|
|
Rotate,
|
|
/// Over a Live Corners widget (drag to round the corners).
|
|
CornerRadius,
|
|
Pen,
|
|
PenAdd,
|
|
PenDelete,
|
|
PenClose,
|
|
PenContinue,
|
|
Text,
|
|
Hand,
|
|
HandGrab,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
Eyedropper,
|
|
NotAllowed,
|
|
/// Over the gradient annotator's bar: a click adds a stop.
|
|
AddStop,
|
|
/// A gradient stop dragged off the bar: releasing deletes it.
|
|
RemoveStop,
|
|
/// The Slice tool: a crosshair with a blade.
|
|
Slice,
|
|
/// The Slice Selection tool: the arrow with a slice badge.
|
|
SliceSelect,
|
|
}
|
|
|
|
/// A tool state machine.
|
|
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
|
|
}
|
|
/// Tool options as JSON (shown by the Control bar / tool options dialog).
|
|
fn options(&self) -> Value {
|
|
Value::Null
|
|
}
|
|
fn set_option(&mut self, _key: &str, _value: &Value) {}
|
|
/// Is an interaction in progress (drag, open pen path)?
|
|
fn busy(&self) -> bool {
|
|
false
|
|
}
|
|
/// Does the tool take `key` now, ahead of the command shortcuts bound to it (the Gradient tool
|
|
/// with a stop selected takes Delete and the arrows)?
|
|
fn claims_key(&self, _cx: &ToolContext, _key: ToolKey) -> bool {
|
|
false
|
|
}
|
|
/// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed.
|
|
fn wants_text(&self) -> bool {
|
|
false
|
|
}
|
|
fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec<Action> {
|
|
vec![]
|
|
}
|
|
fn notify(&mut self, _cx: &ToolContext, _what: &str) {}
|
|
/// Called when the user switches away (finish pending work).
|
|
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
|
vec![]
|
|
}
|
|
/// A command from outside the tool ran (a menu, a panel, an agent): finish work it ended (the
|
|
/// Type tool stops editing text the command took out of the selection).
|
|
fn after_command(&mut self, _cx: &ToolContext) -> Vec<Action> {
|
|
vec![]
|
|
}
|
|
}
|
|
|
|
/// Create a tool by id. Unknown or not-yet-implemented tools fall back to a no-op tool that keeps the id.
|
|
pub fn create(id: &str) -> Box<dyn Tool> {
|
|
match id {
|
|
"selection" => Box::new(select::SelectionTool::default()),
|
|
"directSelection" => Box::new(direct::DirectSelectionTool::new(false)),
|
|
"groupSelection" => Box::new(direct::DirectSelectionTool::new(true)),
|
|
"pen" => Box::new(pen::PenTool::default()),
|
|
"type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)),
|
|
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
|
|
// Not in the toolbar: `file.place.queue` loads it.
|
|
"place" => Box::new(place::PlaceTool::default()),
|
|
other => symbolism::create(other)
|
|
.or_else(|| builder::create(other))
|
|
.or_else(|| draw2::create(other))
|
|
.or_else(|| xform::create(other))
|
|
.or_else(|| meshblend::create(other))
|
|
.or_else(|| distort::create(other))
|
|
.or_else(|| extra::create(other))
|
|
.or_else(|| slice::create(other))
|
|
.or_else(|| printtiling::create(other))
|
|
.or_else(|| cut::create(other))
|
|
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
|
|
}
|
|
}
|
|
|
|
/// Placeholder for tools whose behaviour hasn't landed yet (selecting them still works).
|
|
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 json_ids(ids: &[NodeId]) -> Value {
|
|
Value::Array(ids.iter().map(|i| Value::from(i.0)).collect())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod testutil {
|
|
use super::*;
|
|
use vectorcraft_doc::{Appearance, Node};
|
|
use vectorcraft_geom::shapes;
|
|
|
|
pub fn doc_with_rect() -> (Document, NodeId) {
|
|
let mut d = Document::new(500.0, 500.0);
|
|
let l = d.layers[0].id;
|
|
let id = d.alloc_id();
|
|
d.insert(Some(l), 0, Node::path(id, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)), Appearance::default_art())).unwrap();
|
|
(d, id)
|
|
}
|
|
|
|
pub fn paint() -> PaintDefaults {
|
|
PaintDefaults::default()
|
|
}
|
|
|
|
pub fn cx<'a>(d: &'a Document, s: &'a Selection, p: &'a PaintDefaults) -> ToolContext<'a> {
|
|
ToolContext {
|
|
doc: d,
|
|
selection: s,
|
|
zoom: 1.0,
|
|
isolation: None,
|
|
paint: p,
|
|
outline: false,
|
|
smart_guides: true,
|
|
snap_to_grid: false,
|
|
show_bbox: true,
|
|
snap_to_pixel: false,
|
|
snap_to_point: true,
|
|
corner_widgets: true,
|
|
fill_active: true,
|
|
gradient_stop: None,
|
|
appearance_item: None,
|
|
constrain_angle: 0.0,
|
|
freeform_point: None,
|
|
raster_sample: 1,
|
|
preview_bounds: false,
|
|
unit: Unit::Points,
|
|
stroke_unit: Unit::Points,
|
|
paste_plain_text: false,
|
|
slices_hidden: false,
|
|
slices_locked: false,
|
|
}
|
|
}
|
|
}
|