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:
1 parent
50259aff77
commit
c474a3eb33
20 files changed
+1812
-34
No files matched your search
@@ -0,0 +1,222 @@
|
||||
use gpui::{
|
||||
AnyElement, Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Render,
|
||||
ScrollDelta, ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window,
|
||||
div, point, px,
|
||||
};
|
||||
|
||||
use super::{Frame, InfiniteCanvas, MiniMap, Viewport, ZoomControls};
|
||||
use crate::{primitives::FocusNext, theme::Theme};
|
||||
|
||||
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 {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.w(px(400.0))
|
||||
.h(px(300.0))
|
||||
.child((self.part)(cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn stage(
|
||||
part: fn(Entity<Stage>) -> AnyElement,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Stage>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.bind_keys([gpui::KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, _| Stage {
|
||||
part,
|
||||
heard: Vec::new(),
|
||||
said: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn hear(owner: &Entity<Stage>, view: Viewport, cx: &mut gpui::App) {
|
||||
owner.update(cx, |stage, cx| {
|
||||
stage.heard.push(view);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn plane(owner: Entity<Stage>) -> AnyElement {
|
||||
InfiniteCanvas::new("plane", Viewport::new(0.0, 0.0, 1.0))
|
||||
.on_viewport(move |view, _, cx| hear(&owner, view, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn command_and_the_wheel_zoom_about_the_pointer(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(plane, cx);
|
||||
cx.simulate_event(ScrollWheelEvent {
|
||||
position: point(px(100.0), px(50.0)),
|
||||
delta: ScrollDelta::Pixels(point(px(0.0), px(-240.0))),
|
||||
modifiers: Modifiers::command(),
|
||||
touch_phase: TouchPhase::Moved,
|
||||
});
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a zoom");
|
||||
assert!(
|
||||
(view.zoom - std::f32::consts::E).abs() < 0.01,
|
||||
"a turn of 240 zooms by e: {}",
|
||||
view.zoom
|
||||
);
|
||||
let held = view.to_canvas((100.0, 50.0));
|
||||
assert!(
|
||||
(held.0 - 100.0).abs() < 0.01 && (held.1 - 50.0).abs() < 0.01,
|
||||
"the point under the pointer stays: {held:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_drag_on_empty_space_pans(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(plane, cx);
|
||||
let at = |x: f32, y: f32| point(px(x), px(y));
|
||||
cx.simulate_mouse_move(at(200.0, 150.0), None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at(200.0, 150.0), MouseButton::Left, Modifiers::none());
|
||||
for (x, y) in [(190.0, 145.0), (170.0, 135.0), (150.0, 130.0)] {
|
||||
cx.simulate_mouse_move(at(x, y), Some(MouseButton::Left), Modifiers::none());
|
||||
settle(cx);
|
||||
}
|
||||
cx.simulate_mouse_up(at(150.0, 130.0), MouseButton::Left, Modifiers::none());
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a pan");
|
||||
assert_eq!(
|
||||
(view.x, view.y),
|
||||
(50.0, 20.0),
|
||||
"the content followed the pointer from the press"
|
||||
);
|
||||
}
|
||||
|
||||
fn zoom(owner: Entity<Stage>) -> AnyElement {
|
||||
ZoomControls::new("zoom", 1.0)
|
||||
.on_zoom(move |zoom, _, cx| hear(&owner, Viewport::new(0.0, 0.0, zoom), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_zoom_buttons_step_and_the_percent_resets(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(zoom, cx);
|
||||
for nth in [1, 2, 3] {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
(0..nth).for_each(|_| window.focus_next());
|
||||
});
|
||||
cx.simulate_keystrokes("space");
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse("space").expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
let zooms: Vec<f32> = heard(&host, cx).iter().map(|view| view.zoom).collect();
|
||||
assert_eq!(zooms, [0.75, 1.0, 1.5]);
|
||||
}
|
||||
|
||||
fn map(owner: Entity<Stage>) -> AnyElement {
|
||||
MiniMap::new(
|
||||
"map",
|
||||
[Frame::new(0.0, 0.0, 400.0, 280.0)],
|
||||
Viewport::new(0.0, 0.0, 1.0),
|
||||
(100.0, 70.0),
|
||||
)
|
||||
.on_viewport(move |view, _, cx| hear(&owner, view, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
/// The map draws inside its 1px edge, so its point 150, 105 lies at 151, 106.
|
||||
fn a_press_on_the_map_centers_the_view_there(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(map, cx);
|
||||
cx.simulate_click(point(px(151.0), px(106.0)), Modifiers::none());
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a move");
|
||||
let middle = view.to_canvas((50.0, 35.0));
|
||||
assert!(
|
||||
(middle.0 - 300.0).abs() < 1.0 && (middle.1 - 210.0).abs() < 1.0,
|
||||
"the view centers on the point pressed: {middle:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn wheel(at: (f32, f32), dy: f32, modifiers: Modifiers) -> ScrollWheelEvent {
|
||||
ScrollWheelEvent {
|
||||
position: point(px(at.0), px(at.1)),
|
||||
delta: ScrollDelta::Pixels(point(px(0.0), px(dy))),
|
||||
modifiers,
|
||||
touch_phase: TouchPhase::Moved,
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_plain_wheel_moves_the_content_as_a_scroll_box_does(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(plane, cx);
|
||||
cx.simulate_event(wheel((100.0, 50.0), -50.0, Modifiers::none()));
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a scroll");
|
||||
assert_eq!(
|
||||
view.to_view((0.0, 0.0)),
|
||||
(0.0, -50.0),
|
||||
"the content went up with the wheel"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn wheels_and_moves_add_up(cx: &mut TestAppContext) {
|
||||
let (host, cx) = stage(plane, cx);
|
||||
for dy in [-10.0, -10.0, -10.0, 0.0] {
|
||||
cx.simulate_event(wheel((100.0, 50.0), dy, Modifiers::none()));
|
||||
}
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a scroll");
|
||||
assert_eq!(
|
||||
view.y, 30.0,
|
||||
"three wheels add up, and an end that moves nothing leaves it"
|
||||
);
|
||||
let at = |x: f32, y: f32| point(px(x), px(y));
|
||||
cx.simulate_mouse_move(at(200.0, 150.0), None, Modifiers::none());
|
||||
cx.simulate_mouse_down(at(200.0, 150.0), MouseButton::Left, Modifiers::none());
|
||||
for x in [190.0, 180.0, 170.0, 160.0] {
|
||||
cx.simulate_mouse_move(at(x, 150.0), Some(MouseButton::Left), Modifiers::none());
|
||||
}
|
||||
cx.simulate_mouse_up(at(160.0, 150.0), MouseButton::Left, Modifiers::none());
|
||||
settle(cx);
|
||||
let view = *heard(&host, cx).last().expect("a pan");
|
||||
assert_eq!(
|
||||
(view.x, view.y),
|
||||
(40.0, 30.0),
|
||||
"the drag pans from the press by the pointer's whole way"
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user