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
@@ -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 | |
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
})
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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};
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user