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