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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 07:27:27 -04:00
1 parent ea99d30e3b
commit 49a205ea35
20 files changed
+1812 -34

No files matched your search

+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"]);
}