Canvas · Plane: an endless canvas, its grid, rulers, zoom and map
A new chapter, src/canvas. InfiniteCanvas lays artboards, shapes and guides on an endless plane over a dot grid whose step, one, two or five times a power of ten, keeps the dots apart as it zooms out. Rulers run along the top and left in canvas units. A drag on empty space pans from the press, a wheel scrolls, and Command or Control with the wheel zooms about the pointer; the owner keeps the viewport, seeded, so its own zoom shows at once. Layers lie over the plane in view pixels. Shapes are rectangles, ellipses, regular polygons, lines, arrows, text, paths and notes, each in a chart hue, painted to scale; text and notes scale their type. Artboards carry their names above them. ZoomControls step out and in through fixed stops, fit on request, and hold ZoomIndicator, whose press sets 100%. MiniMap draws what lies on the canvas and the view's box, and centers the view where it is pressed or dragged. The viewport math, the steps and the map's fit are tested without gpui. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
86336ee828
commit
2c099ceffb
15 files changed
+1530
-14
No files matched your search
@@ -114,8 +114,11 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T31a Project · Tasks — `tasks/ch31-43.md`
|
||||
- [x] T31b Project · Issues and boards
|
||||
- [x] T31c Project · Plans
|
||||
- [ ] T31d Project · Time and databases
|
||||
- [ ] T32 Canvas & Design
|
||||
- [x] T31d Project · Time and databases
|
||||
- [ ] T32a Canvas · Plane — `tasks/ch31-43.md`
|
||||
- [ ] T32b Canvas · Tools
|
||||
- [ ] T32c Canvas · Panels
|
||||
- [ ] T32d Canvas · Graphs and boards
|
||||
- [ ] T33 DB & Dev Tools
|
||||
- [ ] T34 Dashboard
|
||||
- [ ] T35 Settings
|
||||
@@ -369,3 +372,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T31c | 1 | FAIL | a roadmap bar's end and the workload bar's cap went untested |
|
||||
| T31c | 2 | PASS | |
|
||||
| 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 | |
|
||||
@@ -0,0 +1,27 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod plane;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 32,
|
||||
slug: "canvas",
|
||||
title: "Canvas & Design",
|
||||
summary: "An endless plane to lay work on, the tools and panels around it, and graphs of nodes.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("canvas-plane", 420.0, 300.0),
|
||||
Step::DragTo("canvas-plane", 300.0, 240.0),
|
||||
Step::UpAt("canvas-plane", 300.0, 240.0),
|
||||
Step::Wait(200),
|
||||
Step::Shot("panned"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div().child(plane::plane(window, cx)).into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use ely_gpui_component::{
|
||||
canvas::{
|
||||
Artboard, Frame, Guide, InfiniteCanvas, MiniMap, Shape, ShapeKind, Viewport, ZoomControls,
|
||||
},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, section, set},
|
||||
};
|
||||
|
||||
/// The demo's view, in pixels.
|
||||
const VIEW: (f32, f32) = (820.0, 460.0);
|
||||
|
||||
fn boards() -> Vec<Artboard> {
|
||||
vec![
|
||||
Artboard::new("home", "Home · 390", Frame::new(0.0, 0.0, 390.0, 640.0)),
|
||||
Artboard::new(
|
||||
"detail",
|
||||
"Detail · 390",
|
||||
Frame::new(470.0, 0.0, 390.0, 640.0),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn shapes() -> Vec<Shape> {
|
||||
vec![
|
||||
Shape::new(
|
||||
"hero",
|
||||
"Hero",
|
||||
ShapeKind::Rect,
|
||||
Frame::new(24.0, 32.0, 342.0, 180.0),
|
||||
)
|
||||
.hue(0),
|
||||
Shape::new(
|
||||
"title",
|
||||
"Title",
|
||||
ShapeKind::Text("Studio Atrium".into()),
|
||||
Frame::new(24.0, 232.0, 342.0, 32.0),
|
||||
),
|
||||
Shape::new(
|
||||
"dot",
|
||||
"Status",
|
||||
ShapeKind::Ellipse,
|
||||
Frame::new(24.0, 290.0, 56.0, 56.0),
|
||||
)
|
||||
.hue(5),
|
||||
Shape::new(
|
||||
"badge",
|
||||
"Badge",
|
||||
ShapeKind::Polygon(6),
|
||||
Frame::new(100.0, 290.0, 56.0, 56.0),
|
||||
)
|
||||
.hue(2),
|
||||
Shape::new(
|
||||
"rule",
|
||||
"Rule",
|
||||
ShapeKind::Line,
|
||||
Frame::new(24.0, 380.0, 342.0, 0.0),
|
||||
)
|
||||
.hue(4),
|
||||
Shape::new(
|
||||
"flow",
|
||||
"Flow",
|
||||
ShapeKind::Arrow,
|
||||
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),
|
||||
]),
|
||||
Frame::new(494.0, 60.0, 160.0, 50.0),
|
||||
)
|
||||
.hue(1),
|
||||
Shape::new(
|
||||
"note",
|
||||
"Note",
|
||||
ShapeKind::Note("Ask Chloé about the stair".into()),
|
||||
Frame::new(494.0, 150.0, 180.0, 120.0),
|
||||
)
|
||||
.hue(2),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn plane(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let view = keep(
|
||||
"canvas-view",
|
||||
|| Viewport::new(-60.0, -60.0, 0.7),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let now = *view.read(cx);
|
||||
let frames: Vec<Frame> = boards().iter().map(|board| board.frame).collect();
|
||||
let whole = frames
|
||||
.iter()
|
||||
.skip(1)
|
||||
.fold(frames[0], |whole, frame| whole.union(frame));
|
||||
let (panned, zoomed, fitted, steered) = (view.clone(), view.clone(), view.clone(), view);
|
||||
let theme = cx.theme();
|
||||
let canvas = InfiniteCanvas::new("canvas-plane", now)
|
||||
.artboards(boards())
|
||||
.shapes(shapes())
|
||||
.guides([Guide::Vertical(24.0), Guide::Horizontal(212.0)])
|
||||
.rulers()
|
||||
.on_viewport(move |next, _, cx| set(&panned, next, cx))
|
||||
.layer(
|
||||
div()
|
||||
.absolute()
|
||||
.bottom_3()
|
||||
.right_3()
|
||||
.p_1()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.bg(theme.colors.surface)
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
ZoomControls::new("canvas-zoom", now.zoom)
|
||||
.on_zoom(move |zoom, _, cx| {
|
||||
let now = *zoomed.read(cx);
|
||||
let middle = (VIEW.0 / 2.0, VIEW.1 / 2.0);
|
||||
set(&zoomed, now.zoomed(zoom / now.zoom, middle), cx)
|
||||
})
|
||||
.on_fit(move |_, cx| {
|
||||
set(&fitted, Viewport::fitting(whole, VIEW, 48.0), cx)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.layer(
|
||||
div().absolute().bottom_3().left_8().child(
|
||||
MiniMap::new("canvas-map", frames, now, VIEW)
|
||||
.on_viewport(move |next, _, cx| set(&steered, next, cx)),
|
||||
),
|
||||
);
|
||||
section(
|
||||
"InfiniteCanvas · CanvasGrid / Guides · Ruler · ArtboardFrame · ZoomControls / ZoomIndicator · MiniMap",
|
||||
"An endless plane: a dot grid that thins as it zooms out, rulers in canvas units, guides, and artboards with their names. Drag to pan, scroll to move, and hold Command to zoom about the pointer. The zoom steps in fixed stops, and the minimap moves the view to a press.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"canvas-plane",
|
||||
div()
|
||||
.w(px(VIEW.0))
|
||||
.h(px(VIEW.1))
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.overflow_hidden()
|
||||
.child(canvas),
|
||||
))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod agent;
|
||||
mod buttons;
|
||||
mod calendar;
|
||||
mod canvas;
|
||||
mod charts;
|
||||
mod chat;
|
||||
mod collab;
|
||||
@@ -77,6 +78,7 @@ pub const ALL: &[Page] = &[
|
||||
mail::PAGE,
|
||||
calendar::PAGE,
|
||||
project::PAGE,
|
||||
canvas::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, InteractiveElement,
|
||||
IntoElement, ParentElement, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window,
|
||||
canvas, div, fill, point, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
plane::OnViewport,
|
||||
view::{Frame, Viewport},
|
||||
};
|
||||
use crate::theme::{ActiveTheme, Radius};
|
||||
|
||||
/// A drag on a minimap, and whose map it is.
|
||||
struct Steer {
|
||||
owner: EntityId,
|
||||
}
|
||||
|
||||
/// Where a map of `whole` shown in a box of `size` puts things: its scale and the offset that centers it.
|
||||
pub(crate) fn fitted(whole: Frame, (w, h): (f32, f32)) -> (f32, (f32, f32)) {
|
||||
let scale = (w / whole.w.max(1.0)).min(h / whole.h.max(1.0));
|
||||
let offset = ((w - whole.w * scale) / 2.0, (h - whole.h * scale) / 2.0);
|
||||
(scale, offset)
|
||||
}
|
||||
|
||||
/// A small map of what lies on a canvas and the part in view; a press or a drag on it centers the view there.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MiniMap {
|
||||
id: ElementId,
|
||||
frames: Vec<Frame>,
|
||||
viewport: Viewport,
|
||||
view: (f32, f32),
|
||||
on_viewport: Option<OnViewport>,
|
||||
}
|
||||
|
||||
impl MiniMap {
|
||||
/// The frames of what lies on the canvas, the viewport, and the size of the canvas's view in pixels.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
frames: impl IntoIterator<Item = Frame>,
|
||||
viewport: Viewport,
|
||||
view: (f32, f32),
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
frames: frames.into_iter().collect(),
|
||||
viewport,
|
||||
view,
|
||||
on_viewport: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_viewport(
|
||||
mut self,
|
||||
handler: impl Fn(Viewport, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_viewport = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MiniMap {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let bounds: Entity<Bounds<Pixels>> =
|
||||
window.use_keyed_state((self.id.clone(), "box"), cx, |_, _| Bounds::default());
|
||||
let theme = cx.theme();
|
||||
let (colors, sizes, rem) = (theme.colors.clone(), theme.canvas(), window.rem_size());
|
||||
let (w, h) = (
|
||||
f32::from(sizes.map.0.to_pixels(rem)),
|
||||
f32::from(sizes.map.1.to_pixels(rem)),
|
||||
);
|
||||
let (left, top) = self.viewport.to_canvas((0.0, 0.0));
|
||||
let seen = Frame::new(
|
||||
left,
|
||||
top,
|
||||
self.view.0 / self.viewport.zoom,
|
||||
self.view.1 / self.viewport.zoom,
|
||||
);
|
||||
let whole = self
|
||||
.frames
|
||||
.iter()
|
||||
.fold(seen, |whole, frame| whole.union(frame));
|
||||
let (scale, (dx, dy)) = fitted(whole, (w, h));
|
||||
let map = move |frame: &Frame| {
|
||||
Frame::new(
|
||||
dx + (frame.x - whole.x) * scale,
|
||||
dy + (frame.y - whole.y) * scale,
|
||||
frame.w * scale,
|
||||
frame.h * scale,
|
||||
)
|
||||
};
|
||||
let steer = {
|
||||
let (bounds, viewport, view, on_viewport) = (
|
||||
bounds.clone(),
|
||||
self.viewport,
|
||||
self.view,
|
||||
self.on_viewport.clone(),
|
||||
);
|
||||
move |at: gpui::Point<Pixels>, window: &mut Window, cx: &mut App| {
|
||||
let origin = bounds.read(cx).origin;
|
||||
let (x, y) = (f32::from(at.x - origin.x), f32::from(at.y - origin.y));
|
||||
let (cx_, cy_) = (whole.x + (x - dx) / scale, whole.y + (y - dy) / scale);
|
||||
let next = Viewport::new(
|
||||
cx_ - view.0 / 2.0 / viewport.zoom,
|
||||
cy_ - view.1 / 2.0 / viewport.zoom,
|
||||
viewport.zoom,
|
||||
);
|
||||
log::info!("minimap: view at {:.0}, {:.0}", next.x, next.y);
|
||||
if let Some(on_viewport) = &on_viewport {
|
||||
on_viewport(next, window, cx);
|
||||
}
|
||||
}
|
||||
};
|
||||
let frames: Vec<Frame> = self.frames.iter().map(map).collect();
|
||||
let lens = map(&seen);
|
||||
let (palette, hairline) = (colors.clone(), sizes.hairline);
|
||||
let owner = bounds.entity_id();
|
||||
let (pressed, moved, measured) = (steer.clone(), steer, bounds);
|
||||
div()
|
||||
.id(self.id)
|
||||
.relative()
|
||||
.w(sizes.map.0)
|
||||
.h(sizes.map.1)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.bg(colors.surface)
|
||||
.overflow_hidden()
|
||||
.on_mouse_down(gpui::MouseButton::Left, move |event, window, cx| {
|
||||
pressed(event.position, window, cx)
|
||||
})
|
||||
.on_drag(Steer { owner }, |_, _, _, cx| cx.new(|_| EmptyView))
|
||||
.on_drag_move::<Steer>(move |event, window, cx| {
|
||||
if event.drag(cx).owner == owner {
|
||||
moved(event.event.position, window, cx)
|
||||
}
|
||||
})
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
if *measured.read(cx) != bounds {
|
||||
measured.update(cx, |held, _| *held = bounds);
|
||||
}
|
||||
},
|
||||
move |bounds, _, window, _| {
|
||||
let place = |frame: &Frame| {
|
||||
Bounds::new(
|
||||
bounds.origin + point(Pixels::from(frame.x), Pixels::from(frame.y)),
|
||||
size(Pixels::from(frame.w), Pixels::from(frame.h)),
|
||||
)
|
||||
};
|
||||
for frame in &frames {
|
||||
window.paint_quad(fill(place(frame), palette.border_strong));
|
||||
}
|
||||
window.paint_quad(
|
||||
fill(place(&lens), palette.fg.alpha(0.06))
|
||||
.border_widths(hairline)
|
||||
.border_color(palette.fg_muted),
|
||||
);
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frame, fitted};
|
||||
|
||||
#[test]
|
||||
fn a_map_fits_the_whole_and_centers_it() {
|
||||
let (scale, offset) = fitted(Frame::new(0.0, 0.0, 400.0, 100.0), (200.0, 140.0));
|
||||
assert_eq!(scale, 0.5);
|
||||
assert_eq!(offset, (0.0, 45.0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
mod minimap;
|
||||
mod plane;
|
||||
mod shape;
|
||||
mod view;
|
||||
mod zoom;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use minimap::MiniMap;
|
||||
pub use plane::InfiniteCanvas;
|
||||
pub use shape::{Artboard, Guide, Shape, ShapeKind};
|
||||
pub use view::{Frame, Viewport};
|
||||
pub use zoom::{ZoomControls, ZoomIndicator};
|
||||
@@ -0,0 +1,456 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, Bounds, ElementId, EmptyView, Entity, EntityId, Hsla,
|
||||
InteractiveElement, IntoElement, ParentElement, PathBuilder, Pixels, Point, RenderOnce,
|
||||
ScrollDelta, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, size,
|
||||
};
|
||||
|
||||
use super::{
|
||||
shape::{Artboard, Guide, Shape, ShapeKind, polygon},
|
||||
view::{Frame, Viewport, marks, step},
|
||||
};
|
||||
use crate::{
|
||||
layout::seeded::use_seeded,
|
||||
theme::{ActiveTheme, Palette, TextSize},
|
||||
};
|
||||
|
||||
pub(crate) type OnViewport = Rc<dyn Fn(Viewport, &mut Window, &mut App)>;
|
||||
|
||||
/// A drag that pans a canvas, and whose canvas it is.
|
||||
struct Pan {
|
||||
owner: EntityId,
|
||||
}
|
||||
|
||||
/// The canvas's box and the pointer of a pan under way.
|
||||
#[derive(Default)]
|
||||
struct Plane {
|
||||
bounds: Bounds<Pixels>,
|
||||
grip: Option<Point<Pixels>>,
|
||||
}
|
||||
|
||||
/// The view pixels of a canvas frame.
|
||||
pub(crate) fn in_view(view: &Viewport, frame: &Frame) -> Frame {
|
||||
let (x, y) = view.to_view((frame.x, frame.y));
|
||||
Frame::new(x, y, frame.w * view.zoom, frame.h * view.zoom)
|
||||
}
|
||||
|
||||
fn at(origin: Point<Pixels>, (x, y): (f32, f32)) -> Point<Pixels> {
|
||||
origin + point(Pixels::from(x), Pixels::from(y))
|
||||
}
|
||||
|
||||
fn quad(origin: Point<Pixels>, frame: &Frame) -> Bounds<Pixels> {
|
||||
Bounds::new(
|
||||
at(origin, (frame.x, frame.y)),
|
||||
size(Pixels::from(frame.w), Pixels::from(frame.h)),
|
||||
)
|
||||
}
|
||||
|
||||
fn outline(
|
||||
points: &[(f32, f32)],
|
||||
origin: Point<Pixels>,
|
||||
width: Pixels,
|
||||
closed: bool,
|
||||
) -> PathBuilder {
|
||||
let mut path = PathBuilder::stroke(width);
|
||||
if let Some(first) = points.first() {
|
||||
path.move_to(at(origin, *first));
|
||||
points[1..]
|
||||
.iter()
|
||||
.for_each(|next| path.line_to(at(origin, *next)));
|
||||
if closed {
|
||||
path.close();
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn solid(points: &[(f32, f32)], origin: Point<Pixels>) -> PathBuilder {
|
||||
let mut path = PathBuilder::fill();
|
||||
if let Some(first) = points.first() {
|
||||
path.move_to(at(origin, *first));
|
||||
points[1..]
|
||||
.iter()
|
||||
.for_each(|next| path.line_to(at(origin, *next)));
|
||||
path.close();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
fn finish(path: PathBuilder, color: Hsla, window: &mut Window) {
|
||||
match path.build() {
|
||||
Ok(path) => window.paint_path(path, color),
|
||||
Err(error) => log::error!("canvas: a path failed to build: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints a shape's outline and wash in view pixels.
|
||||
pub(crate) fn paint_shape(
|
||||
shape: &Shape,
|
||||
view: &Viewport,
|
||||
origin: Point<Pixels>,
|
||||
palette: &Palette,
|
||||
stroke: Pixels,
|
||||
window: &mut Window,
|
||||
) {
|
||||
let ink = palette.hue(shape.hue, format_args!("shape {}", shape.key));
|
||||
let wash = ink.alpha(0.14);
|
||||
let frame = in_view(view, &shape.frame);
|
||||
match &shape.kind {
|
||||
ShapeKind::Rect => {
|
||||
window.paint_quad(fill(quad(origin, &frame), wash));
|
||||
let corners = [
|
||||
(frame.x, frame.y),
|
||||
(frame.right(), frame.y),
|
||||
(frame.right(), frame.bottom()),
|
||||
(frame.x, frame.bottom()),
|
||||
];
|
||||
finish(outline(&corners, origin, stroke, true), ink, window);
|
||||
}
|
||||
ShapeKind::Ellipse | ShapeKind::Polygon(_) => {
|
||||
let sides = match shape.kind {
|
||||
ShapeKind::Polygon(sides) => sides,
|
||||
_ => 64,
|
||||
};
|
||||
let corners = polygon(&frame, sides);
|
||||
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()));
|
||||
finish(outline(&[from, to], origin, stroke, false), ink, window);
|
||||
if 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);
|
||||
let head = f32::from(stroke) * 5.0;
|
||||
let back = (to.0 - ux * head, to.1 - uy * head);
|
||||
let left = (back.0 - uy * head / 2.0, back.1 + ux * head / 2.0);
|
||||
let right = (back.0 + uy * head / 2.0, back.1 - ux * head / 2.0);
|
||||
finish(solid(&[to, left, right], origin), ink, window);
|
||||
}
|
||||
}
|
||||
ShapeKind::Path(points) => {
|
||||
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);
|
||||
}
|
||||
ShapeKind::Text(_) | ShapeKind::Note(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// An endless plane: a dot grid that thins as it zooms out, rulers along the top and left, guides, artboards with their names, and shapes. A drag on empty space pans it from where it was pressed, a wheel scrolls it, and Command or Control with the wheel zooms about the pointer. The owner keeps the viewport; a new one from the owner shows at once.
|
||||
#[derive(IntoElement)]
|
||||
pub struct InfiniteCanvas {
|
||||
id: ElementId,
|
||||
viewport: Viewport,
|
||||
shapes: Vec<Shape>,
|
||||
artboards: Vec<Artboard>,
|
||||
guides: Vec<Guide>,
|
||||
rulers: bool,
|
||||
layers: Vec<AnyElement>,
|
||||
on_viewport: Option<OnViewport>,
|
||||
}
|
||||
|
||||
impl InfiniteCanvas {
|
||||
pub fn new(id: impl Into<ElementId>, viewport: Viewport) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
viewport,
|
||||
shapes: Vec::new(),
|
||||
artboards: Vec::new(),
|
||||
guides: Vec::new(),
|
||||
rulers: false,
|
||||
layers: Vec::new(),
|
||||
on_viewport: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shapes(mut self, shapes: impl IntoIterator<Item = Shape>) -> Self {
|
||||
self.shapes = shapes.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn artboards(mut self, artboards: impl IntoIterator<Item = Artboard>) -> Self {
|
||||
self.artboards = artboards.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn guides(mut self, guides: impl IntoIterator<Item = Guide>) -> Self {
|
||||
self.guides = guides.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Rulers along the top and the left, in canvas units.
|
||||
pub fn rulers(mut self) -> Self {
|
||||
self.rulers = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Lays an element over the plane, in its view's pixels, as a selection or handles are.
|
||||
pub fn layer(mut self, element: impl IntoElement) -> Self {
|
||||
self.layers.push(element.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the viewport after each pan and zoom.
|
||||
pub fn on_viewport(
|
||||
mut self,
|
||||
handler: impl Fn(Viewport, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_viewport = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for InfiniteCanvas {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id.clone();
|
||||
let local = use_seeded((id.clone(), "view"), self.viewport, window, cx);
|
||||
let plane: Entity<Plane> =
|
||||
window.use_keyed_state((id.clone(), "plane"), cx, |_, _| Plane::default());
|
||||
let view = local.read(cx).value;
|
||||
let bounds = plane.read(cx).bounds;
|
||||
let (w, h) = (f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||
let set: OnViewport = {
|
||||
let (local, on_viewport) = (local.clone(), self.on_viewport.clone());
|
||||
Rc::new(move |next, window, cx| {
|
||||
local.update(cx, |local, cx| {
|
||||
local.value = next;
|
||||
cx.notify();
|
||||
});
|
||||
if let Some(on_viewport) = &on_viewport {
|
||||
on_viewport(next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let (colors, sizes) = (theme.colors.clone(), theme.canvas());
|
||||
let rem = window.rem_size();
|
||||
let (dot, hairline, stroke) = (sizes.dot, sizes.hairline, sizes.stroke.to_pixels(rem));
|
||||
let (ruler, side) = (sizes.ruler.to_pixels(rem), sizes.side.to_pixels(rem));
|
||||
let grid = step(view.zoom, f32::from(sizes.grid.to_pixels(rem)));
|
||||
let (left, top) = view.to_canvas((0.0, 0.0));
|
||||
let (right, bottom) = view.to_canvas((w, h));
|
||||
let dots: Vec<(f32, f32)> = marks(left, right, grid)
|
||||
.into_iter()
|
||||
.flat_map(|x| {
|
||||
marks(top, bottom, grid)
|
||||
.into_iter()
|
||||
.map(move |y| view.to_view((x, y)))
|
||||
})
|
||||
.collect();
|
||||
let words = |text: String, color| {
|
||||
div()
|
||||
.absolute()
|
||||
.whitespace_nowrap()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(color)
|
||||
.child(text)
|
||||
};
|
||||
let names: Vec<AnyElement> = self
|
||||
.artboards
|
||||
.iter()
|
||||
.map(|board| {
|
||||
let frame = in_view(&view, &board.frame);
|
||||
words(board.name.to_string(), colors.fg_muted)
|
||||
.left(Pixels::from(frame.x))
|
||||
.bottom(Pixels::from(h - frame.y))
|
||||
.pb_1()
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
let texts: Vec<AnyElement> = self
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|shape| !shape.hidden)
|
||||
.filter_map(|shape| {
|
||||
let frame = in_view(&view, &shape.frame);
|
||||
let size = theme.text_size(TextSize::Base).to_pixels(rem) * view.zoom;
|
||||
let placed = |text: &str| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(Pixels::from(frame.x))
|
||||
.top(Pixels::from(frame.y))
|
||||
.w(Pixels::from(frame.w))
|
||||
.h(Pixels::from(frame.h))
|
||||
.overflow_hidden()
|
||||
.text_size(size)
|
||||
.line_height(size * 1.3)
|
||||
.child(text.to_string())
|
||||
};
|
||||
match &shape.kind {
|
||||
ShapeKind::Text(text) => {
|
||||
Some(placed(text).text_color(colors.fg).into_any_element())
|
||||
}
|
||||
ShapeKind::Note(text) => {
|
||||
let paper = colors.hue(shape.hue, format_args!("note {}", shape.key));
|
||||
Some(
|
||||
placed(text)
|
||||
.p(sizes.note_inset.to_pixels(rem) * view.zoom)
|
||||
.rounded(sizes.note_corner.to_pixels(rem) * view.zoom)
|
||||
.bg(paper.alpha(0.22))
|
||||
.text_color(colors.fg)
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let tick = step(view.zoom, f32::from(sizes.label.to_pixels(rem)));
|
||||
let rulers = self.rulers.then(|| {
|
||||
let across = marks(left, right, tick).into_iter().map(|x| {
|
||||
words(format!("{}", x.round()), colors.fg_subtle)
|
||||
.left(Pixels::from(view.to_view((x, 0.0)).0))
|
||||
.top_0()
|
||||
.pl_1()
|
||||
.into_any_element()
|
||||
});
|
||||
let down = marks(top, bottom, tick).into_iter().map(|y| {
|
||||
words(format!("{}", y.round()), colors.fg_subtle)
|
||||
.left_0()
|
||||
.top(Pixels::from(view.to_view((0.0, y)).1))
|
||||
.pl_0p5()
|
||||
.into_any_element()
|
||||
});
|
||||
let strip = |element: gpui::Div| {
|
||||
element
|
||||
.absolute()
|
||||
.bg(colors.surface)
|
||||
.border_color(colors.border)
|
||||
};
|
||||
[
|
||||
strip(div().top_0().left_0().right_0().h(ruler).border_b_1())
|
||||
.children(across.collect::<Vec<_>>())
|
||||
.into_any_element(),
|
||||
strip(div().top_0().left_0().bottom_0().w(side).border_r_1())
|
||||
.children(down.collect::<Vec<_>>())
|
||||
.into_any_element(),
|
||||
]
|
||||
});
|
||||
let (shapes, artboards, guides, palette) =
|
||||
(self.shapes, self.artboards, self.guides, colors.clone());
|
||||
let painted = canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let origin = bounds.origin;
|
||||
for (x, y) in &dots {
|
||||
window.paint_quad(fill(
|
||||
Bounds::new(at(origin, (*x, *y)), size(dot, dot)),
|
||||
palette.border_strong,
|
||||
));
|
||||
}
|
||||
for board in &artboards {
|
||||
let frame = in_view(&view, &board.frame);
|
||||
window.paint_quad(
|
||||
fill(quad(origin, &frame), palette.surface)
|
||||
.border_widths(hairline)
|
||||
.border_color(palette.border),
|
||||
);
|
||||
}
|
||||
for shape in shapes.iter().filter(|shape| !shape.hidden) {
|
||||
paint_shape(shape, &view, origin, &palette, stroke, window);
|
||||
}
|
||||
let line = palette.hue(3, "guide");
|
||||
for guide in &guides {
|
||||
let (from, to) = match guide {
|
||||
Guide::Vertical(x) => {
|
||||
let x = view.to_view((*x, 0.0)).0;
|
||||
((x, 0.0), (x, h))
|
||||
}
|
||||
Guide::Horizontal(y) => {
|
||||
let y = view.to_view((0.0, *y)).1;
|
||||
((0.0, y), (w, y))
|
||||
}
|
||||
};
|
||||
finish(
|
||||
outline(&[from, to], origin, stroke / 2.0, false),
|
||||
line,
|
||||
window,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full();
|
||||
let measured = {
|
||||
let plane = plane.clone();
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if plane.read(cx).bounds != bounds {
|
||||
plane.update(cx, |plane, cx| {
|
||||
plane.bounds = bounds;
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
};
|
||||
let owner = plane.entity_id();
|
||||
let (wheel, pan, drop) = (set.clone(), set, plane.clone());
|
||||
let (scrolled, dragged, pressed) = (plane.clone(), plane.clone(), plane);
|
||||
div()
|
||||
.id(id)
|
||||
.relative()
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.bg(colors.sunken)
|
||||
.cursor_grab()
|
||||
.on_scroll_wheel(move |event, window, cx| {
|
||||
let bounds = scrolled.read(cx).bounds;
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(delta) => (f32::from(delta.x), f32::from(delta.y)),
|
||||
ScrollDelta::Lines(lines) => {
|
||||
let line = f32::from(window.line_height());
|
||||
(lines.x * line, lines.y * line)
|
||||
}
|
||||
};
|
||||
let pointer = event.position - bounds.origin;
|
||||
let about = (f32::from(pointer.x), f32::from(pointer.y));
|
||||
let now = view;
|
||||
let next = match event.modifiers.platform || event.modifiers.control {
|
||||
true => now.zoomed((-delta.1 / 240.0).exp(), about),
|
||||
false => now.panned((-delta.0, -delta.1)),
|
||||
};
|
||||
cx.stop_propagation();
|
||||
wheel(next, window, cx);
|
||||
})
|
||||
.on_mouse_down(gpui::MouseButton::Left, move |event, _, cx| {
|
||||
pressed.update(cx, |plane, _| plane.grip = Some(event.position))
|
||||
})
|
||||
.on_drag(Pan { owner }, |_, _, _, cx| cx.new(|_| EmptyView))
|
||||
.on_drag_move::<Pan>(move |event, window, cx| {
|
||||
if event.drag(cx).owner != owner {
|
||||
return;
|
||||
}
|
||||
let pointer = event.event.position;
|
||||
let last = dragged.update(cx, |plane, _| plane.grip.replace(pointer));
|
||||
if let Some(last) = last {
|
||||
let delta = pointer - last;
|
||||
pan(
|
||||
view.panned((f32::from(delta.x), f32::from(delta.y))),
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
}
|
||||
})
|
||||
.on_drop(move |_: &Pan, _, cx| drop.update(cx, |plane, _| plane.grip = None))
|
||||
.child(painted)
|
||||
.children(names)
|
||||
.children(texts)
|
||||
.children(self.layers)
|
||||
.children(rulers.into_iter().flatten())
|
||||
.child(measured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
use super::view::Frame;
|
||||
|
||||
/// What a shape is.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ShapeKind {
|
||||
Rect,
|
||||
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,
|
||||
Text(SharedString),
|
||||
/// Points in canvas units from the frame's top left.
|
||||
Path(Vec<(f32, f32)>),
|
||||
/// A square of paper with words on it.
|
||||
Note(SharedString),
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub kind: ShapeKind,
|
||||
pub frame: Frame,
|
||||
pub hue: usize,
|
||||
pub hidden: bool,
|
||||
pub locked: bool,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
pub fn new(
|
||||
key: impl Into<SharedString>,
|
||||
name: impl Into<SharedString>,
|
||||
kind: ShapeKind,
|
||||
frame: Frame,
|
||||
) -> Self {
|
||||
if let ShapeKind::Polygon(sides) = kind {
|
||||
assert!(sides >= 3, "a polygon of {sides} sides");
|
||||
}
|
||||
Self {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
kind,
|
||||
frame,
|
||||
hue: 0,
|
||||
hidden: false,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hue(mut self, hue: usize) -> Self {
|
||||
self.hue = hue;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn hidden(mut self, hidden: bool) -> Self {
|
||||
self.hidden = hidden;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn locked(mut self, locked: bool) -> Self {
|
||||
self.locked = locked;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A named board on the canvas that designs sit on.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Artboard {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub frame: Frame,
|
||||
}
|
||||
|
||||
impl Artboard {
|
||||
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, frame: Frame) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
name: name.into(),
|
||||
frame,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A line across the canvas to line things up with.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Guide {
|
||||
/// At this canvas x.
|
||||
Vertical(f32),
|
||||
/// At this canvas y.
|
||||
Horizontal(f32),
|
||||
}
|
||||
|
||||
/// The corners of a regular polygon of `sides` in `frame`, the first at the top.
|
||||
pub(crate) fn polygon(frame: &Frame, sides: u8) -> Vec<(f32, f32)> {
|
||||
let (cx, cy) = frame.center();
|
||||
(0..sides)
|
||||
.map(|ix| {
|
||||
let turn = std::f32::consts::TAU * f32::from(ix) / f32::from(sides)
|
||||
- std::f32::consts::FRAC_PI_2;
|
||||
(
|
||||
cx + frame.w / 2.0 * turn.cos(),
|
||||
cy + frame.h / 2.0 * turn.sin(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frame, polygon};
|
||||
|
||||
#[test]
|
||||
fn a_polygon_starts_at_the_top_and_goes_round() {
|
||||
let corners = polygon(&Frame::new(0.0, 0.0, 100.0, 100.0), 4);
|
||||
let round = |(x, y): (f32, f32)| (x.round(), y.round());
|
||||
assert_eq!(
|
||||
corners.into_iter().map(round).collect::<Vec<_>>(),
|
||||
[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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};
|
||||
|
||||
/// A view that shows one canvas part and keeps the viewports it heard.
|
||||
struct Stage {
|
||||
part: fn(Entity<Stage>) -> AnyElement,
|
||||
heard: Vec<Viewport>,
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
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 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:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/// A box on the canvas, in canvas units.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Frame {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
|
||||
assert!(w >= 0.0 && h >= 0.0, "a frame of {w} by {h}");
|
||||
Self { x, y, w, h }
|
||||
}
|
||||
|
||||
pub fn right(&self) -> f32 {
|
||||
self.x + self.w
|
||||
}
|
||||
|
||||
pub fn bottom(&self) -> f32 {
|
||||
self.y + self.h
|
||||
}
|
||||
|
||||
pub fn center(&self) -> (f32, f32) {
|
||||
(self.x + self.w / 2.0, self.y + self.h / 2.0)
|
||||
}
|
||||
|
||||
/// The least frame that holds both.
|
||||
pub fn union(&self, other: &Frame) -> Frame {
|
||||
let (x, y) = (self.x.min(other.x), self.y.min(other.y));
|
||||
Frame::new(
|
||||
x,
|
||||
y,
|
||||
self.right().max(other.right()) - x,
|
||||
self.bottom().max(other.bottom()) - y,
|
||||
)
|
||||
}
|
||||
|
||||
/// The frame spanned by two corners, in any order.
|
||||
pub fn spanning((ax, ay): (f32, f32), (bx, by): (f32, f32)) -> Frame {
|
||||
Frame::new(ax.min(bx), ay.min(by), (ax - bx).abs(), (ay - by).abs())
|
||||
}
|
||||
|
||||
/// Whether it lies wholly inside `other`.
|
||||
pub fn within(&self, other: &Frame) -> bool {
|
||||
self.x >= other.x
|
||||
&& self.y >= other.y
|
||||
&& self.right() <= other.right()
|
||||
&& self.bottom() <= other.bottom()
|
||||
}
|
||||
|
||||
pub fn contains(&self, (x, y): (f32, f32)) -> bool {
|
||||
x >= self.x && x <= self.right() && y >= self.y && y <= self.bottom()
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a canvas looks: the canvas point at the view's top left, and its zoom.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Viewport {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub zoom: f32,
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
/// The farthest out and the nearest in a canvas zooms.
|
||||
pub const ZOOMS: (f32, f32) = (0.1, 8.0);
|
||||
|
||||
pub fn new(x: f32, y: f32, zoom: f32) -> Self {
|
||||
let (least, most) = Self::ZOOMS;
|
||||
assert!(
|
||||
(least..=most).contains(&zoom),
|
||||
"zoom {zoom} past {least} to {most}"
|
||||
);
|
||||
Self { x, y, zoom }
|
||||
}
|
||||
|
||||
/// A canvas point in view pixels from the view's top left.
|
||||
pub fn to_view(&self, (x, y): (f32, f32)) -> (f32, f32) {
|
||||
((x - self.x) * self.zoom, (y - self.y) * self.zoom)
|
||||
}
|
||||
|
||||
/// A point in the view as a canvas point.
|
||||
pub fn to_canvas(&self, (x, y): (f32, f32)) -> (f32, f32) {
|
||||
(x / self.zoom + self.x, y / self.zoom + self.y)
|
||||
}
|
||||
|
||||
/// Zoomed by `factor` about a view point, which keeps its canvas point; held inside the zooms.
|
||||
pub fn zoomed(self, factor: f32, about: (f32, f32)) -> Viewport {
|
||||
let (least, most) = Self::ZOOMS;
|
||||
let zoom = (self.zoom * factor).clamp(least, most);
|
||||
let (x, y) = self.to_canvas(about);
|
||||
Viewport::new(x - about.0 / zoom, y - about.1 / zoom, zoom)
|
||||
}
|
||||
|
||||
/// Moved so the content slides by a view distance.
|
||||
pub fn panned(self, (dx, dy): (f32, f32)) -> Viewport {
|
||||
Viewport::new(self.x - dx / self.zoom, self.y - dy / self.zoom, self.zoom)
|
||||
}
|
||||
|
||||
/// Shows `frame` whole and centered in a view of `size`, `margin` view pixels clear each side, no nearer than a zoom of one.
|
||||
pub fn fitting(frame: Frame, (w, h): (f32, f32), margin: f32) -> Viewport {
|
||||
let (least, _) = Self::ZOOMS;
|
||||
let room = ((w - margin * 2.0).max(1.0), (h - margin * 2.0).max(1.0));
|
||||
let zoom = (room.0 / frame.w.max(1.0))
|
||||
.min(room.1 / frame.h.max(1.0))
|
||||
.clamp(least, 1.0);
|
||||
let (cx, cy) = frame.center();
|
||||
Viewport::new(cx - w / 2.0 / zoom, cy - h / 2.0 / zoom, zoom)
|
||||
}
|
||||
}
|
||||
|
||||
/// The canvas step between marks at `zoom`: the least of one, two or five times a power of ten that sets marks `least` view pixels apart or more.
|
||||
pub(crate) fn step(zoom: f32, least: f32) -> f32 {
|
||||
let raw = least / zoom;
|
||||
let power = 10f32.powf(raw.log10().floor());
|
||||
[1.0, 2.0, 5.0, 10.0]
|
||||
.into_iter()
|
||||
.map(|each| each * power)
|
||||
.find(|each| *each >= raw)
|
||||
.expect("ten times the power passes the raw step")
|
||||
}
|
||||
|
||||
/// Canvas marks every `step` from `from` through `to`.
|
||||
pub(crate) fn marks(from: f32, to: f32, step: f32) -> Vec<f32> {
|
||||
let first = (from / step).ceil() as i64;
|
||||
let last = (to / step).floor() as i64;
|
||||
(first..=last).map(|ix| ix as f32 * step).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Frame, Viewport, marks, step};
|
||||
|
||||
#[test]
|
||||
fn a_point_keeps_its_place_as_the_view_zooms_about_it() {
|
||||
let view = Viewport::new(100.0, 50.0, 1.0);
|
||||
let about = (200.0, 120.0);
|
||||
let held = view.to_canvas(about);
|
||||
let near = view.zoomed(2.0, about);
|
||||
assert_eq!(near.zoom, 2.0);
|
||||
assert_eq!(near.to_canvas(about), held);
|
||||
assert_eq!(
|
||||
view.zoomed(1000.0, about).zoom,
|
||||
8.0,
|
||||
"held at the nearest zoom"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pan_slides_the_content_with_the_pointer() {
|
||||
let view = Viewport::new(0.0, 0.0, 2.0).panned((40.0, -20.0));
|
||||
assert_eq!((view.x, view.y), (-20.0, 10.0));
|
||||
assert_eq!(view.to_view((0.0, 0.0)), (40.0, -20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fit_shows_the_frame_whole_and_centered() {
|
||||
let view = Viewport::fitting(Frame::new(0.0, 0.0, 800.0, 400.0), (440.0, 440.0), 20.0);
|
||||
assert_eq!(view.zoom, 0.5);
|
||||
assert_eq!(view.to_view((400.0, 200.0)), (220.0, 220.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marks_step_by_one_two_or_five_and_stay_apart() {
|
||||
assert_eq!(step(1.0, 16.0), 20.0);
|
||||
assert_eq!(step(0.5, 16.0), 50.0);
|
||||
assert_eq!(step(4.0, 16.0), 5.0);
|
||||
assert_eq!(marks(-15.0, 45.0, 20.0), [0.0, 20.0, 40.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
|
||||
|
||||
use super::view::Viewport;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::Run,
|
||||
primitives::IconName,
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
type OnZoom = Rc<dyn Fn(f32, &mut Window, &mut App)>;
|
||||
|
||||
/// The zooms the buttons step through.
|
||||
pub(crate) const STEPS: [f32; 11] = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 8.0];
|
||||
|
||||
/// The step past `zoom` going in, or out, if there is one.
|
||||
pub(crate) fn stepped(zoom: f32, inward: bool) -> Option<f32> {
|
||||
match inward {
|
||||
true => STEPS.into_iter().find(|step| *step > zoom + 0.001),
|
||||
false => STEPS.into_iter().rev().find(|step| *step < zoom - 0.001),
|
||||
}
|
||||
}
|
||||
|
||||
/// A zoom in percent; a press sets it back to 100%.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ZoomIndicator {
|
||||
id: ElementId,
|
||||
zoom: f32,
|
||||
on_zoom: Option<OnZoom>,
|
||||
}
|
||||
|
||||
impl ZoomIndicator {
|
||||
pub fn new(id: impl Into<ElementId>, zoom: f32) -> Self {
|
||||
let (least, most) = Viewport::ZOOMS;
|
||||
assert!((least..=most).contains(&zoom), "zoom {zoom}");
|
||||
Self {
|
||||
id: id.into(),
|
||||
zoom,
|
||||
on_zoom: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the zoom asked for, one on a press.
|
||||
pub fn on_zoom(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_zoom = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ZoomIndicator {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let words = format!("{}%", (self.zoom * 100.0).round());
|
||||
let on_zoom = self.on_zoom;
|
||||
Button::new(self.id, words)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("zoom indicator: back to 100%");
|
||||
if let Some(on_zoom) = &on_zoom {
|
||||
on_zoom(1.0, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Out, the zoom in percent, and in, through fixed steps; with `on_fit`, a button that fits what lies on the canvas.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ZoomControls {
|
||||
id: ElementId,
|
||||
zoom: f32,
|
||||
on_zoom: Option<OnZoom>,
|
||||
on_fit: Option<Run>,
|
||||
}
|
||||
|
||||
impl ZoomControls {
|
||||
pub fn new(id: impl Into<ElementId>, zoom: f32) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
zoom,
|
||||
on_zoom: None,
|
||||
on_fit: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets each zoom asked for.
|
||||
pub fn on_zoom(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_zoom = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_fit(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_fit = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ZoomControls {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
let (id, zoom) = (self.id.clone(), self.zoom);
|
||||
let button = |inward: bool| {
|
||||
let next = stepped(zoom, inward);
|
||||
let on_zoom = self.on_zoom.clone();
|
||||
let (icon, words) = match inward {
|
||||
true => (IconName::ZoomIn, "Zoom in"),
|
||||
false => (IconName::ZoomOut, "Zoom out"),
|
||||
};
|
||||
IconButton::new((id.clone(), words), icon)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip(words)
|
||||
.disabled(next.is_none())
|
||||
.on_click(move |_, window, cx| {
|
||||
let next = next.expect("a step lies that way");
|
||||
log::info!("zoom controls: {}%", (next * 100.0).round());
|
||||
if let Some(on_zoom) = &on_zoom {
|
||||
on_zoom(next, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let indicator = {
|
||||
let on_zoom = self.on_zoom.clone();
|
||||
ZoomIndicator::new((id.clone(), "level"), zoom).on_zoom(move |zoom, window, cx| {
|
||||
if let Some(on_zoom) = &on_zoom {
|
||||
on_zoom(zoom, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let fit = self.on_fit.map(|on_fit| {
|
||||
IconButton::new((id.clone(), "fit"), IconName::Maximize2)
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.size(ControlSize::Sm)
|
||||
.tooltip("Fit to screen")
|
||||
.on_click(move |_, window, cx| on_fit(window, cx))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.child(button(false))
|
||||
.child(indicator)
|
||||
.child(button(true))
|
||||
.children(fit)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::stepped;
|
||||
|
||||
#[test]
|
||||
fn zoom_steps_go_to_the_next_stop_and_end_at_the_last() {
|
||||
assert_eq!(stepped(1.0, true), Some(1.5));
|
||||
assert_eq!(stepped(1.2, false), Some(1.0));
|
||||
assert_eq!(stepped(8.0, true), None);
|
||||
assert_eq!(stepped(0.1, false), None);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ mod assets;
|
||||
|
||||
pub mod buttons;
|
||||
pub mod calendar;
|
||||
pub mod canvas;
|
||||
pub mod charts;
|
||||
pub mod chat;
|
||||
pub mod collab;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use gpui::{Pixels, Rems, px};
|
||||
|
||||
use super::{Theme, tokens::px_to_rems};
|
||||
|
||||
/// Canvas measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CanvasSizes {
|
||||
/// The top ruler, tall.
|
||||
pub ruler: Rems,
|
||||
/// The left ruler, wide enough for four figures.
|
||||
pub side: Rems,
|
||||
/// Grid dots at least this far apart on screen.
|
||||
pub grid: Rems,
|
||||
/// Ruler labels at least this far apart on screen.
|
||||
pub label: Rems,
|
||||
/// A grid dot, square.
|
||||
pub dot: Pixels,
|
||||
/// Lines, wide.
|
||||
pub stroke: Rems,
|
||||
/// Frames and outlines, wide.
|
||||
pub hairline: Pixels,
|
||||
/// A note's inset and corner at a zoom of one.
|
||||
pub note_inset: Rems,
|
||||
pub note_corner: Rems,
|
||||
/// A minimap, wide and tall.
|
||||
pub map: (Rems, Rems),
|
||||
/// A handle, square.
|
||||
pub handle: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn canvas(&self) -> CanvasSizes {
|
||||
CanvasSizes {
|
||||
ruler: px_to_rems(20.0),
|
||||
side: px_to_rems(32.0),
|
||||
grid: px_to_rems(16.0),
|
||||
label: px_to_rems(64.0),
|
||||
dot: px(1.5),
|
||||
stroke: px_to_rems(1.5),
|
||||
hairline: px(1.0),
|
||||
note_inset: px_to_rems(12.0),
|
||||
note_corner: px_to_rems(4.0),
|
||||
map: (px_to_rems(200.0), px_to_rems(140.0)),
|
||||
handle: px_to_rems(8.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod calendar;
|
||||
mod canvas;
|
||||
mod chart;
|
||||
mod files;
|
||||
mod generative;
|
||||
@@ -14,6 +15,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
|
||||
|
||||
use crate::motion;
|
||||
pub use calendar::CalendarSizes;
|
||||
pub use canvas::CanvasSizes;
|
||||
pub use chart::ChartSizes;
|
||||
pub use files::FileSizes;
|
||||
pub use generative::GenerativeSizes;
|
||||
|
||||
+21
-12
@@ -33,26 +33,33 @@
|
||||
- [x] Pomodoro Timer
|
||||
- [x] DatabaseView (from T21c) — table, board, gallery, calendar, timeline
|
||||
|
||||
## T32 Canvas & Design — `src/canvas`
|
||||
## T32a Canvas · Plane — `src/canvas`
|
||||
|
||||
- [x] InfiniteCanvas
|
||||
- [x] CanvasGrid / Guides
|
||||
- [x] Ruler
|
||||
- [x] ZoomControls / ZoomIndicator
|
||||
- [x] MiniMap
|
||||
- [x] ArtboardFrame
|
||||
|
||||
## T32b Canvas · Tools
|
||||
|
||||
- [ ] InfiniteCanvas
|
||||
- [ ] CanvasGrid / Guides
|
||||
- [ ] Ruler
|
||||
- [ ] SelectionBox
|
||||
- [ ] TransformHandles
|
||||
- [ ] SnapIndicator
|
||||
- [ ] LayerPanel
|
||||
- [ ] AssetPanel
|
||||
- [ ] InspectorPanel
|
||||
- [ ] AlignmentToolbar
|
||||
- [ ] ToolPalette
|
||||
- [ ] ZoomControls / ZoomIndicator
|
||||
- [ ] MiniMap
|
||||
- [ ] ArtboardFrame
|
||||
- [ ] ShapeTools — rect, ellipse, polygon, line, arrow
|
||||
- [ ] PenTool / PathEditor
|
||||
- [ ] TextTool
|
||||
- [ ] BrushTool / BrushSettings
|
||||
- [ ] HistoryPanel
|
||||
|
||||
## T32c Canvas · Panels
|
||||
|
||||
- [ ] LayerPanel
|
||||
- [ ] AssetPanel
|
||||
- [ ] InspectorPanel
|
||||
- [ ] AlignmentToolbar
|
||||
- [ ] ColorPanel
|
||||
- [ ] GradientPanel → `forms::GradientEditor`
|
||||
- [ ] ShadowEditor
|
||||
@@ -61,6 +68,9 @@
|
||||
- [ ] ConstraintsEditor
|
||||
- [ ] AutoLayoutControls
|
||||
- [ ] ExportPanel
|
||||
|
||||
## T32d Canvas · Graphs and boards
|
||||
|
||||
- [ ] NodeEditor / NodeGraph
|
||||
- [ ] Node / Port / Edge / Connection
|
||||
- [ ] FlowChart / DiagramEditor
|
||||
@@ -68,7 +78,6 @@
|
||||
- [ ] Whiteboard
|
||||
- [ ] StickyNote
|
||||
- [ ] Connector Line
|
||||
- [ ] HistoryPanel
|
||||
|
||||
## T33 DB & Dev Tools — `src/devtools`
|
||||
|
||||
|
||||
Reference in new issue
Block a user