Canvas · Tools: select, move and snap, resize, draw, pen, text, brush

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 07:27:27 -04:00
1 parent 50259aff77
commit c474a3eb33
20 files changed
+1812 -34

No files matched your search

+2 -1
View File
@@ -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 | |
+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-hexagon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
</svg>

After

Width:  |  Height:  |  Size: 423 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-move-up-right"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 5H19V11" />
<path d="M19 5L5 19" />
</svg>

After

Width:  |  Height:  |  Size: 345 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-slash"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 2 2 22" />
</svg>

After

Width:  |  Height:  |  Size: 310 B

+20 -1
View File
@@ -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()
}
+14 -10
View File
@@ -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> {
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),
+195
View File
@@ -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<Shape>,
selected: Vec<SharedString>,
tool: Tool,
brush: Brush,
view: Viewport,
steps: Vec<(SharedString, Vec<Shape>)>,
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<Studio> {
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<SharedString> = 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();
})
})),
),
)
}
+334
View File
@@ -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<ShapeKind> {
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<usize> {
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<SharedString> {
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<Guide>) {
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<f32>| {
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<Shape> {
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));
}
}
+193
View File
@@ -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<dyn Fn(&[SharedString], &mut Window, &mut App)>;
pub(super) type OnDraw = Rc<dyn Fn(ShapeKind, Frame, &mut Window, &mut App)>;
pub(super) type OnMove = Rc<dyn Fn(&[SharedString], (f32, f32), &mut Window, &mut App)>;
pub(super) type OnResize = Rc<dyn Fn(&SharedString, Frame, &mut Window, &mut App)>;
/// 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<Pixels>,
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<Frame> {
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<Hand>, 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<OnKeys>,
pub(super) on_draw: Option<OnDraw>,
pub(super) on_move: Option<OnMove>,
pub(super) on_resize: Option<OnResize>,
}
/// 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<Vec<Shape>>,
pub(super) selected: Rc<Vec<SharedString>>,
pub(super) others: Vec<Frame>,
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);
}
}
_ => {}
}
}
+85
View File
@@ -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<dyn Fn(usize, &mut Window, &mut App)>;
/// 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<SharedString>,
at: usize,
on_step: Option<OnStep>,
}
impl HistoryPanel {
/// `at` counts the steps in force; none undone is `steps.len()`.
pub fn new(
id: impl Into<ElementId>,
steps: impl IntoIterator<Item = impl Into<SharedString>>,
at: usize,
) -> Self {
let steps: Vec<SharedString> = 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);
}
}))
}
}
+127
View File
@@ -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<Guide>,
viewport: Viewport,
}
impl SnapIndicator {
pub fn new(guides: impl IntoIterator<Item = Guide>, 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),
}
}))
}
}
+13 -1
View File
@@ -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};
+166
View File
@@ -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<dyn Fn(Tool, &mut Window, &mut App)>;
type OnBrush = Rc<dyn Fn(Brush, &mut Window, &mut App)>;
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<OnTool>,
}
impl ToolPalette {
pub fn new(id: impl Into<ElementId>, 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<OnBrush>,
}
impl BrushSettings {
pub fn new(id: impl Into<ElementId>, 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)
}
}
+6 -5
View File
@@ -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(_) => {}
}
+39 -6
View File
@@ -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 {
@@ -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<Stage>) -> AnyElement,
heard: Vec<Viewport>,
said: Vec<String>,
}
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<Stage>, view: Viewport, cx: &mut gpui::App) {
});
}
fn say(owner: &Entity<Stage>, words: String, cx: &mut gpui::App) {
owner.update(cx, |stage, cx| {
stage.said.push(words);
cx.notify();
});
}
fn said(host: &Entity<Stage>, cx: &mut VisualTestContext) -> Vec<String> {
host.read_with(cx, |stage, _| stage.said.clone())
}
fn heard(host: &Entity<Stage>, cx: &mut VisualTestContext) -> Vec<Viewport> {
host.read_with(cx, |stage, _| stage.heard.clone())
}
+136
View File
@@ -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<gpui::Pixels> {
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<Shape> {
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<Stage>) -> 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"]);
}
+408
View File
@@ -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<Shape>,
selected: Vec<SharedString>,
brush: Brush,
on_select: Option<OnKeys>,
on_draw: Option<OnDraw>,
on_move: Option<OnMove>,
on_resize: Option<OnResize>,
}
impl ToolLayer {
pub fn new(
id: impl Into<ElementId>,
tool: Tool,
viewport: Viewport,
shapes: impl IntoIterator<Item = Shape>,
) -> 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<Item = impl Into<SharedString>>) -> 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<Hand> =
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<Frame> = 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<Shape> = 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<Pixels>, bounds: Bounds<Pixels>| {
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<SharedString> =
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::<Tracing>(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()
}
}
+3
View File
@@ -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",
+9 -9
View File
@@ -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