From c474a3eb3384c4042eb87060469ec495c814313c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 07:27:27 -0400 Subject: [PATCH] =?UTF-8?q?Canvas=20=C2=B7=20Tools:=20select,=20move=20and?= =?UTF-8?q?=20snap,=20resize,=20draw,=20pen,=20text,=20brush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ToolLayer lies over an InfiniteCanvas and carries its tools. Select takes the topmost shape a press reaches, Shift adds or drops one, a drag moves the selection and snaps its nearest edge or middle to another shape's within reach, a handle resizes it, Shift keeping a corner's shape, and a drag on empty space draws a SelectionBox that takes what lies wholly inside. The shape tools draw by a drag toward any corner, Shift keeping them square; the pen adds a point per press and a double press ends the path; text drops a line where pressed; the brush strokes a path its width. A drag may end in the layer or out of it; each gesture ends once and asks the owner. SelectionBox, TransformHandles and SnapIndicator draw the marks. ToolPalette picks a tool from icons named in tips; BrushSettings sets a brush's width and hue; HistoryPanel lists the steps done, the undone ones quiet, and takes the drawing to a step on Enter or a double press. The model changes: lines and arrows end at a named corner, so they run either way, and paths carry their width. Three Lucide icons: hexagon, slash and move-up-right. --- TASKS.md | 3 +- assets/icons/hexagon.svg | 15 + assets/icons/move-up-right.svg | 16 + assets/icons/slash.svg | 15 + examples/gallery/pages/canvas/mod.rs | 21 +- examples/gallery/pages/canvas/plane.rs | 24 +- examples/gallery/pages/canvas/tools.rs | 195 ++++++++++++ src/canvas/edit.rs | 334 ++++++++++++++++++++ src/canvas/gesture.rs | 193 ++++++++++++ src/canvas/history.rs | 85 ++++++ src/canvas/marks.rs | 127 ++++++++ src/canvas/mod.rs | 14 +- src/canvas/palette.rs | 166 ++++++++++ src/canvas/plane.rs | 11 +- src/canvas/shape.rs | 45 ++- src/canvas/{tests.rs => tests/mod.rs} | 17 +- src/canvas/tests/tools.rs | 136 +++++++++ src/canvas/tools.rs | 408 +++++++++++++++++++++++++ src/primitives/icon.rs | 3 + tasks/ch31-43.md | 18 +- 20 files changed, 1812 insertions(+), 34 deletions(-) create mode 100644 assets/icons/hexagon.svg create mode 100644 assets/icons/move-up-right.svg create mode 100644 assets/icons/slash.svg create mode 100644 examples/gallery/pages/canvas/tools.rs create mode 100644 src/canvas/edit.rs create mode 100644 src/canvas/gesture.rs create mode 100644 src/canvas/history.rs create mode 100644 src/canvas/marks.rs create mode 100644 src/canvas/palette.rs rename src/canvas/{tests.rs => tests/mod.rs} (93%) create mode 100644 src/canvas/tests/tools.rs create mode 100644 src/canvas/tools.rs diff --git a/TASKS.md b/TASKS.md index 3beb899..639043d 100644 --- a/TASKS.md +++ b/TASKS.md @@ -115,7 +115,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T31b Project · Issues and boards - [x] T31c Project · Plans - [x] T31d Project · Time and databases -- [ ] T32a Canvas · Plane — `tasks/ch31-43.md` +- [x] T32a Canvas · Plane — `tasks/ch31-43.md` - [ ] T32b Canvas · Tools - [ ] T32c Canvas · Panels - [ ] T32d Canvas · Graphs and boards @@ -374,3 +374,4 @@ Per-component lines live in `tasks/`. Tags there: | T31d | 1 | FAIL | a focused segment past the strip's fold stayed hidden; the tracker lost focus as Start and Stop swapped; Stop and Skip went untested; percent did not refuse content | | T31d | 2 | PASS | | | T32a | 1 | FAIL | the plain wheel panned against every scroll box; the handlers panned from the render's viewport, so events within a frame overwrote each other; the fit's cap at one went untested | +| T32a | 2 | PASS | | diff --git a/assets/icons/hexagon.svg b/assets/icons/hexagon.svg new file mode 100644 index 0000000..576b930 --- /dev/null +++ b/assets/icons/hexagon.svg @@ -0,0 +1,15 @@ + + + + diff --git a/assets/icons/move-up-right.svg b/assets/icons/move-up-right.svg new file mode 100644 index 0000000..ce85e98 --- /dev/null +++ b/assets/icons/move-up-right.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/slash.svg b/assets/icons/slash.svg new file mode 100644 index 0000000..63e5a38 --- /dev/null +++ b/assets/icons/slash.svg @@ -0,0 +1,15 @@ + + + + diff --git a/examples/gallery/pages/canvas/mod.rs b/examples/gallery/pages/canvas/mod.rs index 0517ecb..7fe2f0e 100644 --- a/examples/gallery/pages/canvas/mod.rs +++ b/examples/gallery/pages/canvas/mod.rs @@ -1,6 +1,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*}; mod plane; +mod tools; use super::Page; use crate::script::Step; @@ -20,8 +21,26 @@ const SCRIPT: &[Step] = &[ Step::UpAt("canvas-plane", 300.0, 240.0), Step::Wait(200), Step::Shot("panned"), + Step::DownAt("canvas-tools", 150.0, 110.0), + Step::DragTo("canvas-tools", 170.0, 112.0), + Step::DragTo("canvas-tools", 187.0, 112.0), + Step::Wait(200), + Step::Shot("snapping"), + Step::UpAt("canvas-tools", 187.0, 112.0), + Step::Wait(200), + Step::DownAt("canvas-palette", 83.0, 16.0), + Step::UpAt("canvas-palette", 83.0, 16.0), + Step::Wait(150), + Step::DownAt("canvas-tools", 60.0, 230.0), + Step::DragTo("canvas-tools", 200.0, 300.0), + Step::UpAt("canvas-tools", 200.0, 300.0), + Step::Wait(250), + Step::Shot("drawn"), ]; fn render(window: &mut Window, cx: &mut App) -> AnyElement { - div().child(plane::plane(window, cx)).into_any_element() + div() + .child(plane::plane(window, cx)) + .child(tools::tools(window, cx)) + .into_any_element() } diff --git a/examples/gallery/pages/canvas/plane.rs b/examples/gallery/pages/canvas/plane.rs index c49dca3..77b46b0 100644 --- a/examples/gallery/pages/canvas/plane.rs +++ b/examples/gallery/pages/canvas/plane.rs @@ -1,6 +1,7 @@ use ely_gpui_component::{ canvas::{ - Artboard, Frame, Guide, InfiniteCanvas, MiniMap, Shape, ShapeKind, Viewport, ZoomControls, + Artboard, Corner, Frame, Guide, InfiniteCanvas, MiniMap, Shape, ShapeKind, Viewport, + ZoomControls, }, theme::{ActiveTheme, Radius}, }; @@ -57,27 +58,30 @@ fn shapes() -> Vec { Shape::new( "rule", "Rule", - ShapeKind::Line, + ShapeKind::Line(Corner::BottomRight), Frame::new(24.0, 380.0, 342.0, 0.0), ) .hue(4), Shape::new( "flow", "Flow", - ShapeKind::Arrow, + ShapeKind::Arrow(Corner::BottomRight), Frame::new(390.0, 120.0, 80.0, 0.0), ) .hue(3), Shape::new( "sketch", "Sketch", - ShapeKind::Path(vec![ - (0.0, 40.0), - (40.0, 0.0), - (80.0, 30.0), - (120.0, 10.0), - (160.0, 50.0), - ]), + ShapeKind::Path { + points: vec![ + (0.0, 40.0), + (40.0, 0.0), + (80.0, 30.0), + (120.0, 10.0), + (160.0, 50.0), + ], + width: 2.0, + }, Frame::new(494.0, 60.0, 160.0, 50.0), ) .hue(1), diff --git a/examples/gallery/pages/canvas/tools.rs b/examples/gallery/pages/canvas/tools.rs new file mode 100644 index 0000000..768e848 --- /dev/null +++ b/examples/gallery/pages/canvas/tools.rs @@ -0,0 +1,195 @@ +use ely_gpui_component::{ + canvas::{ + Brush, BrushSettings, Frame, HistoryPanel, InfiniteCanvas, Shape, ShapeKind, Tool, + ToolLayer, ToolPalette, Viewport, + }, + theme::{ActiveTheme, Radius}, +}; +use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The tools demo's drawing: its shapes, what is selected, the tool and brush, the view, and each step with the shapes after it. +struct Studio { + shapes: Vec, + selected: Vec, + tool: Tool, + brush: Brush, + view: Viewport, + steps: Vec<(SharedString, Vec)>, + at: usize, + made: usize, +} + +impl Studio { + fn new() -> Self { + let shapes = vec![ + Shape::new( + "card", + "Card", + ShapeKind::Rect, + Frame::new(40.0, 40.0, 220.0, 140.0), + ) + .hue(0), + Shape::new( + "badge", + "Badge", + ShapeKind::Ellipse, + Frame::new(300.0, 60.0, 90.0, 90.0), + ) + .hue(5), + Shape::new( + "label", + "Label", + ShapeKind::Text("Drag me".into()), + Frame::new(60.0, 210.0, 160.0, 32.0), + ), + ]; + Self { + shapes: shapes.clone(), + selected: vec!["card".into()], + tool: Tool::Select, + brush: Brush { size: 4.0, hue: 3 }, + view: Viewport::new(0.0, 0.0, 1.0), + steps: vec![("Open the drawing".into(), shapes)], + at: 1, + made: 0, + } + } + + /// Keeps the shapes as a new step, dropping any steps undone. + fn record(&mut self, words: String) { + self.steps.truncate(self.at); + self.steps.push((words.into(), self.shapes.clone())); + self.at = self.steps.len(); + } +} + +fn studio(window: &mut Window, cx: &mut App) -> Entity { + keep("canvas-studio", Studio::new, window, cx) +} + +pub fn tools(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let studio = studio(window, cx); + let now = studio.read(cx); + let (tool, view, brush, at) = (now.tool, now.view, now.brush, now.at); + let (shapes, selected) = (now.shapes.clone(), now.selected.clone()); + let steps: Vec = now.steps.iter().map(|(words, _)| words.clone()).collect(); + let [ + picked, + viewed, + chosen, + drawn, + moved, + sized, + brushed, + travelled, + ] = [(); 8].map(|_| studio.clone()); + let layer = ToolLayer::new("canvas-tools-layer", tool, view, shapes.clone()) + .selected(selected) + .brush(brush) + .on_select(move |keys, _, cx| change(&chosen, cx, |studio| studio.selected = keys.to_vec())) + .on_draw(move |kind, frame, _, cx| { + change(&drawn, cx, |studio| { + studio.made += 1; + let key = SharedString::from(format!("made-{}", studio.made)); + let hue = match kind { + ShapeKind::Path { .. } => studio.brush.hue, + _ => studio.made % 8, + }; + let words = format!( + "Draw {}", + match &kind { + ShapeKind::Path { .. } => "a stroke", + ShapeKind::Text(_) => "text", + _ => "a shape", + } + ); + studio + .shapes + .push(Shape::new(key.clone(), "Drawn", kind, frame).hue(hue)); + studio.selected = vec![key]; + studio.record(words); + }) + }) + .on_move(move |keys, (dx, dy), _, cx| { + change(&moved, cx, |studio| { + studio + .shapes + .iter_mut() + .filter(|shape| keys.contains(&shape.key)) + .for_each(|shape| { + shape.frame.x += dx; + shape.frame.y += dy; + }); + studio.record(format!("Move {}", keys.len())); + }) + }) + .on_resize(move |key, frame, _, cx| { + change(&sized, cx, |studio| { + let shape = studio + .shapes + .iter_mut() + .find(|shape| shape.key == *key) + .expect("a listed shape"); + shape.frame = frame; + studio.record(format!("Resize {key}")); + }) + }); + let canvas = InfiniteCanvas::new("canvas-tools", view) + .shapes(shapes) + .on_viewport(move |next, _, cx| change(&viewed, cx, |studio| studio.view = next)) + .layer(layer); + let theme = cx.theme(); + section( + "ToolPalette · SelectionBox · TransformHandles · SnapIndicator · ShapeTools · PenTool / PathEditor · TextTool · BrushTool / BrushSettings · HistoryPanel", + "Tools at work on the plane. Select presses a shape, Shift adds, a drag moves it and snaps its edges to the others, a handle resizes it, and a drag on empty space draws a marquee. The shape tools draw by a drag, Shift keeping them square; the pen adds a point per press and a double press ends the path; text drops a line; the brush strokes. Each change is a step in the history, which a double press or Enter takes back to.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_4() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(probe( + "canvas-palette", + ToolPalette::new("canvas-palette", tool) + .on_change(move |tool, _, cx| change(&picked, cx, |studio| studio.tool = tool)), + )) + .child(probe( + "canvas-tools", + div() + .w(px(560.)) + .h(px(360.)) + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .child(canvas), + )), + ) + .child( + div() + .w(px(260.)) + .flex() + .flex_col() + .gap_4() + .child(BrushSettings::new("canvas-brush", brush).on_change(move |brush, _, cx| change(&brushed, cx, |studio| studio.brush = brush))) + .child(HistoryPanel::new("canvas-history", steps, at).on_step(move |at, _, cx| { + change(&travelled, cx, |studio| { + studio.at = at; + studio.shapes = studio.steps[at - 1].1.clone(); + studio.selected.clear(); + }) + })), + ), + ) +} diff --git a/src/canvas/edit.rs b/src/canvas/edit.rs new file mode 100644 index 0000000..82ed044 --- /dev/null +++ b/src/canvas/edit.rs @@ -0,0 +1,334 @@ +use gpui::SharedString; + +use super::{ + shape::{Corner, Guide, Shape, ShapeKind}, + view::Frame, +}; + +/// What the pointer does on a canvas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tool { + Select, + Hand, + Rect, + Ellipse, + Polygon, + Line, + Arrow, + Pen, + Text, + Brush, +} + +impl Tool { + pub const ALL: [Tool; 10] = [ + Tool::Select, + Tool::Hand, + Tool::Rect, + Tool::Ellipse, + Tool::Polygon, + Tool::Line, + Tool::Arrow, + Tool::Pen, + Tool::Text, + Tool::Brush, + ]; + + pub fn words(self) -> &'static str { + match self { + Tool::Select => "Select", + Tool::Hand => "Hand", + Tool::Rect => "Rectangle", + Tool::Ellipse => "Ellipse", + Tool::Polygon => "Polygon", + Tool::Line => "Line", + Tool::Arrow => "Arrow", + Tool::Pen => "Pen", + Tool::Text => "Text", + Tool::Brush => "Brush", + } + } + + /// The letter that picks it. + pub fn key(self) -> &'static str { + match self { + Tool::Select => "v", + Tool::Hand => "h", + Tool::Rect => "r", + Tool::Ellipse => "o", + Tool::Polygon => "y", + Tool::Line => "l", + Tool::Arrow => "a", + Tool::Pen => "p", + Tool::Text => "t", + Tool::Brush => "b", + } + } + + /// The kind of shape a drag with it draws, if it draws one. + pub(crate) fn draws(self) -> Option { + match self { + Tool::Rect => Some(ShapeKind::Rect), + Tool::Ellipse => Some(ShapeKind::Ellipse), + Tool::Polygon => Some(ShapeKind::Polygon(6)), + Tool::Line => Some(ShapeKind::Line(Corner::BottomRight)), + Tool::Arrow => Some(ShapeKind::Arrow(Corner::BottomRight)), + _ => None, + } + } +} + +/// A handle on a selection's frame, by the side or corner it holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Handle { + North, + NorthEast, + East, + SouthEast, + South, + SouthWest, + West, + NorthWest, +} + +impl Handle { + pub const ALL: [Handle; 8] = [ + Handle::North, + Handle::NorthEast, + Handle::East, + Handle::SouthEast, + Handle::South, + Handle::SouthWest, + Handle::West, + Handle::NorthWest, + ]; + + /// Where it sits on `frame`, as shares of its width and height. + pub(crate) fn at(self) -> (f32, f32) { + match self { + Handle::North => (0.5, 0.0), + Handle::NorthEast => (1.0, 0.0), + Handle::East => (1.0, 0.5), + Handle::SouthEast => (1.0, 1.0), + Handle::South => (0.5, 1.0), + Handle::SouthWest => (0.0, 1.0), + Handle::West => (0.0, 0.5), + Handle::NorthWest => (0.0, 0.0), + } + } +} + +/// The topmost shape at a canvas point that is shown and not locked, by its place. +pub(crate) fn hit(shapes: &[Shape], at: (f32, f32), reach: f32) -> Option { + shapes.iter().rposition(|shape| { + let frame = &shape.frame; + let grown = Frame::new( + frame.x - reach, + frame.y - reach, + frame.w + reach * 2.0, + frame.h + reach * 2.0, + ); + !shape.hidden && !shape.locked && grown.contains(at) + }) +} + +/// The shapes wholly inside a marquee, shown and not locked. +pub(crate) fn inside(shapes: &[Shape], marquee: &Frame) -> Vec { + shapes + .iter() + .filter(|shape| !shape.hidden && !shape.locked && shape.frame.within(marquee)) + .map(|shape| shape.key.clone()) + .collect() +} + +/// `frame` with `handle` moved by a canvas distance, never smaller than `least`; with `square`, a corner keeps the frame's shape. +pub(crate) fn resized( + frame: Frame, + handle: Handle, + (dx, dy): (f32, f32), + least: f32, + square: bool, +) -> Frame { + let (sx, sy) = handle.at(); + let (mut left, mut top, mut right, mut bottom) = + (frame.x, frame.y, frame.right(), frame.bottom()); + match sx { + 0.0 => left = (left + dx).min(right - least), + 1.0 => right = (right + dx).max(left + least), + _ => {} + } + match sy { + 0.0 => top = (top + dy).min(bottom - least), + 1.0 => bottom = (bottom + dy).max(top + least), + _ => {} + } + let mut next = Frame::new(left, top, right - left, bottom - top); + if square && sx != 0.5 && sy != 0.5 { + let ratio = frame.w / frame.h.max(least); + let h = next.w / ratio; + if sy == 0.0 { + next.y = next.bottom() - h; + } + next.h = h; + } + next +} + +/// How far to nudge a moving frame so an edge or its middle meets another frame's within `reach`, and the lines it meets. +pub(crate) fn snapped(moving: &Frame, others: &[Frame], reach: f32) -> ((f32, f32), Vec) { + let lines = |frame: &Frame| { + ( + [frame.x, frame.x + frame.w / 2.0, frame.right()], + [frame.y, frame.y + frame.h / 2.0, frame.bottom()], + ) + }; + let (xs, ys) = lines(moving); + let best = |mine: [f32; 3], theirs: Vec| { + mine.iter() + .flat_map(|at| theirs.iter().map(move |line| (line - at, *line))) + .filter(|(gap, _)| gap.abs() <= reach) + .min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())) + }; + let across = best(xs, others.iter().flat_map(|frame| lines(frame).0).collect()); + let down = best(ys, others.iter().flat_map(|frame| lines(frame).1).collect()); + let mut guides = Vec::new(); + if let Some((_, x)) = across { + guides.push(Guide::Vertical(x)); + } + if let Some((_, y)) = down { + guides.push(Guide::Horizontal(y)); + } + ( + ( + across.map_or(0.0, |(gap, _)| gap), + down.map_or(0.0, |(gap, _)| gap), + ), + guides, + ) +} + +/// The shape a drag from `from` to `to` draws with a tool, its frame spanning both; `square` keeps it as wide as tall. +pub(crate) fn drawn( + kind: ShapeKind, + from: (f32, f32), + to: (f32, f32), + square: bool, +) -> (ShapeKind, Frame) { + let to = match square { + true => { + let side = (to.0 - from.0).abs().max((to.1 - from.1).abs()); + ( + from.0 + side.copysign(to.0 - from.0), + from.1 + side.copysign(to.1 - from.1), + ) + } + false => to, + }; + let end = match (to.0 >= from.0, to.1 >= from.1) { + (true, true) => Corner::BottomRight, + (true, false) => Corner::TopRight, + (false, true) => Corner::BottomLeft, + (false, false) => Corner::TopLeft, + }; + let kind = match kind { + ShapeKind::Line(_) => ShapeKind::Line(end), + ShapeKind::Arrow(_) => ShapeKind::Arrow(end), + other => other, + }; + (kind, Frame::spanning(from, to)) +} + +#[cfg(test)] +mod tests { + use super::{Handle, drawn, hit, inside, resized, snapped}; + use crate::canvas::{Corner, Frame, Guide, Shape, ShapeKind}; + + fn shapes() -> Vec { + vec![ + Shape::new( + "a", + "A", + ShapeKind::Rect, + Frame::new(0.0, 0.0, 100.0, 100.0), + ), + Shape::new( + "b", + "B", + ShapeKind::Rect, + Frame::new(50.0, 50.0, 100.0, 100.0), + ), + Shape::new( + "c", + "C", + ShapeKind::Rect, + Frame::new(300.0, 0.0, 50.0, 50.0), + ) + .locked(true), + ] + } + + #[test] + fn a_press_takes_the_topmost_shape_it_reaches_and_skips_the_locked() { + assert_eq!(hit(&shapes(), (60.0, 60.0), 0.0), Some(1), "b lies over a"); + assert_eq!( + hit(&shapes(), (-3.0, 10.0), 4.0), + Some(0), + "within reach of a's edge" + ); + assert_eq!(hit(&shapes(), (320.0, 20.0), 0.0), None, "c is locked"); + } + + #[test] + fn a_marquee_takes_what_lies_wholly_inside() { + let keys = inside(&shapes(), &Frame::new(-10.0, -10.0, 400.0, 130.0)); + assert_eq!(keys, ["a"], "b runs past the bottom and c is locked"); + } + + #[test] + fn a_handle_moves_its_sides_and_a_square_corner_keeps_the_shape() { + let frame = Frame::new(0.0, 0.0, 100.0, 50.0); + assert_eq!( + resized(frame, Handle::East, (20.0, 99.0), 2.0, false), + Frame::new(0.0, 0.0, 120.0, 50.0) + ); + assert_eq!( + resized(frame, Handle::NorthWest, (10.0, 10.0), 2.0, false), + Frame::new(10.0, 10.0, 90.0, 40.0) + ); + assert_eq!( + resized(frame, Handle::West, (500.0, 0.0), 2.0, false), + Frame::new(98.0, 0.0, 2.0, 50.0), + "never below the least" + ); + assert_eq!( + resized(frame, Handle::SouthEast, (100.0, 0.0), 2.0, true), + Frame::new(0.0, 0.0, 200.0, 100.0) + ); + } + + #[test] + fn a_moving_frame_snaps_its_nearest_line_within_reach() { + let others = [Frame::new(200.0, 0.0, 100.0, 100.0)]; + let (nudge, guides) = snapped(&Frame::new(97.0, 150.0, 100.0, 30.0), &others, 5.0); + assert_eq!(nudge, (3.0, 0.0), "its right edge meets the other's left"); + assert_eq!(guides, [Guide::Vertical(200.0)]); + assert_eq!( + snapped(&Frame::new(0.0, 300.0, 10.0, 10.0), &others, 5.0).0, + (0.0, 0.0) + ); + } + + #[test] + fn a_drag_draws_toward_its_corner_and_shift_keeps_it_square() { + let (kind, frame) = drawn( + ShapeKind::Arrow(Corner::BottomRight), + (100.0, 100.0), + (40.0, 160.0), + false, + ); + assert_eq!(kind, ShapeKind::Arrow(Corner::BottomLeft)); + assert_eq!(frame, Frame::new(40.0, 100.0, 60.0, 60.0)); + let (_, square) = drawn(ShapeKind::Rect, (0.0, 0.0), (30.0, -80.0), true); + assert_eq!(square, Frame::new(0.0, -80.0, 80.0, 80.0)); + } +} diff --git a/src/canvas/gesture.rs b/src/canvas/gesture.rs new file mode 100644 index 0000000..5e5b51b --- /dev/null +++ b/src/canvas/gesture.rs @@ -0,0 +1,193 @@ +use std::rc::Rc; + +use gpui::{App, Bounds, Entity, Pixels, SharedString, Window}; + +use super::{ + edit::{Handle, drawn, inside, resized, snapped}, + shape::{Shape, ShapeKind}, + view::{Frame, Viewport}, +}; + +pub(super) type OnKeys = Rc; +pub(super) type OnDraw = Rc; +pub(super) type OnMove = Rc; +pub(super) type OnResize = Rc; + +/// A brush's width in canvas units and its hue among the chart colors. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Brush { + pub size: f32, + pub hue: usize, +} + +/// What a press set going, with its canvas points. +#[derive(Clone, Debug, Default, PartialEq)] +pub(super) enum Gesture { + #[default] + Idle, + Marquee { + from: (f32, f32), + to: (f32, f32), + }, + Moving { + from: (f32, f32), + to: (f32, f32), + }, + Resizing { + key: SharedString, + handle: Handle, + from: (f32, f32), + to: (f32, f32), + }, + Drawing { + kind: ShapeKind, + from: (f32, f32), + to: (f32, f32), + }, + Stroke { + points: Vec<(f32, f32)>, + }, +} + +/// The layer's own state: the gesture under way, whether Shift is held, the box it covers, and the pen's points so far. +#[derive(Default)] +pub(super) struct Hand { + pub(super) gesture: Gesture, + pub(super) square: bool, + pub(super) bounds: Bounds, + pub(super) pen: Vec<(f32, f32)>, +} + +/// Shapes a small nudge still counts as a click by, in view pixels. +pub(super) const REACH: f32 = 4.0; + +/// The least a drawn or resized shape spans, in canvas units. +pub(super) const LEAST: f32 = 2.0; + +/// A pen path's width, in canvas units. +pub(super) const PEN: f32 = 2.0; + +/// The frame the selected shapes span together. +pub(super) fn spanned(shapes: &[Shape], selected: &[SharedString]) -> Option { + shapes + .iter() + .filter(|shape| selected.contains(&shape.key)) + .map(|shape| shape.frame) + .reduce(|whole, frame| whole.union(&frame)) +} + +/// A path through canvas points, `width` wide: its kind and its frame, the points counted from the frame's corner. +pub(super) fn path(points: &[(f32, f32)], width: f32) -> (ShapeKind, Frame) { + let frame = points + .iter() + .map(|(x, y)| Frame::new(*x, *y, 0.0, 0.0)) + .reduce(|whole, frame| whole.union(&frame)) + .expect("a path has points"); + let points = points + .iter() + .map(|(x, y)| (x - frame.x, y - frame.y)) + .collect(); + (ShapeKind::Path { points, width }, frame) +} + +pub(super) fn stroke_shape(points: &[(f32, f32)], width: f32, hue: usize) -> Shape { + let (kind, frame) = path(points, width); + Shape::new("+stroke", "Stroke", kind, frame).hue(hue) +} + +/// The gesture under way, taken so it ends once, with whether Shift was held. +pub(super) fn taken(hand: &Entity, cx: &mut App) -> (Gesture, bool) { + hand.update(cx, |hand, cx| { + cx.notify(); + (std::mem::take(&mut hand.gesture), hand.square) + }) +} + +/// The owner's handlers for what a gesture ends in. +pub(super) struct Handlers { + pub(super) on_select: Option, + pub(super) on_draw: Option, + pub(super) on_move: Option, + pub(super) on_resize: Option, +} + +/// What a gesture ends against: the shapes, the selection, the frames to snap to, and the view. +pub(super) struct Scene { + pub(super) shapes: Rc>, + pub(super) selected: Rc>, + pub(super) others: Vec, + pub(super) view: Viewport, +} + +/// Hands a finished gesture to the owner. +pub(super) fn commit( + gesture: Gesture, + square: bool, + scene: &Scene, + handlers: &Handlers, + brush: Brush, + window: &mut Window, + cx: &mut App, +) { + match gesture { + Gesture::Marquee { from, to } => { + let keys = inside(&scene.shapes, &Frame::spanning(from, to)); + log::info!("tool layer: marquee takes {keys:?}"); + if let Some(on_select) = &handlers.on_select { + on_select(&keys, window, cx); + } + } + Gesture::Moving { from, to } => { + let Some(whole) = spanned(&scene.shapes, &scene.selected) else { + return; + }; + let raw = (to.0 - from.0, to.1 - from.1); + let moved = Frame::new(whole.x + raw.0, whole.y + raw.1, whole.w, whole.h); + let (nudge, _) = snapped(&moved, &scene.others, REACH / scene.view.zoom); + let by = (raw.0 + nudge.0, raw.1 + nudge.1); + if by == (0.0, 0.0) { + return; + } + log::info!("tool layer: move {:?} by {by:?}", scene.selected); + if let Some(on_move) = &handlers.on_move { + on_move(&scene.selected, by, window, cx); + } + } + Gesture::Resizing { + key, + handle, + from, + to, + } => { + let frame = scene + .shapes + .iter() + .find(|shape| shape.key == key) + .expect("the resized shape is listed") + .frame; + let next = resized(frame, handle, (to.0 - from.0, to.1 - from.1), LEAST, square); + log::info!("tool layer: resize {key} to {next:?}"); + if let Some(on_resize) = &handlers.on_resize { + on_resize(&key, next, window, cx); + } + } + Gesture::Drawing { kind, from, to } => { + let (kind, frame) = drawn(kind, from, to, square); + if frame.w.max(frame.h) < LEAST { + return; + } + log::info!("tool layer: draw {kind:?} at {frame:?}"); + if let Some(on_draw) = &handlers.on_draw { + on_draw(kind, frame, window, cx); + } + } + Gesture::Stroke { points } if points.len() > 1 => { + let (kind, frame) = path(&points, brush.size); + log::info!("tool layer: a stroke of {} points", points.len()); + if let Some(on_draw) = &handlers.on_draw { + on_draw(kind, frame, window, cx); + } + } + _ => {} + } +} diff --git a/src/canvas/history.rs b/src/canvas/history.rs new file mode 100644 index 0000000..37a7fe1 --- /dev/null +++ b/src/canvas/history.rs @@ -0,0 +1,85 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Window, div}; + +use crate::{ + lists::{ListItem, SelectableList}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize}, +}; + +type OnStep = Rc; + +/// What was done, oldest first, with the document at step `at`: the steps after it were undone and read quiet. Enter or a double press on a step takes the document back or forward to it. +#[derive(IntoElement)] +pub struct HistoryPanel { + id: ElementId, + steps: Vec, + at: usize, + on_step: Option, +} + +impl HistoryPanel { + /// `at` counts the steps in force; none undone is `steps.len()`. + pub fn new( + id: impl Into, + steps: impl IntoIterator>, + at: usize, + ) -> Self { + let steps: Vec = steps.into_iter().map(Into::into).collect(); + assert!( + at <= steps.len(), + "history at {at} of {} steps", + steps.len() + ); + Self { + id: id.into(), + steps, + at, + on_step: None, + } + } + + /// Gets how many steps to keep in force. + pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for HistoryPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let at = self.at; + let rows = self.steps.into_iter().enumerate().fold( + SelectableList::new((self.id.clone(), "steps")), + |list, (ix, step)| { + let done = ix < at; + let mark = Icon::new(if done { + IconName::Check + } else { + IconName::RotateCcw + }) + .size(IconSize::Sm) + .color(if done { + theme.colors.fg_muted + } else { + theme.colors.fg_subtle + }); + let row = ListItem::new((self.id.clone(), format!("step-{ix}")), step) + .leading(mark) + .quiet(!done) + .current(ix + 1 == at); + list.row(format!("{ix}"), row) + }, + ); + let on_step = self.on_step; + div().child(rows.on_activate(move |key, window, cx| { + let ix: usize = key.parse().expect("a step's key is its place"); + log::info!("history panel: back to step {}", ix + 1); + if let Some(on_step) = &on_step { + on_step(ix + 1, window, cx); + } + })) + } +} diff --git a/src/canvas/marks.rs b/src/canvas/marks.rs new file mode 100644 index 0000000..661729c --- /dev/null +++ b/src/canvas/marks.rs @@ -0,0 +1,127 @@ +use gpui::{App, Hsla, IntoElement, ParentElement, Pixels, RenderOnce, Styled, Window, div}; + +use super::{ + edit::Handle, + shape::Guide, + view::{Frame, Viewport}, +}; +use crate::theme::ActiveTheme; + +/// A box in view pixels as an absolute element. +fn placed(frame: &Frame) -> gpui::Div { + div() + .absolute() + .left(Pixels::from(frame.x)) + .top(Pixels::from(frame.y)) + .w(Pixels::from(frame.w)) + .h(Pixels::from(frame.h)) +} + +/// The marquee a drag on empty canvas draws, in view pixels: a faint wash in the accent and its edge. +#[derive(IntoElement)] +pub struct SelectionBox { + frame: Frame, +} + +impl SelectionBox { + pub fn new(frame: Frame) -> Self { + Self { frame } + } +} + +impl RenderOnce for SelectionBox { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let accent = cx.theme().colors.accent; + placed(&self.frame) + .bg(accent.alpha(0.08)) + .border_1() + .border_color(accent) + } +} + +/// A selection's frame in view pixels with a square handle at each corner and side. +#[derive(IntoElement)] +pub struct TransformHandles { + frame: Frame, +} + +impl TransformHandles { + pub fn new(frame: Frame) -> Self { + Self { frame } + } +} + +/// Where each handle's square sits, in view pixels, for a frame and a handle `side` wide. +pub(crate) fn handle_boxes(frame: &Frame, side: f32) -> Vec<(Handle, Frame)> { + Handle::ALL + .into_iter() + .map(|handle| { + let (sx, sy) = handle.at(); + let (x, y) = ( + frame.x + frame.w * sx - side / 2.0, + frame.y + frame.h * sy - side / 2.0, + ); + (handle, Frame::new(x, y, side, side)) + }) + .collect() +} + +impl RenderOnce for TransformHandles { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (accent, surface) = (theme.colors.accent, theme.colors.surface); + let side = f32::from(theme.canvas().handle.to_pixels(window.rem_size())); + let squares = handle_boxes(&self.frame, side) + .into_iter() + .map(move |(_, frame)| placed(&frame).bg(surface).border_1().border_color(accent)); + div() + .absolute() + .inset_0() + .child(placed(&self.frame).border_1().border_color(accent)) + .children(squares) + } +} + +/// The lines a moving shape snaps to, across the whole view. +#[derive(IntoElement)] +pub struct SnapIndicator { + guides: Vec, + viewport: Viewport, +} + +impl SnapIndicator { + pub fn new(guides: impl IntoIterator, viewport: Viewport) -> Self { + Self { + guides: guides.into_iter().collect(), + viewport, + } + } +} + +impl RenderOnce for SnapIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let line: Hsla = cx.theme().colors.hue(3, "snap"); + let view = self.viewport; + div() + .absolute() + .inset_0() + .children(self.guides.into_iter().map(move |guide| { + match guide { + Guide::Vertical(x) => div() + .absolute() + .top_0() + .bottom_0() + .left(Pixels::from(view.to_view((x, 0.0)).0)) + .border_l_1() + .border_color(line), + Guide::Horizontal(y) => div() + .absolute() + .left_0() + .right_0() + .top(Pixels::from(view.to_view((0.0, y)).1)) + .border_t_1() + .border_color(line), + } + })) + } +} diff --git a/src/canvas/mod.rs b/src/canvas/mod.rs index f28d8fb..7c95206 100644 --- a/src/canvas/mod.rs +++ b/src/canvas/mod.rs @@ -1,14 +1,26 @@ +mod edit; +mod gesture; +mod history; +mod marks; mod minimap; +mod palette; mod plane; mod shape; +mod tools; mod view; mod zoom; #[cfg(all(test, feature = "test-support"))] mod tests; +pub use edit::{Handle, Tool}; +pub use gesture::Brush; +pub use history::HistoryPanel; +pub use marks::{SelectionBox, SnapIndicator, TransformHandles}; pub use minimap::MiniMap; +pub use palette::{BrushSettings, ToolPalette}; pub use plane::InfiniteCanvas; -pub use shape::{Artboard, Guide, Shape, ShapeKind}; +pub use shape::{Artboard, Corner, Guide, Shape, ShapeKind}; +pub use tools::ToolLayer; pub use view::{Frame, Viewport}; pub use zoom::{ZoomControls, ZoomIndicator}; diff --git a/src/canvas/palette.rs b/src/canvas/palette.rs new file mode 100644 index 0000000..5a3169e --- /dev/null +++ b/src/canvas/palette.rs @@ -0,0 +1,166 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use super::{edit::Tool, gesture::Brush}; +use crate::{ + buttons::{ToggleGroup, ToggleItem}, + forms::{ColorPalette, Slider}, + primitives::IconName, + theme::{ActiveTheme, HUE_NAMES, TextSize}, + typography::tabular, +}; + +type OnTool = Rc; +type OnBrush = Rc; + +fn icon(tool: Tool) -> IconName { + match tool { + Tool::Select => IconName::MousePointer2, + Tool::Hand => IconName::Hand, + Tool::Rect => IconName::Square, + Tool::Ellipse => IconName::Circle, + Tool::Polygon => IconName::Hexagon, + Tool::Line => IconName::Slash, + Tool::Arrow => IconName::MoveUpRight, + Tool::Pen => IconName::PenTool, + Tool::Text => IconName::Type, + Tool::Brush => IconName::Brush, + } +} + +/// The canvas's tools in a row, one picked, each named in a tip. +#[derive(IntoElement)] +pub struct ToolPalette { + id: ElementId, + tool: Tool, + on_change: Option, +} + +impl ToolPalette { + pub fn new(id: impl Into, tool: Tool) -> Self { + Self { + id: id.into(), + tool, + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(Tool, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ToolPalette { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let on_change = self.on_change; + Tool::ALL + .into_iter() + .fold(ToggleGroup::new(self.id), |group, tool| { + group.item( + ToggleItem::new(tool.words()) + .icon(icon(tool)) + .tooltip(tool.words()), + ) + }) + .selected([self.tool.words()]) + .on_change(move |values: &[SharedString], window, cx| { + let Some(words) = values.first() else { return }; + let tool = Tool::ALL + .into_iter() + .find(|tool| tool.words() == words.as_ref()) + .expect("a tool of the palette"); + log::info!("tool palette: {}", tool.words()); + if let Some(on_change) = &on_change { + on_change(tool, window, cx); + } + }) + } +} + +/// A brush's width on a slider and its hue from the chart's named colors. +#[derive(IntoElement)] +pub struct BrushSettings { + id: ElementId, + brush: Brush, + on_change: Option, +} + +impl BrushSettings { + pub fn new(id: impl Into, brush: Brush) -> Self { + Self { + id: id.into(), + brush, + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(Brush, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BrushSettings { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let chart = theme.colors.chart; + let brush = self.brush; + let (sized, hued) = (self.on_change.clone(), self.on_change); + let width = Slider::new((self.id.clone(), "size"), f64::from(brush.size)) + .range(1.0, 24.0) + .step(1.0) + .on_change(move |size, window, cx| { + if let Some(on_change) = &sized { + on_change( + Brush { + size: size as f32, + ..brush + }, + window, + cx, + ); + } + }); + let hues = ColorPalette::new( + (self.id.clone(), "hue"), + HUE_NAMES + .iter() + .zip(chart) + .map(|(name, color)| (*name, color)), + ) + .columns(8) + .selected(chart[brush.hue]) + .on_change(move |color, window, cx| { + let hue = chart + .iter() + .position(|each| *each == color) + .expect("a hue of the chart"); + if let Some(on_change) = &hued { + on_change(Brush { hue, ..brush }, window, cx); + } + }); + div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_3() + .child(div().flex_1().child(width)) + .child( + tabular(div()) + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!("{} pt", brush.size)), + ), + ) + .child(hues) + } +} diff --git a/src/canvas/plane.rs b/src/canvas/plane.rs index 7b0d0d0..d9ea305 100644 --- a/src/canvas/plane.rs +++ b/src/canvas/plane.rs @@ -116,10 +116,10 @@ pub(crate) fn paint_shape( finish(solid(&corners, origin), wash, window); finish(outline(&corners, origin, stroke, true), ink, window); } - ShapeKind::Line | ShapeKind::Arrow => { - let (from, to) = ((frame.x, frame.y), (frame.right(), frame.bottom())); + ShapeKind::Line(end) | ShapeKind::Arrow(end) => { + let (from, to) = (end.opposite().of(&frame), end.of(&frame)); finish(outline(&[from, to], origin, stroke, false), ink, window); - if shape.kind == ShapeKind::Arrow { + if matches!(shape.kind, ShapeKind::Arrow(_)) { let (dx, dy) = (to.0 - from.0, to.1 - from.1); let length = (dx * dx + dy * dy).sqrt().max(1.0); let (ux, uy) = (dx / length, dy / length); @@ -130,12 +130,13 @@ pub(crate) fn paint_shape( finish(solid(&[to, left, right], origin), ink, window); } } - ShapeKind::Path(points) => { + ShapeKind::Path { points, width } => { let points: Vec<(f32, f32)> = points .iter() .map(|(x, y)| view.to_view((shape.frame.x + x, shape.frame.y + y))) .collect(); - finish(outline(&points, origin, stroke, false), ink, window); + let wide = Pixels::from(width * view.zoom).max(stroke); + finish(outline(&points, origin, wide, false), ink, window); } ShapeKind::Text(_) | ShapeKind::Note(_) => {} } diff --git a/src/canvas/shape.rs b/src/canvas/shape.rs index 8d33780..322d053 100644 --- a/src/canvas/shape.rs +++ b/src/canvas/shape.rs @@ -9,17 +9,50 @@ pub enum ShapeKind { Ellipse, /// A regular polygon of so many sides, three or more. Polygon(u8), - /// From the frame's top left to its bottom right. - Line, - /// A line with a head at its end. - Arrow, + /// From the opposite corner of the frame to this one. + Line(Corner), + /// A line with a head at the corner it ends at. + Arrow(Corner), Text(SharedString), - /// Points in canvas units from the frame's top left. - Path(Vec<(f32, f32)>), + /// Points in canvas units from the frame's top left, a line so wide. + Path { + points: Vec<(f32, f32)>, + width: f32, + }, /// A square of paper with words on it. Note(SharedString), } +/// A frame's corner, where a line or an arrow ends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Corner { + TopLeft, + TopRight, + BottomRight, + BottomLeft, +} + +impl Corner { + /// Its point on `frame`. + pub(crate) fn of(self, frame: &Frame) -> (f32, f32) { + match self { + Corner::TopLeft => (frame.x, frame.y), + Corner::TopRight => (frame.right(), frame.y), + Corner::BottomRight => (frame.right(), frame.bottom()), + Corner::BottomLeft => (frame.x, frame.bottom()), + } + } + + pub(crate) fn opposite(self) -> Corner { + match self { + Corner::TopLeft => Corner::BottomRight, + Corner::TopRight => Corner::BottomLeft, + Corner::BottomRight => Corner::TopLeft, + Corner::BottomLeft => Corner::TopRight, + } + } +} + /// Something on a canvas: its key, its name, what it is, its frame, its hue among the chart colors, and whether it hides or is locked. #[derive(Clone, Debug, PartialEq)] pub struct Shape { diff --git a/src/canvas/tests.rs b/src/canvas/tests/mod.rs similarity index 93% rename from src/canvas/tests.rs rename to src/canvas/tests/mod.rs index 44cca55..517d7ab 100644 --- a/src/canvas/tests.rs +++ b/src/canvas/tests/mod.rs @@ -7,10 +7,13 @@ use gpui::{ use super::{Frame, InfiniteCanvas, MiniMap, Viewport, ZoomControls}; use crate::{primitives::FocusNext, theme::Theme}; -/// A view that shows one canvas part and keeps the viewports it heard. +mod tools; + +/// A view that shows one canvas part and keeps the viewports and words it heard. struct Stage { part: fn(Entity) -> AnyElement, heard: Vec, + said: Vec, } impl Render for Stage { @@ -42,6 +45,7 @@ fn stage( let (host, cx) = cx.add_window_view(|_, _| Stage { part, heard: Vec::new(), + said: Vec::new(), }); settle(cx); (host, cx) @@ -54,6 +58,17 @@ fn hear(owner: &Entity, view: Viewport, cx: &mut gpui::App) { }); } +fn say(owner: &Entity, words: String, cx: &mut gpui::App) { + owner.update(cx, |stage, cx| { + stage.said.push(words); + cx.notify(); + }); +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |stage, _| stage.said.clone()) +} + fn heard(host: &Entity, cx: &mut VisualTestContext) -> Vec { host.read_with(cx, |stage, _| stage.heard.clone()) } diff --git a/src/canvas/tests/tools.rs b/src/canvas/tests/tools.rs new file mode 100644 index 0000000..4b185ca --- /dev/null +++ b/src/canvas/tests/tools.rs @@ -0,0 +1,136 @@ +use gpui::{ + AnyElement, Entity, IntoElement, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, + TestAppContext, VisualTestContext, point, px, +}; + +use super::{Stage, said, say, settle, stage}; +use crate::canvas::{Frame, InfiniteCanvas, Shape, ShapeKind, Tool, ToolLayer, Viewport}; + +fn at(x: f32, y: f32) -> gpui::Point { + point(px(x), px(y)) +} + +/// Presses at `from`, drags in steps to `to`, and lets go. +fn drag(from: (f32, f32), to: (f32, f32), cx: &mut VisualTestContext) { + cx.simulate_mouse_move(at(from.0, from.1), None, Modifiers::none()); + cx.simulate_mouse_down(at(from.0, from.1), MouseButton::Left, Modifiers::none()); + for step in 1..=4 { + let share = step as f32 / 4.0; + let x = from.0 + (to.0 - from.0) * share; + let y = from.1 + (to.1 - from.1) * share; + cx.simulate_mouse_move(at(x, y), Some(MouseButton::Left), Modifiers::none()); + settle(cx); + } + cx.simulate_mouse_up(at(to.0, to.1), MouseButton::Left, Modifiers::none()); + settle(cx); +} + +fn press(x: f32, y: f32, count: usize, cx: &mut VisualTestContext) { + cx.simulate_event(MouseDownEvent { + button: MouseButton::Left, + position: at(x, y), + modifiers: Modifiers::none(), + click_count: count, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + button: MouseButton::Left, + position: at(x, y), + modifiers: Modifiers::none(), + click_count: count, + }); + settle(cx); +} + +fn pair() -> Vec { + vec![ + Shape::new( + "a", + "A", + ShapeKind::Rect, + Frame::new(0.0, 0.0, 100.0, 100.0), + ), + Shape::new( + "b", + "B", + ShapeKind::Rect, + Frame::new(200.0, 0.0, 100.0, 100.0), + ), + ] +} + +fn layer(tool: Tool, selected: &[&'static str], owner: Entity) -> AnyElement { + let view = Viewport::new(0.0, 0.0, 1.0); + let (drawn, chosen, moved, sized) = (owner.clone(), owner.clone(), owner.clone(), owner); + let layer = ToolLayer::new("layer", tool, view, pair()) + .selected(selected.iter().copied()) + .on_draw(move |kind, frame, _, cx| { + let what = match kind { + ShapeKind::Path { points, .. } => format!("path of {}", points.len()), + other => format!("{other:?}"), + }; + say( + &drawn, + format!( + "draw {what} {} {} {} {}", + frame.x, frame.y, frame.w, frame.h + ), + cx, + ) + }) + .on_select(move |keys, _, cx| say(&chosen, format!("select {keys:?}"), cx)) + .on_move(move |keys, by, _, cx| say(&moved, format!("move {keys:?} {by:?}"), cx)) + .on_resize(move |key, frame, _, cx| { + say(&sized, format!("resize {key} {} {}", frame.w, frame.h), cx) + }); + InfiniteCanvas::new("plane", view) + .shapes(pair()) + .layer(layer) + .into_any_element() +} + +#[gpui::test] +fn a_shape_tool_draws_what_the_drag_spans(cx: &mut TestAppContext) { + let (host, cx) = stage(|owner| layer(Tool::Ellipse, &[], owner), cx); + drag((110.0, 120.0), (190.0, 180.0), cx); + assert_eq!(said(&host, cx), ["draw Ellipse 110 120 80 60"]); +} + +#[gpui::test] +fn a_selected_shape_moves_and_snaps_to_its_neighbour(cx: &mut TestAppContext) { + let (host, cx) = stage(|owner| layer(Tool::Select, &["a"], owner), cx); + drag((50.0, 50.0), (147.0, 50.0), cx); + assert_eq!( + said(&host, cx), + ["move [\"a\"] (100.0, 0.0)"], + "its right edge meets b's left" + ); +} + +#[gpui::test] +fn a_drag_on_empty_space_selects_what_it_holds(cx: &mut TestAppContext) { + let (host, cx) = stage(|owner| layer(Tool::Select, &[], owner), cx); + drag((350.0, 150.0), (150.0, -10.0), cx); + assert_eq!( + said(&host, cx), + ["select [\"b\"]"], + "a runs past the marquee's left" + ); +} + +#[gpui::test] +fn a_corner_handle_resizes_the_selection(cx: &mut TestAppContext) { + let (host, cx) = stage(|owner| layer(Tool::Select, &["a"], owner), cx); + drag((100.0, 100.0), (150.0, 120.0), cx); + assert_eq!(said(&host, cx), ["resize a 150 120"]); +} + +#[gpui::test] +fn the_pen_adds_a_point_a_press_and_a_double_press_ends_the_path(cx: &mut TestAppContext) { + let (host, cx) = stage(|owner| layer(Tool::Pen, &[], owner), cx); + for (x, y) in [(110.0, 150.0), (160.0, 150.0), (160.0, 200.0)] { + press(x, y, 1, cx); + } + press(160.0, 200.0, 2, cx); + assert_eq!(said(&host, cx), ["draw path of 3 110 150 50 50"]); +} diff --git a/src/canvas/tools.rs b/src/canvas/tools.rs new file mode 100644 index 0000000..95028ce --- /dev/null +++ b/src/canvas/tools.rs @@ -0,0 +1,408 @@ +use std::rc::Rc; + +use gpui::{ + App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, InteractiveElement, + IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString, + StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, +}; + +use super::{ + edit::{Tool, drawn, hit, resized, snapped}, + gesture::{ + Brush, Gesture, Hand, Handlers, LEAST, OnDraw, OnKeys, OnMove, OnResize, PEN, REACH, Scene, + commit, path, spanned, stroke_shape, taken, + }, + marks::{SelectionBox, SnapIndicator, TransformHandles, handle_boxes}, + plane::{in_view, paint_shape}, + shape::{Shape, ShapeKind}, + view::{Frame, Viewport}, +}; +use crate::theme::ActiveTheme; + +/// A drag on a tool layer, and whose layer it is. +struct Tracing { + owner: EntityId, +} + +/// A layer over an InfiniteCanvas where its tools work. Select presses a shape, Shift adds to the selection, a drag moves it and snaps its edges to others, a handle resizes it, and a drag on empty space draws a SelectionBox. The shape tools draw by a drag, Shift keeping them square; the pen adds a point per press and a double press ends the path; text drops a line where pressed; the brush strokes a path. A drag may end anywhere, in the layer or out of it. Everything asks the owner; the hand leaves the canvas to pan. +#[derive(IntoElement)] +pub struct ToolLayer { + id: ElementId, + tool: Tool, + viewport: Viewport, + shapes: Vec, + selected: Vec, + brush: Brush, + on_select: Option, + on_draw: Option, + on_move: Option, + on_resize: Option, +} + +impl ToolLayer { + pub fn new( + id: impl Into, + tool: Tool, + viewport: Viewport, + shapes: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + tool, + viewport, + shapes: shapes.into_iter().collect(), + selected: Vec::new(), + brush: Brush { size: 4.0, hue: 0 }, + on_select: None, + on_draw: None, + on_move: None, + on_resize: None, + } + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { + self.selected = keys.into_iter().map(Into::into).collect(); + self + } + + pub fn brush(mut self, brush: Brush) -> Self { + self.brush = brush; + self + } + + /// Gets the keys selected after each press or marquee. + pub fn on_select( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_select = Some(Rc::new(handler)); + self + } + + /// Gets a new shape's kind and frame, for the owner to key and keep. + pub fn on_draw( + mut self, + handler: impl Fn(ShapeKind, Frame, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_draw = Some(Rc::new(handler)); + self + } + + /// Gets the selection's keys and how far it moved, snaps included. + pub fn on_move( + mut self, + handler: impl Fn(&[SharedString], (f32, f32), &mut Window, &mut App) + 'static, + ) -> Self { + self.on_move = Some(Rc::new(handler)); + self + } + + /// Gets a resized shape's key and new frame. + pub fn on_resize( + mut self, + handler: impl Fn(&SharedString, Frame, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resize = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ToolLayer { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let hand: Entity = + window.use_keyed_state((self.id.clone(), "hand"), cx, |_, _| Hand::default()); + if self.tool == Tool::Hand { + return div().into_any_element(); + } + let (view, tool) = (self.viewport, self.tool); + let shapes = Rc::new(self.shapes); + let selected = Rc::new(self.selected); + let others: Vec = shapes + .iter() + .filter(|shape| !selected.contains(&shape.key)) + .map(|shape| shape.frame) + .collect(); + let whole = spanned(&shapes, &selected); + let state = hand.read(cx); + let gesture = state.gesture.clone(); + let square = state.square; + let pen = state.pen.clone(); + let theme = cx.theme(); + let (palette, stroke) = ( + theme.colors.clone(), + theme.canvas().stroke.to_pixels(window.rem_size()), + ); + let side = f32::from(theme.canvas().handle.to_pixels(window.rem_size())); + let (nudge, guides) = match &gesture { + Gesture::Moving { from, to } => { + let moved = whole.map(|frame| { + Frame::new( + frame.x + to.0 - from.0, + frame.y + to.1 - from.1, + frame.w, + frame.h, + ) + }); + moved.map_or(((0.0, 0.0), Vec::new()), |moved| { + snapped(&moved, &others, REACH / view.zoom) + }) + } + _ => ((0.0, 0.0), Vec::new()), + }; + let preview: Vec = match &gesture { + Gesture::Drawing { kind, from, to } => { + let (kind, frame) = drawn(kind.clone(), *from, *to, square); + vec![Shape::new("+draft", "Draft", kind, frame)] + } + Gesture::Moving { from, to } => shapes + .iter() + .filter(|shape| selected.contains(&shape.key)) + .map(|shape| { + let mut moved = shape.clone(); + moved.frame.x += to.0 - from.0 + nudge.0; + moved.frame.y += to.1 - from.1 + nudge.1; + moved + }) + .collect(), + Gesture::Resizing { + key, + handle, + from, + to, + } => shapes + .iter() + .filter(|shape| shape.key == *key) + .map(|shape| { + let mut sized = shape.clone(); + sized.frame = resized( + shape.frame, + *handle, + (to.0 - from.0, to.1 - from.1), + LEAST, + square, + ); + sized + }) + .collect(), + Gesture::Stroke { points } if points.len() > 1 => { + vec![stroke_shape(points, self.brush.size, self.brush.hue)] + } + _ if pen.len() > 1 => vec![stroke_shape(&pen, PEN, self.brush.hue)], + _ => Vec::new(), + }; + let marquee = match &gesture { + Gesture::Marquee { from, to } => Some(SelectionBox::new(in_view( + &view, + &Frame::spanning(*from, *to), + ))), + _ => None, + }; + let handles = (tool == Tool::Select && matches!(gesture, Gesture::Idle)) + .then_some(whole) + .flatten() + .map(|frame| TransformHandles::new(in_view(&view, &frame))); + let snap = (!guides.is_empty()).then(|| SnapIndicator::new(guides.clone(), view)); + let point_of = move |at: Point, bounds: Bounds| { + let local = at - bounds.origin; + view.to_canvas((f32::from(local.x), f32::from(local.y))) + }; + let owner = hand.entity_id(); + let (pressed, moved, dropped, released) = + (hand.clone(), hand.clone(), hand.clone(), hand.clone()); + let (press_shapes, press_selected) = (shapes.clone(), selected.clone()); + let (select_on_press, draw_on_press, select_on_up) = ( + self.on_select.clone(), + self.on_draw.clone(), + self.on_select.clone(), + ); + let handlers = Handlers { + on_select: self.on_select, + on_draw: self.on_draw, + on_move: self.on_move, + on_resize: self.on_resize, + }; + let scene = Rc::new(Scene { + shapes, + selected, + others, + view, + }); + let handlers = Rc::new(handlers); + let brush = self.brush; + let (out_hand, out_scene, out_handlers) = (hand.clone(), scene.clone(), handlers.clone()); + let measured = hand.clone(); + div() + .id(self.id) + .absolute() + .inset_0() + .when(tool != Tool::Select, |layer| layer.cursor_crosshair()) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + cx.stop_propagation(); + let bounds = pressed.read(cx).bounds; + let at = point_of(event.position, bounds); + let local = event.position - bounds.origin; + let view_at = (f32::from(local.x), f32::from(local.y)); + let gesture = match tool { + Tool::Select => { + let held = (press_selected.len() == 1) + .then(|| spanned(&press_shapes, &press_selected)) + .flatten() + .and_then(|frame| { + handle_boxes(&in_view(&view, &frame), side) + .into_iter() + .find(|(_, square)| square.contains(view_at)) + }); + match (held, hit(&press_shapes, at, REACH / view.zoom)) { + (Some((handle, _)), _) => Gesture::Resizing { + key: press_selected[0].clone(), + handle, + from: at, + to: at, + }, + (None, Some(ix)) => { + let key = press_shapes[ix].key.clone(); + let next: Vec = + match (event.modifiers.shift, press_selected.contains(&key)) { + (true, true) => press_selected + .iter() + .filter(|each| **each != key) + .cloned() + .collect(), + (true, false) => { + press_selected.iter().cloned().chain([key]).collect() + } + (false, true) => press_selected.to_vec(), + (false, false) => vec![key], + }; + if next != *press_selected { + log::info!("tool layer: select {next:?}"); + if let Some(on_select) = &select_on_press { + on_select(&next, window, cx); + } + } + Gesture::Moving { from: at, to: at } + } + (None, None) => Gesture::Marquee { from: at, to: at }, + } + } + Tool::Pen => { + let ends = event.click_count >= 2; + let points = pressed.update(cx, |hand, _| { + if !ends { + hand.pen.push(at); + } + match ends { + true => std::mem::take(&mut hand.pen), + false => Vec::new(), + } + }); + if ends && points.len() > 1 { + let (kind, frame) = path(&points, PEN); + log::info!("tool layer: a path of {} points", points.len()); + if let Some(on_draw) = &draw_on_press { + on_draw(kind, frame, window, cx); + } + } + Gesture::Idle + } + Tool::Text => { + log::info!("tool layer: text at {at:?}"); + if let Some(on_draw) = &draw_on_press { + on_draw( + ShapeKind::Text("Text".into()), + Frame::new(at.0, at.1, 160.0, 32.0), + window, + cx, + ); + } + Gesture::Idle + } + Tool::Brush => Gesture::Stroke { points: vec![at] }, + drawer => Gesture::Drawing { + kind: drawer.draws().expect("the rest draw shapes"), + from: at, + to: at, + }, + }; + pressed.update(cx, |hand, cx| { + hand.gesture = gesture; + hand.square = event.modifiers.shift; + cx.notify(); + }); + }) + .on_drag(Tracing { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) + .on_drag_move::(move |event, _, cx| { + if event.drag(cx).owner != owner { + return; + } + let bounds = moved.read(cx).bounds; + let at = point_of(event.event.position, bounds); + moved.update(cx, |hand, cx| { + hand.square = event.event.modifiers.shift; + match &mut hand.gesture { + Gesture::Marquee { to, .. } + | Gesture::Moving { to, .. } + | Gesture::Resizing { to, .. } + | Gesture::Drawing { to, .. } => *to = at, + Gesture::Stroke { points } => points.push(at), + Gesture::Idle => {} + } + cx.notify(); + }); + }) + .on_drop(move |_: &Tracing, window, cx| { + let (gesture, square) = taken(&dropped, cx); + commit(gesture, square, &scene, &handlers, brush, window, cx); + }) + .on_mouse_up_out(MouseButton::Left, move |_, window, cx| { + let (gesture, square) = taken(&out_hand, cx); + commit( + gesture, + square, + &out_scene, + &out_handlers, + brush, + window, + cx, + ); + }) + .on_mouse_up(MouseButton::Left, move |_, window, cx| { + if cx.has_active_drag() { + return; + } + let gesture = released.update(cx, |hand, cx| { + cx.notify(); + std::mem::take(&mut hand.gesture) + }); + if matches!(gesture, Gesture::Marquee { .. }) { + log::info!("tool layer: nothing selected"); + if let Some(on_select) = &select_on_up { + on_select(&[], window, cx); + } + } + }) + .child( + canvas( + move |bounds, window, cx| { + if measured.read(cx).bounds != bounds { + measured.update(cx, |hand, _| hand.bounds = bounds); + window.request_animation_frame(); + } + }, + move |bounds, _, window, _| { + for shape in &preview { + paint_shape(shape, &view, bounds.origin, &palette, stroke, window); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) + .children(marquee) + .children(handles) + .children(snap) + .into_any_element() + } +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 031fc6c..c10527f 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -174,6 +174,7 @@ icons! { Heading3 => "heading-3", Headphones => "headphones", Heart => "heart", + Hexagon => "hexagon", History => "history", Hourglass => "hourglass", House => "house", @@ -218,6 +219,7 @@ icons! { Moon => "moon", MousePointer2 => "mouse-pointer-2", Move => "move", + MoveUpRight => "move-up-right", Music => "music", Network => "network", NotebookPen => "notebook-pen", @@ -271,6 +273,7 @@ icons! { Shuffle => "shuffle", SkipBack => "skip-back", SkipForward => "skip-forward", + Slash => "slash", SlidersHorizontal => "sliders-horizontal", Smartphone => "smartphone", Smile => "smile", diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 0ae15a2..1129883 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -44,15 +44,15 @@ ## T32b Canvas · Tools -- [ ] SelectionBox -- [ ] TransformHandles -- [ ] SnapIndicator -- [ ] ToolPalette -- [ ] ShapeTools — rect, ellipse, polygon, line, arrow -- [ ] PenTool / PathEditor -- [ ] TextTool -- [ ] BrushTool / BrushSettings -- [ ] HistoryPanel +- [x] SelectionBox +- [x] TransformHandles +- [x] SnapIndicator +- [x] ToolPalette +- [x] ShapeTools — rect, ellipse, polygon, line, arrow +- [x] PenTool / PathEditor +- [x] TextTool +- [x] BrushTool / BrushSettings +- [x] HistoryPanel ## T32c Canvas · Panels