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

+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),
}
}))
}
}