From 07b875b0d35e06d3aac5ad5ec0e304c8724940a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 07:58:27 -0400 Subject: [PATCH] =?UTF-8?q?Canvas=20=C2=B7=20Panels:=20layers,=20inspector?= =?UTF-8?q?,=20alignment,=20assets,=20style=20and=20layout=20editors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LayerPanel lists the canvas's layers topmost first on lists::Tree: a box shows or hides each, F2 renames, a drag restacks into a new paint order. InspectorPanel edits the one selected shape's frame, fill, visibility and lock. AlignmentToolbar lines up or spreads the selection through `aligned`. AssetPanel finds shapes by name in a picks grid, each drawn to fit its tile. ColorPanel, ShadowEditor, BorderEditor and TypographyPanel edit a look; GradientPanel is forms::GradientEditor. ConstraintsEditor pins a shape to its frame, with `Constraints::placed`; AutoLayoutControls sets flow, gap, padding and a place grid, with `AutoLayout::laid`; ExportPanel names each file once. The panels share `canvas::panel`: editing, pair, caption, heading and color_well. --- AGENTS.md | 2 + assets/icons/align-center-horizontal.svg | 19 + assets/icons/align-center-vertical.svg | 19 + assets/icons/align-end-horizontal.svg | 17 + assets/icons/align-end-vertical.svg | 17 + .../align-horizontal-distribute-center.svg | 20 + assets/icons/align-start-horizontal.svg | 17 + assets/icons/align-start-vertical.svg | 17 + .../align-vertical-distribute-center.svg | 20 + assets/icons/square-round-corner.svg | 16 + assets/icons/sticky-note.svg | 16 + examples/gallery/pages/canvas/looks.rs | 317 +++++++++++++++ examples/gallery/pages/canvas/mod.rs | 5 + examples/gallery/pages/canvas/panels.rs | 188 +++++++++ examples/gallery/pages/canvas/tools.rs | 12 +- src/canvas/align.rs | 222 +++++++++++ src/canvas/assets.rs | 136 +++++++ src/canvas/autolayout.rs | 315 +++++++++++++++ src/canvas/constraints.rs | 267 +++++++++++++ src/canvas/export.rs | 281 +++++++++++++ src/canvas/inspector.rs | 143 +++++++ src/canvas/layers.rs | 180 +++++++++ src/canvas/mod.rs | 19 + src/canvas/panel.rs | 94 +++++ src/canvas/shape.rs | 31 ++ src/canvas/style.rs | 372 ++++++++++++++++++ src/canvas/tests/mod.rs | 2 + src/canvas/tests/panels.rs | 137 +++++++ src/canvas/typography.rs | 186 +++++++++ src/primitives/icon.rs | 10 + src/theme/canvas.rs | 15 + tasks/ch31-43.md | 24 +- 32 files changed, 3118 insertions(+), 18 deletions(-) create mode 100644 assets/icons/align-center-horizontal.svg create mode 100644 assets/icons/align-center-vertical.svg create mode 100644 assets/icons/align-end-horizontal.svg create mode 100644 assets/icons/align-end-vertical.svg create mode 100644 assets/icons/align-horizontal-distribute-center.svg create mode 100644 assets/icons/align-start-horizontal.svg create mode 100644 assets/icons/align-start-vertical.svg create mode 100644 assets/icons/align-vertical-distribute-center.svg create mode 100644 assets/icons/square-round-corner.svg create mode 100644 assets/icons/sticky-note.svg create mode 100644 examples/gallery/pages/canvas/looks.rs create mode 100644 examples/gallery/pages/canvas/panels.rs create mode 100644 src/canvas/align.rs create mode 100644 src/canvas/assets.rs create mode 100644 src/canvas/autolayout.rs create mode 100644 src/canvas/constraints.rs create mode 100644 src/canvas/export.rs create mode 100644 src/canvas/inspector.rs create mode 100644 src/canvas/layers.rs create mode 100644 src/canvas/panel.rs create mode 100644 src/canvas/style.rs create mode 100644 src/canvas/tests/panels.rs create mode 100644 src/canvas/typography.rs diff --git a/AGENTS.md b/AGENTS.md index a1366bd..c2dad1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - The focus color belongs to the focus ring. A chosen or shown item takes accent, as `forms::picks` and `lists::ListItem` do. - A box that shows a host's picture takes the picture's shape from the host (`ratio`), so nothing is cropped and a point given as a share lands on the picture. A focusable one keeps its focus border on an outer box, so the picture keeps that shape exactly (`media::ImageCropper`). - A default only the component can work out, such as a crop fitted to a picture it decodes, reaches the owner once through `window.defer` after render (`media::ImageUpload`). +- A ScrubInput shows its number in about 140px beside a short label. In a narrower column a pair hides it, so a long label takes a row of its own (`canvas::TypographyPanel`). +- Design panels edit a whole value and hand the owner a changed copy through `canvas::panel::editing`. A swatch that opens a picker is `canvas::panel::color_well`. - A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`). - A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue. - A choice that names a person leads with their initials through `forms::Choice::avatar`, in the list and in a Select's trigger alike (`project::AssigneePicker`). diff --git a/assets/icons/align-center-horizontal.svg b/assets/icons/align-center-horizontal.svg new file mode 100644 index 0000000..91af229 --- /dev/null +++ b/assets/icons/align-center-horizontal.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/align-center-vertical.svg b/assets/icons/align-center-vertical.svg new file mode 100644 index 0000000..5363c76 --- /dev/null +++ b/assets/icons/align-center-vertical.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/assets/icons/align-end-horizontal.svg b/assets/icons/align-end-horizontal.svg new file mode 100644 index 0000000..c41e712 --- /dev/null +++ b/assets/icons/align-end-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-end-vertical.svg b/assets/icons/align-end-vertical.svg new file mode 100644 index 0000000..e049671 --- /dev/null +++ b/assets/icons/align-end-vertical.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-horizontal-distribute-center.svg b/assets/icons/align-horizontal-distribute-center.svg new file mode 100644 index 0000000..7fb870d --- /dev/null +++ b/assets/icons/align-horizontal-distribute-center.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/align-start-horizontal.svg b/assets/icons/align-start-horizontal.svg new file mode 100644 index 0000000..2846673 --- /dev/null +++ b/assets/icons/align-start-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-start-vertical.svg b/assets/icons/align-start-vertical.svg new file mode 100644 index 0000000..040b338 --- /dev/null +++ b/assets/icons/align-start-vertical.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/assets/icons/align-vertical-distribute-center.svg b/assets/icons/align-vertical-distribute-center.svg new file mode 100644 index 0000000..a646479 --- /dev/null +++ b/assets/icons/align-vertical-distribute-center.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/square-round-corner.svg b/assets/icons/square-round-corner.svg new file mode 100644 index 0000000..313be68 --- /dev/null +++ b/assets/icons/square-round-corner.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/assets/icons/sticky-note.svg b/assets/icons/sticky-note.svg new file mode 100644 index 0000000..f339042 --- /dev/null +++ b/assets/icons/sticky-note.svg @@ -0,0 +1,16 @@ + + + + + diff --git a/examples/gallery/pages/canvas/looks.rs b/examples/gallery/pages/canvas/looks.rs new file mode 100644 index 0000000..e82841d --- /dev/null +++ b/examples/gallery/pages/canvas/looks.rs @@ -0,0 +1,317 @@ +use ely_gpui_component::{ + canvas::{ + AutoLayout, AutoLayoutControls, Border, BorderEditor, ColorPanel, Constraints, + ConstraintsEditor, Dash, ExportFormat, ExportPanel, ExportSetting, Flow, Frame, Pin, Place, + Shadow, ShadowEditor, TypeStyle, TypographyPanel, + }, + forms::{GradientEditor, GradientStop, TextInput}, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{ + App, BoxShadow, FontWeight, Hsla, IntoElement, ParentElement, SharedString, Styled, TextAlign, + Window, div, point, prelude::*, px, rgb, rgba, +}; + +use crate::ui::{change, keep, section}; + +/// The style demo's values. +struct Look { + color: Hsla, + stops: Vec, + shadows: Vec, + border: Border, + style: TypeStyle, +} + +impl Look { + fn new() -> Self { + Self { + color: rgb(0xf4efe6).into(), + stops: vec![ + GradientStop { + at: 0.0, + color: rgb(0xe9e4da).into(), + }, + GradientStop { + at: 1.0, + color: rgb(0x8a7f6d).into(), + }, + ], + shadows: vec![Shadow { + x: 0.0, + y: 8.0, + blur: 24.0, + spread: 0.0, + color: rgba(0x1816132e).into(), + }], + border: Border { + width: 1.0, + dash: Dash::Solid, + color: rgb(0xd8d1c4).into(), + radii: [12.0; 4], + }, + style: TypeStyle { + family: "Inter".into(), + weight: FontWeight::MEDIUM, + size: 20.0, + line_height: 1.3, + tracking: 0.0, + align: TextAlign::Left, + }, + } + } +} + +const SWATCHES: [(&str, u32); 6] = [ + ("Paper", 0xf4efe6), + ("Sand", 0xe9e4da), + ("Stone", 0xb8ad9b), + ("Moss", 0x7d8a6a), + ("Ink", 0x2b2a27), + ("Clay", 0xb86f50), +]; + +pub fn styles(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let look = keep("canvas-look", Look::new, window, cx); + let search = window.use_keyed_state("canvas-type-search", cx, |window, cx| { + TextInput::new(window, cx).placeholder("Font") + }); + let now = look.read(cx); + let (color, stops, shadows, border, style) = ( + now.color, + now.stops.clone(), + now.shadows.clone(), + now.border, + now.style.clone(), + ); + let [colored, graded, shaded, bordered, typed] = [(); 5].map(|_| look.clone()); + let [r0, r1, r2, r3] = border.radii; + let card = div() + .w(px(200.)) + .h(px(120.)) + .bg(color) + .border(px(border.width)) + .border_color(border.color) + .when(border.dash != Dash::Solid, |card| card.border_dashed()) + .rounded_tl(px(r0)) + .rounded_tr(px(r1)) + .rounded_br(px(r2)) + .rounded_bl(px(r3)) + .shadow( + shadows + .iter() + .map(|shadow| BoxShadow { + color: shadow.color, + offset: point(px(shadow.x), px(shadow.y)), + blur_radius: px(shadow.blur), + spread_radius: px(shadow.spread), + }) + .collect(), + ); + section( + "ColorPanel · GradientPanel · ShadowEditor · BorderEditor · TypographyPanel", + "What a shape looks like. The color panel sets its fill, over the document's colors; the gradient panel is forms::GradientEditor; shadows stack; a border's corners join or split; the type panel sets a style over a line of it. The card takes each edit.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + div().w(px(260.)).child( + ColorPanel::new("canvas-color", color) + .swatches(SWATCHES.map(|(name, hex)| (name, Hsla::from(rgb(hex))))) + .on_change(move |next, _, cx| change(&colored, cx, |look| look.color = next)), + ), + ) + .child( + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_6() + .child( + GradientEditor::new("canvas-gradient", stops) + .on_change(move |next, _, cx| change(&graded, cx, |look| look.stops = next.to_vec())), + ) + .child( + ShadowEditor::new("canvas-shadows", shadows) + .on_change(move |next, _, cx| change(&shaded, cx, |look| look.shadows = next)), + ), + ) + .child( + div() + .w(px(260.)) + .flex() + .flex_col() + .gap_6() + .child( + BorderEditor::new("canvas-border", border) + .on_change(move |next, _, cx| change(&bordered, cx, |look| look.border = next)), + ) + .child(div().p_6().child(card)), + ) + .child( + div().w(px(280.)).child( + TypographyPanel::new("canvas-type", style, &search) + .on_change(move |next, _, cx| change(&typed, cx, |look| look.style = next)), + ), + ), + ) +} + +/// The layout demo's values. +struct Rules { + constraints: Constraints, + layout: AutoLayout, + exports: Vec, + exported: Vec, +} + +impl Rules { + fn new() -> Self { + Self { + constraints: Constraints { + across: Pin::Both, + down: Pin::Start, + }, + layout: AutoLayout { + flow: Flow::Row, + gap: 12.0, + padding: (16.0, 16.0), + place: (Place::Start, Place::Center), + }, + exports: vec![ + ExportSetting { + scale: 1.0, + format: ExportFormat::Png, + }, + ExportSetting { + scale: 2.0, + format: ExportFormat::Png, + }, + ExportSetting { + scale: 1.0, + format: ExportFormat::Svg, + }, + ], + exported: Vec::new(), + } + } +} + +/// A frame in a preview, in its own pixels. +fn boxed(frame: Frame) -> gpui::Div { + div() + .absolute() + .left(px(frame.x)) + .top(px(frame.y)) + .w(px(frame.w)) + .h(px(frame.h)) +} + +pub fn rules(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let rules = keep("canvas-rules", Rules::new, window, cx); + let now = rules.read(cx); + let (constraints, layout, exports, exported) = ( + now.constraints, + now.layout, + now.exports.clone(), + now.exported.clone(), + ); + let [pinned, laid, set, sent] = [(); 4].map(|_| rules.clone()); + let theme = cx.theme(); + let (line, fill, quiet) = ( + theme.colors.border, + theme.colors.active, + theme.colors.fg_subtle, + ); + let (before, child) = ( + Frame::new(0.0, 0.0, 120.0, 80.0), + Frame::new(16.0, 16.0, 56.0, 24.0), + ); + let grown = Frame::new(0.0, 0.0, 240.0, 120.0); + let pinned_preview = div() + .relative() + .w(px(grown.w)) + .h(px(grown.h)) + .border_1() + .border_color(line) + .child(boxed(before).border_1().border_color(line).border_dashed()) + .child( + boxed(constraints.placed(child, before, grown)) + .bg(fill) + .border_1() + .border_color(quiet), + ); + let room = Frame::new(0.0, 0.0, 240.0, 120.0); + let laid_preview = div() + .relative() + .w(px(room.w)) + .h(px(room.h)) + .border_1() + .border_color(line) + .children( + layout + .laid(room, &[(40.0, 24.0), (56.0, 40.0), (32.0, 32.0)]) + .into_iter() + .map(|frame| boxed(frame).bg(fill).border_1().border_color(quiet)), + ); + let note = |text: SharedString| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(quiet) + .child(text) + }; + section( + "ConstraintsEditor · AutoLayoutControls · ExportPanel", + "How a shape keeps to its frame and lays out its children, and what it exports to. The first box shows where a child pinned so lands once its frame grows from the dashed size; the second lays three children by the controls; export names each file once.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child( + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_3() + .child( + ConstraintsEditor::new("canvas-constraints", constraints) + .on_change(move |next, _, cx| change(&pinned, cx, |rules| rules.constraints = next)), + ) + .child(pinned_preview), + ) + .child( + div() + .w(px(280.)) + .flex() + .flex_col() + .gap_3() + .child( + AutoLayoutControls::new("canvas-auto-layout", layout) + .on_change(move |next, _, cx| change(&laid, cx, |rules| rules.layout = next)), + ) + .child(laid_preview), + ) + .child( + div() + .w(px(260.)) + .flex() + .flex_col() + .gap_3() + .child( + ExportPanel::new("canvas-export", "Card", exports) + .on_change(move |next, _, cx| change(&set, cx, |rules| rules.exports = next)) + .on_export(move |files, _, cx| change(&sent, cx, |rules| rules.exported = files)), + ) + .when(!exported.is_empty(), |panel| { + panel.child(note(format!("Exported {}", exported.join(", ")).into())) + }), + ), + ) +} diff --git a/examples/gallery/pages/canvas/mod.rs b/examples/gallery/pages/canvas/mod.rs index 7fe2f0e..2e0e958 100644 --- a/examples/gallery/pages/canvas/mod.rs +++ b/examples/gallery/pages/canvas/mod.rs @@ -1,5 +1,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*}; +mod looks; +mod panels; mod plane; mod tools; @@ -42,5 +44,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(plane::plane(window, cx)) .child(tools::tools(window, cx)) + .child(panels::arrange(window, cx)) + .child(looks::styles(window, cx)) + .child(looks::rules(window, cx)) .into_any_element() } diff --git a/examples/gallery/pages/canvas/panels.rs b/examples/gallery/pages/canvas/panels.rs new file mode 100644 index 0000000..3a0247b --- /dev/null +++ b/examples/gallery/pages/canvas/panels.rs @@ -0,0 +1,188 @@ +use ely_gpui_component::canvas::{ + AlignmentToolbar, AssetPanel, Corner, Frame, InspectorPanel, LayerPanel, Shape, ShapeKind, + aligned, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use super::tools::studio; +use crate::ui::{change, section}; + +fn asset( + key: &'static str, + name: &'static str, + kind: ShapeKind, + w: f32, + h: f32, + hue: usize, +) -> Shape { + Shape::new(key, name, kind, Frame::new(0.0, 0.0, w, h)).hue(hue) +} + +fn shapes() -> Vec { + vec![ + asset("button", "Button", ShapeKind::Rect, 120.0, 40.0, 0), + asset("dot", "Dot", ShapeKind::Ellipse, 40.0, 40.0, 1), + asset("hexagon", "Hexagon", ShapeKind::Polygon(6), 60.0, 60.0, 2), + asset( + "pointer", + "Pointer", + ShapeKind::Arrow(Corner::BottomRight), + 80.0, + 40.0, + 3, + ), + asset( + "divider", + "Divider", + ShapeKind::Line(Corner::TopRight), + 120.0, + 0.0, + 4, + ), + asset( + "heading", + "Heading", + ShapeKind::Text("Heading".into()), + 160.0, + 32.0, + 5, + ), + ] +} + +fn notes() -> Vec { + vec![ + asset( + "idea", + "Idea", + ShapeKind::Note("An idea".into()), + 120.0, + 120.0, + 6, + ), + asset( + "todo", + "To do", + ShapeKind::Note("To do".into()), + 120.0, + 120.0, + 7, + ), + ] +} + +pub fn arrange(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let studio = studio(window, cx); + let now = studio.read(cx); + let (shapes_now, selected) = (now.shapes.clone(), now.selected.clone()); + let chosen: Vec = shapes_now + .iter() + .filter(|shape| selected.contains(&shape.key)) + .cloned() + .collect(); + let [picked, shown, named, stacked, lined, edited, placed] = [(); 7].map(|_| studio.clone()); + let layers = LayerPanel::new("canvas-layers", shapes_now) + .selected(selected.clone()) + .on_select(move |keys, _, cx| change(&picked, cx, |studio| studio.selected = keys.to_vec())) + .on_show(move |keys, _, cx| { + change(&shown, cx, |studio| { + for shape in &mut studio.shapes { + shape.hidden = !keys.contains(&shape.key); + } + studio.record("Show or hide".into()); + }) + }) + .on_rename(move |key, name, _, cx| { + change(&named, cx, |studio| { + let shape = studio + .shapes + .iter_mut() + .find(|shape| shape.key == *key) + .expect("a listed shape"); + shape.name = name.clone(); + studio.record(format!("Rename {name}")); + }) + }) + .on_restack(move |order, _, cx| { + change(&stacked, cx, |studio| { + studio.shapes.sort_by_key(|shape| { + order + .iter() + .position(|key| *key == shape.key) + .expect("every layer restacked") + }); + studio.record("Restack".into()); + }) + }); + let toolbar = + AlignmentToolbar::new("canvas-align", selected.len()).on_align(move |align, _, cx| { + change(&lined, cx, |studio| { + let keys: Vec = studio.selected.clone(); + let frames: Vec = studio + .shapes + .iter() + .filter(|shape| keys.contains(&shape.key)) + .map(|shape| shape.frame) + .collect(); + let moved = aligned(&frames, align); + studio + .shapes + .iter_mut() + .filter(|shape| keys.contains(&shape.key)) + .zip(moved) + .for_each(|(shape, frame)| shape.frame = frame); + studio.record(align.words().into()); + }) + }); + let inspector = + InspectorPanel::new("canvas-inspector", chosen).on_change(move |shape, _, cx| { + change(&edited, cx, |studio| { + let at = studio + .shapes + .iter() + .position(|each| each.key == shape.key) + .expect("a listed shape"); + let words = format!("Edit {}", shape.name); + studio.shapes[at] = shape; + studio.record(words); + }) + }); + let assets = AssetPanel::new("canvas-assets") + .group("Shapes", shapes()) + .group("Notes", notes()) + .on_pick(move |shape, _, cx| { + change(&placed, cx, |studio| { + studio.made += 1; + let key = SharedString::from(format!("made-{}", studio.made)); + let mut copy = shape.clone(); + copy.key = key.clone(); + copy.frame.x = 280.0 + 12.0 * studio.made as f32; + copy.frame.y = 180.0 + 12.0 * studio.made as f32; + studio.shapes.push(copy); + studio.selected = vec![key]; + studio.record(format!("Place {}", shape.name)); + }) + }); + section( + "LayerPanel · InspectorPanel · AlignmentToolbar · AssetPanel", + "Panels on the drawing above. The layers list it topmost first: a box shows or hides a layer, F2 renames it, a drag restacks it. The inspector edits the one selected; the toolbar lines up or spreads several; an asset pressed lands on the canvas. Each change is a step in the history.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .gap_6() + .child(div().w(px(240.)).h(px(280.)).child(layers)) + .child( + div() + .w(px(300.)) + .flex() + .flex_col() + .gap_3() + .child(toolbar) + .child(inspector), + ) + .child(div().w(px(256.)).child(assets)), + ) +} diff --git a/examples/gallery/pages/canvas/tools.rs b/examples/gallery/pages/canvas/tools.rs index 768e848..e4853d5 100644 --- a/examples/gallery/pages/canvas/tools.rs +++ b/examples/gallery/pages/canvas/tools.rs @@ -13,15 +13,15 @@ use crate::{ }; /// 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, - selected: Vec, +pub(super) struct Studio { + pub(super) shapes: Vec, + pub(super) selected: Vec, tool: Tool, brush: Brush, view: Viewport, steps: Vec<(SharedString, Vec)>, at: usize, - made: usize, + pub(super) made: usize, } impl Studio { @@ -61,14 +61,14 @@ impl Studio { } /// Keeps the shapes as a new step, dropping any steps undone. - fn record(&mut self, words: String) { + pub(super) 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 { +pub(super) fn studio(window: &mut Window, cx: &mut App) -> Entity { keep("canvas-studio", Studio::new, window, cx) } diff --git a/src/canvas/align.rs b/src/canvas/align.rs new file mode 100644 index 0000000..8bf5733 --- /dev/null +++ b/src/canvas/align.rs @@ -0,0 +1,222 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, ParentElement, RenderOnce, Window}; + +use super::view::Frame; +use crate::{ + buttons::IconButton, + primitives::IconName, + shell::{Toolbar, ToolbarGroup, ToolbarSeparator}, +}; + +type OnAlign = Rc; + +/// A way to line up selected shapes, or to spread them so the gaps between them match. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Align { + Left, + Center, + Right, + Top, + Middle, + Bottom, + SpreadAcross, + SpreadDown, +} + +impl Align { + pub const ALL: [Align; 8] = [ + Align::Left, + Align::Center, + Align::Right, + Align::Top, + Align::Middle, + Align::Bottom, + Align::SpreadAcross, + Align::SpreadDown, + ]; + + pub fn words(self) -> &'static str { + match self { + Align::Left => "Align left edges", + Align::Center => "Align centers", + Align::Right => "Align right edges", + Align::Top => "Align top edges", + Align::Middle => "Align middles", + Align::Bottom => "Align bottom edges", + Align::SpreadAcross => "Spread across", + Align::SpreadDown => "Spread down", + } + } + + fn icon(self) -> IconName { + match self { + Align::Left => IconName::AlignStartVertical, + Align::Center => IconName::AlignCenterVertical, + Align::Right => IconName::AlignEndVertical, + Align::Top => IconName::AlignStartHorizontal, + Align::Middle => IconName::AlignCenterHorizontal, + Align::Bottom => IconName::AlignEndHorizontal, + Align::SpreadAcross => IconName::AlignHorizontalDistributeCenter, + Align::SpreadDown => IconName::AlignVerticalDistributeCenter, + } + } + + /// The fewest shapes it moves. + pub fn least(self) -> usize { + match self { + Align::SpreadAcross | Align::SpreadDown => 3, + _ => 2, + } + } +} + +/// `frames` lined up on the edge or center line they span together, or spread so the gaps between them match while the first and last stay. +pub fn aligned(frames: &[Frame], align: Align) -> Vec { + assert!( + frames.len() >= align.least(), + "{} takes {} shapes, not {}", + align.words(), + align.least(), + frames.len() + ); + let whole = frames + .iter() + .copied() + .reduce(|whole, frame| whole.union(&frame)) + .expect("frames to align"); + let at = |frame: &Frame, x: f32, y: f32| Frame::new(x, y, frame.w, frame.h); + let each = |place: &dyn Fn(&Frame) -> Frame| frames.iter().map(place).collect::>(); + match align { + Align::Left => each(&|f| at(f, whole.x, f.y)), + Align::Center => each(&|f| at(f, whole.x + (whole.w - f.w) / 2.0, f.y)), + Align::Right => each(&|f| at(f, whole.right() - f.w, f.y)), + Align::Top => each(&|f| at(f, f.x, whole.y)), + Align::Middle => each(&|f| at(f, f.x, whole.y + (whole.h - f.h) / 2.0)), + Align::Bottom => each(&|f| at(f, f.x, whole.bottom() - f.h)), + Align::SpreadAcross => spread(frames, |f| (f.x, f.w), |f, x| at(f, x, f.y)), + Align::SpreadDown => spread(frames, |f| (f.y, f.h), |f, y| at(f, f.x, y)), + } +} + +/// Frames placed along one axis in the order they start, the gaps between them equal. +fn spread( + frames: &[Frame], + span: impl Fn(&Frame) -> (f32, f32), + to: impl Fn(&Frame, f32) -> Frame, +) -> Vec { + let mut order: Vec = (0..frames.len()).collect(); + order.sort_by(|a, b| span(&frames[*a]).0.total_cmp(&span(&frames[*b]).0)); + let first = span(&frames[order[0]]); + let last = span(&frames[order[order.len() - 1]]); + let used: f32 = frames.iter().map(|frame| span(frame).1).sum(); + let gap = (last.0 + last.1 - first.0 - used) / (frames.len() - 1) as f32; + let mut out = frames.to_vec(); + let mut next = first.0; + for ix in order { + out[ix] = to(&frames[ix], next); + next += span(&frames[ix]).1 + gap; + } + out +} + +/// Buttons that line up or spread the selection. Each asks the owner, who moves the shapes with `aligned`; a way rests disabled until enough shapes are selected for it. +#[derive(IntoElement)] +pub struct AlignmentToolbar { + id: ElementId, + count: usize, + on_align: Option, +} + +impl AlignmentToolbar { + /// `count` shapes are selected. + pub fn new(id: impl Into, count: usize) -> Self { + Self { + id: id.into(), + count, + on_align: None, + } + } + + pub fn on_align(mut self, handler: impl Fn(Align, &mut Window, &mut App) + 'static) -> Self { + self.on_align = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AlignmentToolbar { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let button = |align: Align| { + let on_align = self.on_align.clone(); + IconButton::new((self.id.clone(), align.words()), align.icon()) + .tooltip(align.words()) + .disabled(self.count < align.least()) + .on_click(move |_, window, cx| { + log::info!("alignment toolbar: {}", align.words()); + if let Some(on_align) = &on_align { + on_align(align, window, cx); + } + }) + }; + let (lines, spreads) = Align::ALL.split_at(6); + Toolbar::new() + .child(ToolbarGroup::new().children(lines.iter().map(|align| button(*align)))) + .child(ToolbarSeparator) + .child(ToolbarGroup::new().children(spreads.iter().map(|align| button(*align)))) + } +} + +#[cfg(test)] +mod tests { + use super::{Align, aligned}; + use crate::canvas::Frame; + + fn three() -> Vec { + vec![ + Frame::new(0.0, 10.0, 20.0, 20.0), + Frame::new(100.0, 0.0, 40.0, 60.0), + Frame::new(40.0, 30.0, 10.0, 10.0), + ] + } + + #[test] + fn a_line_up_meets_the_edge_or_center_the_frames_span() { + let xs = |align| { + aligned(&three(), align) + .iter() + .map(|f| f.x) + .collect::>() + }; + assert_eq!(xs(Align::Left), [0.0, 0.0, 0.0]); + assert_eq!(xs(Align::Right), [120.0, 100.0, 130.0]); + assert_eq!(xs(Align::Center), [60.0, 50.0, 65.0]); + let ys = |align| { + aligned(&three(), align) + .iter() + .map(|f| f.y) + .collect::>() + }; + assert_eq!(ys(Align::Top), [0.0, 0.0, 0.0]); + assert_eq!(ys(Align::Middle), [20.0, 0.0, 25.0]); + assert_eq!(ys(Align::Bottom), [40.0, 0.0, 50.0]); + } + + #[test] + fn a_spread_keeps_the_ends_and_evens_the_gaps() { + let spread = aligned(&three(), Align::SpreadAcross); + assert_eq!(spread[0].x, 0.0, "the first stays"); + assert_eq!(spread[1].x, 100.0, "the last stays"); + assert_eq!(spread[2].x, 55.0, "35 either side of the middle one"); + assert_eq!( + spread.iter().map(|f| f.y).collect::>(), + [10.0, 0.0, 30.0], + "a spread across leaves heights alone" + ); + } + + #[test] + #[should_panic(expected = "takes 3 shapes, not 2")] + fn a_spread_takes_three() { + aligned(&three()[..2], Align::SpreadDown); + } +} diff --git a/src/canvas/assets.rs b/src/canvas/assets.rs new file mode 100644 index 0000000..3ee4f24 --- /dev/null +++ b/src/canvas/assets.rs @@ -0,0 +1,136 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, canvas, div, +}; + +use super::{ + plane::paint_shape, + shape::{Shape, ShapeKind}, + view::Viewport, +}; +use crate::{ + forms::{Grid, finder, grid, rows}, + theme::{ActiveTheme, TextSize}, +}; + +type OnShape = Rc; + +const COLUMNS: usize = 4; + +/// Shapes to place, in titled groups, each drawn to fit its tile and found by name. A press or Enter hands the owner the shape. +#[derive(IntoElement)] +pub struct AssetPanel { + id: ElementId, + groups: Vec<(SharedString, Vec)>, + on_pick: Option, +} + +impl AssetPanel { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + groups: Vec::new(), + on_pick: None, + } + } + + pub fn group( + mut self, + title: impl Into, + shapes: impl IntoIterator, + ) -> Self { + self.groups + .push((title.into(), shapes.into_iter().collect())); + self + } + + pub fn on_pick(mut self, handler: impl Fn(&Shape, &mut Window, &mut App) + 'static) -> Self { + self.on_pick = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AssetPanel { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let finder = finder(&self.id, "Search assets", window, cx); + let query = finder.read(cx).query.clone(); + let found: Vec<(SharedString, Vec)> = self + .groups + .into_iter() + .map(|(title, shapes)| { + let named = shapes + .into_iter() + .filter(|shape| shape.name.to_lowercase().contains(&query)) + .collect(); + (title, named) + }) + .collect(); + let layout = if query.is_empty() { + rows( + found + .iter() + .map(|(title, shapes)| (Some(title.clone()), shapes.len())), + COLUMNS, + ) + } else { + rows( + [(None, found.iter().map(|(_, shapes)| shapes.len()).sum())], + COLUMNS, + ) + }; + let shapes: Rc> = + Rc::new(found.into_iter().flat_map(|(_, shapes)| shapes).collect()); + let (painted, picked, on_pick) = (shapes.clone(), shapes.clone(), self.on_pick); + let cell = cx.theme().canvas().asset; + grid( + Grid { + id: self.id, + names: Rc::new(shapes.iter().map(|shape| shape.name.clone()).collect()), + rows: Rc::new(layout), + selected: None, + draw: Rc::new(move |ix, cx| thumbnail(painted[ix].clone(), cx)), + pick: Rc::new(move |ix, window, cx| { + log::info!("asset panel: {}", picked[ix].key); + if let Some(on_pick) = &on_pick { + on_pick(&picked[ix], window, cx); + } + }), + none: "No assets found", + columns: COLUMNS, + cell, + }, + &finder, + window, + cx, + ) + } +} + +/// A shape drawn to fit its tile. Text drawn so small would not read, so a text tile shows a sample of letters. +fn thumbnail(shape: Shape, cx: &App) -> AnyElement { + if let ShapeKind::Text(_) = shape.kind { + let theme = cx.theme(); + return div() + .text_size(theme.text_size(TextSize::Lg)) + .text_color(theme.colors.fg) + .child("Aa") + .into_any_element(); + } + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let theme = cx.theme(); + let rem = window.rem_size(); + let sizes = theme.canvas(); + let inset = f32::from(sizes.asset_inset.to_pixels(rem)); + let room = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let view = Viewport::fitting(shape.frame, room, inset); + let stroke = sizes.stroke.to_pixels(rem); + paint_shape(&shape, &view, bounds.origin, &theme.colors, stroke, window); + }, + ) + .size_full() + .into_any_element() +} diff --git a/src/canvas/autolayout.rs b/src/canvas/autolayout.rs new file mode 100644 index 0000000..447f5e8 --- /dev/null +++ b/src/canvas/autolayout.rs @@ -0,0 +1,315 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, Styled, Window, div, +}; + +use super::{ + panel::{OnEdit, caption, editing}, + view::Frame, +}; +use crate::{ + buttons::SegmentedControl, + forms::ScrubInput, + primitives::{IconName, tab_stop}, + theme::{ActiveTheme, Radius}, +}; + +/// Which way auto layout runs its children. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Flow { + Row, + Column, +} + +/// Where children sit along a side of their frame. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Place { + Start, + Center, + End, +} + +const PLACES: [Place; 3] = [Place::Start, Place::Center, Place::End]; + +impl Place { + /// How far into `free` room it puts what it places. + fn offset(self, free: f32) -> f32 { + match self { + Place::Start => 0.0, + Place::Center => free / 2.0, + Place::End => free, + } + } + + /// The place a step of `by` reaches, stopping at either end. + fn stepped(self, by: isize) -> Place { + let at = PLACES + .iter() + .position(|place| *place == self) + .expect("a place") as isize; + PLACES[(at + by).clamp(0, 2) as usize] + } +} + +/// Children in a row or a column, a gap apart inside padding, placed across and down. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AutoLayout { + pub flow: Flow, + pub gap: f32, + /// Across and down, in pixels. + pub padding: (f32, f32), + /// Across and down. + pub place: (Place, Place), +} + +impl AutoLayout { + /// Children of `sizes` laid inside `frame`. + pub fn laid(&self, frame: Frame, sizes: &[(f32, f32)]) -> Vec { + let row = self.flow == Flow::Row; + let (x, y) = (frame.x + self.padding.0, frame.y + self.padding.1); + let room = ( + frame.w - 2.0 * self.padding.0, + frame.h - 2.0 * self.padding.1, + ); + let (along, wide) = if row { room } else { (room.1, room.0) }; + let (lead, side) = if row { + self.place + } else { + (self.place.1, self.place.0) + }; + let long = |(w, h): (f32, f32)| if row { w } else { h }; + let run: f32 = sizes.iter().map(|size| long(*size)).sum::() + + self.gap * sizes.len().saturating_sub(1) as f32; + let mut next = lead.offset(along - run); + sizes + .iter() + .map(|&(w, h)| { + let off = side.offset(wide - if row { h } else { w }); + let laid = if row { + Frame::new(x + next, y + off, w, h) + } else { + Frame::new(x + off, y + next, w, h) + }; + next += long((w, h)) + self.gap; + laid + }) + .collect() + } +} + +/// Auto layout's flow, gap and padding, and where children sit: nine places in a grid, one Tab stop that the arrows move through. +#[derive(IntoElement)] +pub struct AutoLayoutControls { + id: ElementId, + layout: AutoLayout, + on_change: Option>, +} + +impl AutoLayoutControls { + pub fn new(id: impl Into, layout: AutoLayout) -> Self { + Self { + id: id.into(), + layout, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(AutoLayout, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AutoLayoutControls { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, layout, on_change) = (self.id, self.layout, &self.on_change); + let place = Rc::new(editing( + "auto layout", + &layout, + on_change, + |layout, place| layout.place = place, + )); + let flow = editing("auto layout", &layout, on_change, |layout, flow: Flow| { + layout.flow = flow + }); + let at = |edit: fn(&mut AutoLayout, f64)| editing("auto layout", &layout, on_change, edit); + let scrub = |name: &'static str, value: f32| { + ScrubInput::new((id.clone(), name), name, value as f64) + .range(0.0, 1_000.0) + .precision(0) + }; + let focus = tab_stop((id.clone(), "places").into(), true, window, cx); + let focused = focus.is_focused(window); + let theme = cx.theme(); + let sizes = theme.canvas(); + let (accent, subtle, hover) = ( + theme.colors.accent, + theme.colors.fg_subtle, + theme.colors.hover, + ); + let keys = place.clone(); + let grid = div() + .id((id.clone(), "places")) + .debug_selector(|| "places".into()) + .track_focus(&focus) + .flex_none() + .flex() + .flex_col() + .p_0p5() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(if focused { + theme.colors.focus + } else { + theme.colors.border + }) + .on_key_down(move |event, window, cx| { + let (across, down) = layout.place; + let next = match event.keystroke.key.as_str() { + "left" => (across.stepped(-1), down), + "right" => (across.stepped(1), down), + "up" => (across, down.stepped(-1)), + "down" => (across, down.stepped(1)), + _ => return, + }; + cx.stop_propagation(); + if next != layout.place { + keys(next, window, cx); + } + }) + .children(PLACES.iter().map(|down| { + div().flex().children(PLACES.iter().map(|across| { + let spot = (*across, *down); + let chosen = spot == layout.place; + let press = place.clone(); + div() + .id((id.clone(), format!("place-{across:?}-{down:?}"))) + .flex() + .items_center() + .justify_center() + .size(sizes.place) + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_mouse_down(MouseButton::Left, move |_, window, cx| { + press(spot, window, cx) + }) + .child( + div() + .size(if chosen { + sizes.place_mark + } else { + sizes.place_pip + }) + .rounded(theme.radius(Radius::Sm)) + .bg(if chosen { accent } else { subtle }), + ) + })) + })); + div() + .flex() + .flex_col() + .gap_3() + .child( + SegmentedControl::new( + (id.clone(), "flow"), + if layout.flow == Flow::Row { + "row" + } else { + "column" + }, + ) + .segment("row", "Row", Some(IconName::ArrowRight)) + .segment("column", "Column", Some(IconName::ArrowDown)) + .on_change(move |value: &SharedString, window, cx| { + let chosen = if value.as_ref() == "row" { + Flow::Row + } else { + Flow::Column + }; + flow(chosen, window, cx) + }), + ) + .child( + div().flex().items_start().gap_3().child(grid).child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(scrub("Gap", layout.gap).on_change(at(|l, v| l.gap = v as f32))) + .child(caption("Padding", cx)) + .child( + scrub("Across", layout.padding.0) + .on_change(at(|l, v| l.padding.0 = v as f32)), + ) + .child( + scrub("Down", layout.padding.1) + .on_change(at(|l, v| l.padding.1 = v as f32)), + ), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{AutoLayout, Flow, Place}; + use crate::canvas::Frame; + + #[test] + fn a_row_runs_its_children_a_gap_apart_and_places_the_run() { + let row = AutoLayout { + flow: Flow::Row, + gap: 10.0, + padding: (20.0, 10.0), + place: (Place::Center, Place::End), + }; + let laid = row.laid( + Frame::new(0.0, 0.0, 200.0, 100.0), + &[(40.0, 20.0), (60.0, 40.0)], + ); + assert_eq!( + laid, + [ + Frame::new(45.0, 70.0, 40.0, 20.0), + Frame::new(95.0, 50.0, 60.0, 40.0) + ], + "the run of 110 sits in the middle of 160, each child on the bottom padding" + ); + } + + #[test] + fn a_column_runs_down_from_its_top() { + let column = AutoLayout { + flow: Flow::Column, + gap: 8.0, + padding: (0.0, 0.0), + place: (Place::Start, Place::Start), + }; + let laid = column.laid( + Frame::new(10.0, 10.0, 100.0, 100.0), + &[(30.0, 10.0), (50.0, 20.0)], + ); + assert_eq!( + laid, + [ + Frame::new(10.0, 10.0, 30.0, 10.0), + Frame::new(10.0, 28.0, 50.0, 20.0) + ] + ); + } + + #[test] + fn a_step_stops_at_either_end() { + assert_eq!(Place::Start.stepped(-1), Place::Start); + assert_eq!(Place::Start.stepped(1), Place::Center); + assert_eq!(Place::End.stepped(1), Place::End); + } +} diff --git a/src/canvas/constraints.rs b/src/canvas/constraints.rs new file mode 100644 index 0000000..6dc1c3c --- /dev/null +++ b/src/canvas/constraints.rs @@ -0,0 +1,267 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, prelude::*, relative, +}; + +use super::{ + panel::{OnEdit, caption, editing}, + view::Frame, +}; +use crate::{ + forms::{Choice, Select}, + theme::{ActiveTheme, Radius}, +}; + +/// How a shape keeps to its parent along one axis when the parent resizes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pin { + /// To the left or top edge. + Start, + /// To the right or bottom edge. + End, + /// To both edges, stretching. + Both, + /// To the middle. + Center, + /// Growing with the parent. + Scale, +} + +impl Pin { + pub const ALL: [Pin; 5] = [Pin::Start, Pin::End, Pin::Both, Pin::Center, Pin::Scale]; + + fn value(self) -> &'static str { + match self { + Pin::Start => "start", + Pin::End => "end", + Pin::Both => "both", + Pin::Center => "center", + Pin::Scale => "scale", + } + } + + fn words(self, across: bool) -> &'static str { + match (self, across) { + (Pin::Start, true) => "Left", + (Pin::Start, false) => "Top", + (Pin::End, true) => "Right", + (Pin::End, false) => "Bottom", + (Pin::Both, true) => "Left and right", + (Pin::Both, false) => "Top and bottom", + (Pin::Center, _) => "Center", + (Pin::Scale, _) => "Scale", + } + } + + /// Where a span `at` and `len` long inside a parent from `from`, `size` long, lands once the parent spans `to` and `now`. + pub fn follow( + self, + (at, len): (f32, f32), + (from, size): (f32, f32), + (to, now): (f32, f32), + ) -> (f32, f32) { + let (before, after) = (at - from, from + size - at - len); + match self { + Pin::Start => (to + before, len), + Pin::End => (to + now - after - len, len), + Pin::Both => (to + before, (now - before - after).max(0.0)), + Pin::Center => (to + (now - size) / 2.0 + before, len), + Pin::Scale => { + assert!(size > 0.0, "a parent {size} long cannot scale"); + let grown = now / size; + (to + before * grown, len * grown) + } + } + } +} + +/// How a shape keeps to its parent across and down. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Constraints { + pub across: Pin, + pub down: Pin, +} + +impl Constraints { + /// Where `child` lands once its parent goes from `before` to `after`. + pub fn placed(self, child: Frame, before: Frame, after: Frame) -> Frame { + let (x, w) = + self.across + .follow((child.x, child.w), (before.x, before.w), (after.x, after.w)); + let (y, h) = self + .down + .follow((child.y, child.h), (before.y, before.h), (after.y, after.h)); + Frame::new(x, y, w, h) + } +} + +/// A shape's constraints: a picture of the pins beside a choice for each axis. +#[derive(IntoElement)] +pub struct ConstraintsEditor { + id: ElementId, + constraints: Constraints, + on_change: Option>, +} + +impl ConstraintsEditor { + pub fn new(id: impl Into, constraints: Constraints) -> Self { + Self { + id: id.into(), + constraints, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Constraints, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +/// A hairline from `x`, `y` in shares of its box, `len` long across or down. +fn hairline(x: f32, y: f32, len: f32, across: bool, color: Hsla) -> gpui::Div { + let line = div().absolute().left(relative(x)).top(relative(y)); + if across { + line.w(relative(len)).border_t_1().border_color(color) + } else { + line.h(relative(len)).border_l_1().border_color(color) + } +} + +impl RenderOnce for ConstraintsEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, now, on_change) = (self.id, self.constraints, &self.on_change); + let theme = cx.theme(); + let (lit, rest) = (theme.colors.accent, theme.colors.border); + let pin = |on: bool| if on { lit } else { rest }; + let (h, v) = (now.across, now.down); + let picture = div() + .relative() + .flex_none() + .size(theme.canvas().pins) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + div() + .absolute() + .left(relative(0.35)) + .top(relative(0.35)) + .size(relative(0.3)) + .border_1() + .border_color(theme.colors.fg_subtle), + ) + .child(hairline( + 0.1, + 0.5, + 0.2, + true, + pin(matches!(h, Pin::Start | Pin::Both)), + )) + .child(hairline( + 0.7, + 0.5, + 0.2, + true, + pin(matches!(h, Pin::End | Pin::Both)), + )) + .child(hairline( + 0.5, + 0.1, + 0.2, + false, + pin(matches!(v, Pin::Start | Pin::Both)), + )) + .child(hairline( + 0.5, + 0.7, + 0.2, + false, + pin(matches!(v, Pin::End | Pin::Both)), + )) + .when(h == Pin::Center, |p| { + p.child(hairline(0.4, 0.5, 0.2, true, lit)) + }) + .when(v == Pin::Center, |p| { + p.child(hairline(0.5, 0.4, 0.2, false, lit)) + }); + let choice = |across: bool| { + let pinned = if across { h } else { v }; + let set = editing( + "constraints editor", + &now, + on_change, + move |c: &mut Constraints, pin: Pin| { + if across { c.across = pin } else { c.down = pin } + }, + ); + div() + .flex() + .flex_col() + .gap_1() + .child(caption(if across { "Horizontal" } else { "Vertical" }, cx)) + .child( + Select::new( + (id.clone(), if across { "across" } else { "down" }), + Pin::ALL + .iter() + .map(|pin| Choice::new(pin.value(), pin.words(across))), + ) + .selected(pinned.value()) + .on_change(move |value: &SharedString, window, cx| { + let pin = *Pin::ALL + .iter() + .find(|pin| pin.value() == value.as_ref()) + .expect("a pin the select offers"); + set(pin, window, cx) + }), + ) + }; + div().flex().gap_3().child(picture).child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_2() + .child(choice(true)) + .child(choice(false)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Constraints, Pin}; + use crate::canvas::Frame; + + #[test] + fn each_pin_keeps_its_own_distance_as_the_parent_grows() { + let (child, before) = ((20.0, 10.0), (0.0, 100.0)); + let grown = (50.0, 200.0); + assert_eq!(Pin::Start.follow(child, before, grown), (70.0, 10.0)); + assert_eq!(Pin::End.follow(child, before, grown), (170.0, 10.0)); + assert_eq!(Pin::Both.follow(child, before, grown), (70.0, 110.0)); + assert_eq!(Pin::Center.follow(child, before, grown), (120.0, 10.0)); + assert_eq!(Pin::Scale.follow(child, before, grown), (90.0, 20.0)); + } + + #[test] + fn constraints_place_a_child_on_both_axes() { + let pins = Constraints { + across: Pin::Both, + down: Pin::End, + }; + let placed = pins.placed( + Frame::new(10.0, 60.0, 80.0, 20.0), + Frame::new(0.0, 0.0, 100.0, 100.0), + Frame::new(0.0, 0.0, 300.0, 150.0), + ); + assert_eq!(placed, Frame::new(10.0, 110.0, 280.0, 20.0)); + } +} diff --git a/src/canvas/export.rs b/src/canvas/export.rs new file mode 100644 index 0000000..0a6c0e2 --- /dev/null +++ b/src/canvas/export.rs @@ -0,0 +1,281 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div, +}; + +use super::panel::{OnEdit, editing, heading, pair}; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + forms::{Choice, Select}, + primitives::IconName, + theme::{ActiveTheme, TextSize}, + typography::Ellipsis, +}; + +/// A kind of file to export to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExportFormat { + Png, + Jpg, + Svg, + Pdf, +} + +impl ExportFormat { + pub const ALL: [ExportFormat; 4] = [ + ExportFormat::Png, + ExportFormat::Jpg, + ExportFormat::Svg, + ExportFormat::Pdf, + ]; + + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Png => "png", + ExportFormat::Jpg => "jpg", + ExportFormat::Svg => "svg", + ExportFormat::Pdf => "pdf", + } + } + + /// Whether it keeps shapes as lines, so no scale applies. + pub fn vector(self) -> bool { + matches!(self, ExportFormat::Svg | ExportFormat::Pdf) + } +} + +/// One file to export: a scale and a format. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ExportSetting { + pub scale: f32, + pub format: ExportFormat, +} + +/// The scales a setting offers, in the order plus adds them. +const SCALES: [f32; 5] = [1.0, 2.0, 3.0, 4.0, 0.5]; + +impl ExportSetting { + /// The file it writes for a layer called `name`: a scale other than one after an @, then the extension. A vector format takes no scale. + pub fn file(&self, name: &str) -> String { + let extension = self.format.extension(); + if self.format.vector() || self.scale == 1.0 { + format!("{name}.{extension}") + } else { + format!("{name}@{}x.{extension}", self.scale) + } + } +} + +/// The files `settings` write for `name`, each once, in order. +pub fn export_files(name: &str, settings: &[ExportSetting]) -> Vec { + settings.iter().fold(Vec::new(), |mut files, setting| { + let file = setting.file(name); + if !files.contains(&file) { + files.push(file); + } + files + }) +} + +/// What plus adds: the first scale no PNG has yet. +fn added(settings: &[ExportSetting]) -> Option { + SCALES + .iter() + .find(|scale| { + !settings + .iter() + .any(|setting| setting.format == ExportFormat::Png && setting.scale == **scale) + }) + .map(|scale| ExportSetting { + scale: *scale, + format: ExportFormat::Png, + }) +} + +/// What a layer exports to. Each row picks a scale and a format and minus drops it; plus adds the next scale as PNG; Export hands the owner the files, each once, and rests disabled with none. +#[derive(IntoElement)] +pub struct ExportPanel { + id: ElementId, + name: SharedString, + settings: Vec, + on_change: Option>>, + on_export: Option>>, +} + +impl ExportPanel { + /// `name` is the layer's, which the files take. + pub fn new( + id: impl Into, + name: impl Into, + settings: impl IntoIterator, + ) -> Self { + Self { + id: id.into(), + name: name.into(), + settings: settings.into_iter().collect(), + on_change: None, + on_export: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + pub fn on_export( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_export = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ExportPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, settings, on_change) = (self.id, self.settings, &self.on_change); + let files = export_files(&self.name, &settings); + let next = added(&settings); + let add = editing("export panel", &settings, on_change, move |all, _: ()| { + all.extend(next) + }); + let rows = settings.iter().enumerate().map(|(ix, setting)| { + let scale = editing( + "export panel", + &settings, + on_change, + move |all, scale: f32| all[ix].scale = scale, + ); + let format = editing( + "export panel", + &settings, + on_change, + move |all, format: ExportFormat| all[ix].format = format, + ); + let remove = editing("export panel", &settings, on_change, move |all, _: ()| { + all.remove(ix); + }); + div() + .flex() + .items_center() + .gap_2() + .child( + div().flex_1().min_w_0().child(pair( + Select::new( + (id.clone(), format!("scale-{ix}")), + SCALES + .iter() + .map(|scale| Choice::new(scale.to_string(), format!("{scale}x"))), + ) + .selected(setting.scale.to_string()) + .disabled(setting.format.vector()) + .on_change(move |value, window, cx| { + scale( + value.parse().expect("a scale the select offers"), + window, + cx, + ) + }), + Select::new( + (id.clone(), format!("format-{ix}")), + ExportFormat::ALL.iter().map(|format| { + Choice::new(format.extension(), format.extension().to_uppercase()) + }), + ) + .selected(setting.format.extension()) + .on_change(move |value, window, cx| { + let chosen = *ExportFormat::ALL + .iter() + .find(|format| format.extension() == value.as_ref()) + .expect("a format the select offers"); + format(chosen, window, cx) + }), + )), + ) + .child( + IconButton::new((id.clone(), format!("remove-{ix}")), IconName::Minus) + .tooltip("Remove the size") + .on_click(move |_, window, cx| remove((), window, cx)), + ) + }); + let theme = cx.theme(); + let (exported, on_export) = (files.clone(), self.on_export); + div() + .flex() + .flex_col() + .gap_3() + .child(heading( + "Export", + IconButton::new((id.clone(), "add"), IconName::Plus) + .tooltip("Add a size") + .disabled(next.is_none()) + .on_click(move |_, window, cx| add((), window, cx)), + cx, + )) + .children(rows) + .child( + div() + .flex() + .flex_col() + .gap_0p5() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .children(files.into_iter().map(Ellipsis::new)), + ) + .child( + Button::new((id, "export"), format!("Export {}", self.name)) + .variant(ButtonVariant::Primary) + .disabled(exported.is_empty()) + .on_click(move |_, window, cx| { + log::info!("export panel: {exported:?}"); + if let Some(on_export) = &on_export { + on_export(exported.clone(), window, cx); + } + }), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{ExportFormat, ExportSetting, added, export_files}; + + fn at(scale: f32, format: ExportFormat) -> ExportSetting { + ExportSetting { scale, format } + } + + #[test] + fn files_name_their_scale_and_come_once() { + let settings = [ + at(1.0, ExportFormat::Png), + at(2.0, ExportFormat::Png), + at(0.5, ExportFormat::Jpg), + at(2.0, ExportFormat::Svg), + at(3.0, ExportFormat::Svg), + at(2.0, ExportFormat::Png), + ]; + assert_eq!( + export_files("Card", &settings), + ["Card.png", "Card@2x.png", "Card@0.5x.jpg", "Card.svg"] + ); + } + + #[test] + fn plus_adds_the_first_scale_no_png_has() { + assert_eq!(added(&[]), Some(at(1.0, ExportFormat::Png))); + assert_eq!( + added(&[at(1.0, ExportFormat::Png), at(2.0, ExportFormat::Jpg)]), + Some(at(2.0, ExportFormat::Png)) + ); + let every: Vec<_> = [1.0, 2.0, 3.0, 4.0, 0.5] + .iter() + .map(|scale| at(*scale, ExportFormat::Png)) + .collect(); + assert_eq!(added(&every), None); + } +} diff --git a/src/canvas/inspector.rs b/src/canvas/inspector.rs new file mode 100644 index 0000000..f798734 --- /dev/null +++ b/src/canvas/inspector.rs @@ -0,0 +1,143 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, +}; + +use super::{ + panel::{OnEdit, caption, editing, pair}, + shape::Shape, +}; +use crate::{ + feedback::EmptyState, + forms::{ColorPalette, ScrubInput, Switch}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, HUE_NAMES, IconSize}, + typography::Ellipsis, +}; + +/// How far from the origin a figure may lie, in canvas units either way. +const REACH: f64 = 100_000.0; + +/// The selected layer's name and kind, its frame, its fill among the chart hues, and whether it shows and is locked. Each edit hands the owner the changed shape; with none or several selected it says so. +#[derive(IntoElement)] +pub struct InspectorPanel { + id: ElementId, + selection: Vec, + on_change: Option>, +} + +impl InspectorPanel { + pub fn new(id: impl Into, selection: impl IntoIterator) -> Self { + Self { + id: id.into(), + selection: selection.into_iter().collect(), + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(Shape, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for InspectorPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let [shape] = self.selection.as_slice() else { + let title: SharedString = match self.selection.len() { + 0 => "Nothing selected".into(), + count => format!("{count} layers selected").into(), + }; + return EmptyState::new((id, "empty"), IconName::MousePointer2, title) + .body("Select one layer to see its properties.") + .into_any_element(); + }; + let on_change = &self.on_change; + let at = |edit: fn(&mut Shape, f64)| editing("inspector", shape, on_change, edit); + let scrub = |name: &'static str, value: f32, least: f64| { + ScrubInput::new((id.clone(), name), name, value as f64) + .range(least, REACH) + .precision(0) + }; + let frame = shape.frame; + let theme = cx.theme(); + let chart = theme.colors.chart; + let fill = theme + .colors + .hue(shape.hue, format_args!("layer {}", shape.key)); + let hue = editing("inspector", shape, on_change, move |shape, color: Hsla| { + shape.hue = chart + .iter() + .position(|hue| *hue == color) + .expect("the fill is a chart hue"); + }); + let shown = editing("inspector", shape, on_change, |shape, on: bool| { + shape.hidden = !on + }); + let locked = editing("inspector", shape, on_change, |shape, on: bool| { + shape.locked = on + }); + let part = |title: &'static str| div().flex().flex_col().gap_2().child(caption(title, cx)); + div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + Icon::new(shape.kind.icon()) + .size(IconSize::Sm) + .color(theme.colors.fg_muted), + ) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::SEMIBOLD) + .child(Ellipsis::new(shape.name.clone())), + ) + .child(caption(shape.kind.word(), cx).flex_none()), + ) + .child( + part("Frame") + .child(pair( + scrub("X", frame.x, -REACH).on_change(at(|s, v| s.frame.x = v as f32)), + scrub("Y", frame.y, -REACH).on_change(at(|s, v| s.frame.y = v as f32)), + )) + .child(pair( + scrub("W", frame.w, 0.0).on_change(at(|s, v| s.frame.w = v as f32)), + scrub("H", frame.h, 0.0).on_change(at(|s, v| s.frame.h = v as f32)), + )), + ) + .child( + part("Fill").child( + ColorPalette::new((id.clone(), "fill"), HUE_NAMES.iter().copied().zip(chart)) + .selected(fill) + .on_change(hue), + ), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child( + Switch::new((id.clone(), "visible"), !shape.hidden) + .label("Visible") + .on_change(shown), + ) + .child( + Switch::new((id, "locked"), shape.locked) + .label("Locked") + .on_change(locked), + ), + ) + .into_any_element() + } +} diff --git a/src/canvas/layers.rs b/src/canvas/layers.rs new file mode 100644 index 0000000..d0ee1fe --- /dev/null +++ b/src/canvas/layers.rs @@ -0,0 +1,180 @@ +use std::rc::Rc; + +use gpui::{App, ElementId, IntoElement, RenderOnce, SharedString, Styled, Window}; + +use super::{gesture::OnKeys, shape::Shape}; +use crate::lists::{DropAt, Tree, TreeNode}; + +type OnRename = Rc; + +/// The keys in paint order, bottom first, after `dragged` lands `at` `target` in a list drawn top first. +pub(crate) fn restacked( + order: &[SharedString], + dragged: &SharedString, + target: &SharedString, + at: DropAt, +) -> Vec { + assert!(order.contains(dragged), "layer {dragged} is not listed"); + let mut rest: Vec = order + .iter() + .filter(|key| *key != dragged) + .cloned() + .collect(); + let under = rest + .iter() + .position(|key| key == target) + .unwrap_or_else(|| panic!("layer {dragged} lands against {target}, which is not listed")); + let ix = match at { + DropAt::Before => under + 1, + DropAt::After => under, + DropAt::Inside => panic!("layer {target} holds no layers"), + }; + rest.insert(ix, dragged.clone()); + rest +} + +/// The canvas's layers, topmost first. A box shows or hides each, a press selects and Shift or Command adds, F2 renames, and a drag restacks; a locked layer says so. It fills its box, which the host sizes. +#[derive(IntoElement)] +pub struct LayerPanel { + id: ElementId, + shapes: Vec, + selected: Vec, + on_select: Option, + on_show: Option, + on_rename: Option, + on_restack: Option, +} + +impl LayerPanel { + /// `shapes` in paint order, bottom first, as the canvas takes them. + pub fn new(id: impl Into, shapes: impl IntoIterator) -> Self { + Self { + id: id.into(), + shapes: shapes.into_iter().collect(), + selected: Vec::new(), + on_select: None, + on_show: None, + on_rename: None, + on_restack: None, + } + } + + pub fn selected(mut self, keys: impl IntoIterator>) -> Self { + self.selected = keys.into_iter().map(Into::into).collect(); + self + } + + 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 the keys of the layers shown after a box is pressed. + pub fn on_show( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_show = Some(Rc::new(handler)); + self + } + + pub fn on_rename( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_rename = Some(Rc::new(handler)); + self + } + + /// Gets every key in its new paint order, bottom first. + pub fn on_restack( + mut self, + handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.on_restack = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for LayerPanel { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let order: Vec = self.shapes.iter().map(|shape| shape.key.clone()).collect(); + let shown: Vec = self + .shapes + .iter() + .filter(|shape| !shape.hidden) + .map(|shape| shape.key.clone()) + .collect(); + let nodes = self.shapes.iter().rev().map(|shape| { + let node = TreeNode::new(shape.key.clone(), shape.name.clone()).icon(shape.kind.icon()); + if shape.locked { + node.note("Locked") + } else { + node + } + }); + let mut tree = Tree::new(self.id.clone(), nodes) + .selected(self.selected) + .multiple() + .checked(shown) + .size_full(); + if let Some(on_select) = self.on_select { + tree = tree.on_select(move |keys, window, cx| on_select(keys, window, cx)); + } + if let Some(on_show) = self.on_show { + tree = tree.on_check(move |keys, window, cx| { + log::info!("layer panel: {} shown", keys.len()); + on_show(keys, window, cx) + }); + } + if let Some(on_rename) = self.on_rename { + tree = tree.on_rename(move |key, name, window, cx| on_rename(key, name, window, cx)); + } + if let Some(on_restack) = self.on_restack { + tree = tree.on_move(move |dragged, target, at, window, cx| { + log::info!("layer panel: {dragged} {at:?} {target}"); + on_restack(&restacked(&order, dragged, target, at), window, cx); + }); + } + tree + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::restacked; + use crate::lists::DropAt; + + fn keys(names: &[&'static str]) -> Vec { + names.iter().map(|name| SharedString::from(*name)).collect() + } + + #[test] + fn a_layer_dropped_above_another_paints_over_it() { + let order = keys(&["a", "b", "c"]); + assert_eq!( + restacked(&order, &"a".into(), &"c".into(), DropAt::Before), + keys(&["b", "c", "a"]) + ); + assert_eq!( + restacked(&order, &"c".into(), &"a".into(), DropAt::After), + keys(&["c", "a", "b"]) + ); + assert_eq!( + restacked(&order, &"a".into(), &"b".into(), DropAt::After), + keys(&["a", "b", "c"]), + "just under b is where a lies" + ); + } + + #[test] + #[should_panic(expected = "holds no layers")] + fn a_layer_cannot_land_inside_another() { + restacked(&keys(&["a", "b"]), &"a".into(), &"b".into(), DropAt::Inside); + } +} diff --git a/src/canvas/mod.rs b/src/canvas/mod.rs index 7c95206..313e12b 100644 --- a/src/canvas/mod.rs +++ b/src/canvas/mod.rs @@ -1,26 +1,45 @@ +mod align; +mod assets; +mod autolayout; +mod constraints; mod edit; +mod export; mod gesture; mod history; +mod inspector; +mod layers; mod marks; mod minimap; mod palette; +mod panel; mod plane; mod shape; +mod style; mod tools; +mod typography; mod view; mod zoom; #[cfg(all(test, feature = "test-support"))] mod tests; +pub use align::{Align, AlignmentToolbar, aligned}; +pub use assets::AssetPanel; +pub use autolayout::{AutoLayout, AutoLayoutControls, Flow, Place}; +pub use constraints::{Constraints, ConstraintsEditor, Pin}; pub use edit::{Handle, Tool}; +pub use export::{ExportFormat, ExportPanel, ExportSetting, export_files}; pub use gesture::Brush; pub use history::HistoryPanel; +pub use inspector::InspectorPanel; +pub use layers::LayerPanel; pub use marks::{SelectionBox, SnapIndicator, TransformHandles}; pub use minimap::MiniMap; pub use palette::{BrushSettings, ToolPalette}; pub use plane::InfiniteCanvas; pub use shape::{Artboard, Corner, Guide, Shape, ShapeKind}; +pub use style::{Border, BorderEditor, ColorPanel, Dash, Shadow, ShadowEditor}; pub use tools::ToolLayer; +pub use typography::{TypeStyle, TypographyPanel}; pub use view::{Frame, Viewport}; pub use zoom::{ZoomControls, ZoomIndicator}; diff --git a/src/canvas/panel.rs b/src/canvas/panel.rs new file mode 100644 index 0000000..2471aae --- /dev/null +++ b/src/canvas/panel.rs @@ -0,0 +1,94 @@ +use std::{fmt::Debug, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, SharedString, Styled, Window, div, +}; + +use crate::{ + forms::{ColorPicker, ColorSwatch}, + overlays::Popover, + theme::{ActiveTheme, TextSize}, +}; + +/// A panel's handler for its whole value. +pub(crate) type OnEdit = Rc; + +/// A handler that hands `on_change` a copy of `value` changed by `edit`. +pub(crate) fn editing( + what: &'static str, + value: &V, + on_change: &Option>, + edit: impl Fn(&mut V, T) + 'static, +) -> impl Fn(T, &mut Window, &mut App) + 'static +where + V: Clone + Debug + 'static, + T: 'static, +{ + let (value, on_change) = (value.clone(), on_change.clone()); + move |input, window, cx| { + let mut next = value.clone(); + edit(&mut next, input); + log::info!("{what}: {next:?}"); + if let Some(on_change) = &on_change { + on_change(next, window, cx); + } + } +} + +/// Two fields side by side, sharing the width. +pub(crate) fn pair(left: impl IntoElement, right: impl IntoElement) -> gpui::Div { + div() + .flex() + .gap_2() + .child(div().flex_1().min_w_0().child(left)) + .child(div().flex_1().min_w_0().child(right)) +} + +/// A quiet line naming what follows. +pub(crate) fn caption(text: impl Into, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(text.into()) +} + +/// A panel part's title, with an action at the end of its line. +pub(crate) fn heading( + title: impl Into, + action: impl IntoElement, + cx: &App, +) -> gpui::Div { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.colors.fg) + .child(title.into()), + ) + .child(action) +} + +/// A swatch that opens a color picker. +pub(crate) fn color_well( + id: ElementId, + color: Hsla, + on_pick: impl Fn(Hsla, &mut Window, &mut App) + 'static, +) -> Popover { + let swatch = (id.clone(), "swatch"); + Popover::with_opener( + id.clone(), + move |toggle| { + ColorSwatch::new(swatch, color) + .tooltip("Change the color") + .on_click(move |window, cx| toggle(window, cx)) + }, + move |_, _| ColorPicker::new((id, "picker"), color).on_change(on_pick), + ) +} diff --git a/src/canvas/shape.rs b/src/canvas/shape.rs index 322d053..f48f862 100644 --- a/src/canvas/shape.rs +++ b/src/canvas/shape.rs @@ -1,6 +1,7 @@ use gpui::SharedString; use super::view::Frame; +use crate::primitives::IconName; /// What a shape is. #[derive(Clone, Debug, PartialEq)] @@ -23,6 +24,36 @@ pub enum ShapeKind { Note(SharedString), } +impl ShapeKind { + /// Its icon in lists and grids. + pub(crate) fn icon(&self) -> IconName { + match self { + ShapeKind::Rect => IconName::Square, + ShapeKind::Ellipse => IconName::Circle, + ShapeKind::Polygon(_) => IconName::Hexagon, + ShapeKind::Line(_) => IconName::Slash, + ShapeKind::Arrow(_) => IconName::MoveUpRight, + ShapeKind::Text(_) => IconName::Type, + ShapeKind::Path { .. } => IconName::PenTool, + ShapeKind::Note(_) => IconName::StickyNote, + } + } + + /// What it is, in a word. + pub(crate) fn word(&self) -> &'static str { + match self { + ShapeKind::Rect => "Rectangle", + ShapeKind::Ellipse => "Ellipse", + ShapeKind::Polygon(_) => "Polygon", + ShapeKind::Line(_) => "Line", + ShapeKind::Arrow(_) => "Arrow", + ShapeKind::Text(_) => "Text", + ShapeKind::Path { .. } => "Path", + ShapeKind::Note(_) => "Note", + } + } +} + /// A frame's corner, where a line or an arrow ends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Corner { diff --git a/src/canvas/style.rs b/src/canvas/style.rs new file mode 100644 index 0000000..65b837f --- /dev/null +++ b/src/canvas/style.rs @@ -0,0 +1,372 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, prelude::*, +}; + +use super::panel::{OnEdit, caption, color_well, editing, heading, pair}; +use crate::{ + buttons::{IconButton, SegmentedControl, ToggleButton, ToggleItem}, + forms::{ColorPalette, ColorPicker, ScrubInput}, + primitives::IconName, + theme::{ActiveTheme, Elevation}, + typography::Ellipsis, +}; + +/// The most a shadow or a border measures, in pixels. +const FAR: f64 = 1_000.0; + +/// A color to edit in full, over the document's colors to pick from. +#[derive(IntoElement)] +pub struct ColorPanel { + id: ElementId, + color: Hsla, + swatches: Vec<(SharedString, Hsla)>, + on_change: Option>, +} + +impl ColorPanel { + pub fn new(id: impl Into, color: impl Into) -> Self { + Self { + id: id.into(), + color: color.into(), + swatches: Vec::new(), + on_change: None, + } + } + + /// The document's colors, by name. + pub fn swatches( + mut self, + swatches: impl IntoIterator, impl Into)>, + ) -> Self { + self.swatches = swatches + .into_iter() + .map(|(name, color)| (name.into(), color.into())) + .collect(); + self + } + + pub fn on_change(mut self, handler: impl Fn(Hsla, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ColorPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (picked, chosen) = (self.on_change.clone(), self.on_change); + let report = |on_change: Option>| { + move |color: Hsla, window: &mut Window, cx: &mut App| { + log::info!("color panel: {color:?}"); + if let Some(on_change) = &on_change { + on_change(color, window, cx); + } + } + }; + div() + .flex() + .flex_col() + .gap_3() + .child( + ColorPicker::new((self.id.clone(), "picker"), self.color).on_change(report(picked)), + ) + .when(!self.swatches.is_empty(), |panel| { + panel.child(caption("Document colors", cx)).child( + ColorPalette::new((self.id, "swatches"), self.swatches) + .selected(self.color) + .on_change(report(chosen)), + ) + }) + } +} + +/// A drop shadow: its offset, blur and spread in pixels, and its color. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Shadow { + pub x: f32, + pub y: f32, + pub blur: f32, + pub spread: f32, + pub color: Hsla, +} + +/// Shadows in a list. Each sets its color, offset, blur and spread, and minus drops it; plus adds the theme's floating shadow. +#[derive(IntoElement)] +pub struct ShadowEditor { + id: ElementId, + shadows: Vec, + on_change: Option>>, +} + +impl ShadowEditor { + pub fn new(id: impl Into, shadows: impl IntoIterator) -> Self { + Self { + id: id.into(), + shadows: shadows.into_iter().collect(), + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ShadowEditor { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let lift = cx + .theme() + .elevation(Elevation::Floating) + .last() + .map(|shadow| Shadow { + x: f32::from(shadow.offset.x), + y: f32::from(shadow.offset.y), + blur: f32::from(shadow.blur_radius), + spread: f32::from(shadow.spread_radius), + color: shadow.color, + }) + .expect("a floating elevation casts a shadow"); + let (id, shadows, on_change) = (self.id, self.shadows, &self.on_change); + let add = editing("shadow editor", &shadows, on_change, move |all, _: ()| { + all.push(lift) + }); + let rows = shadows.iter().enumerate().map(|(ix, shadow)| { + let at = |edit: fn(&mut Shadow, f64)| { + editing( + "shadow editor", + &shadows, + on_change, + move |all: &mut Vec, value| edit(&mut all[ix], value), + ) + }; + let scrub = |name: &'static str, value: f32, least: f64| { + ScrubInput::new((id.clone(), format!("{name}-{ix}")), name, value as f64) + .range(least, FAR) + .precision(0) + }; + let tint = editing( + "shadow editor", + &shadows, + on_change, + move |all, color: Hsla| all[ix].color = color, + ); + let remove = editing("shadow editor", &shadows, on_change, move |all, _: ()| { + all.remove(ix); + }); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(color_well( + (id.clone(), format!("color-{ix}")).into(), + shadow.color, + tint, + )) + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(format!("Shadow {}", ix + 1))), + ) + .child( + IconButton::new((id.clone(), format!("remove-{ix}")), IconName::Minus) + .tooltip("Remove the shadow") + .on_click(move |_, window, cx| remove((), window, cx)), + ), + ) + .child(pair( + scrub("X", shadow.x, -FAR).on_change(at(|s, v| s.x = v as f32)), + scrub("Y", shadow.y, -FAR).on_change(at(|s, v| s.y = v as f32)), + )) + .child(pair( + scrub("Blur", shadow.blur, 0.0).on_change(at(|s, v| s.blur = v as f32)), + scrub("Spread", shadow.spread, -FAR).on_change(at(|s, v| s.spread = v as f32)), + )) + }); + div() + .flex() + .flex_col() + .gap_3() + .child(heading( + "Shadows", + IconButton::new((id.clone(), "add"), IconName::Plus) + .tooltip("Add a shadow") + .on_click(move |_, window, cx| add((), window, cx)), + cx, + )) + .children(rows) + } +} + +/// How a border's line is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dash { + Solid, + Dashed, + Dotted, +} + +impl Dash { + pub const ALL: [Dash; 3] = [Dash::Solid, Dash::Dashed, Dash::Dotted]; + + pub fn word(self) -> &'static str { + match self { + Dash::Solid => "Solid", + Dash::Dashed => "Dashed", + Dash::Dotted => "Dotted", + } + } +} + +/// A border: its width in pixels, its line, its color, and each corner's radius from the top left around. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Border { + pub width: f32, + pub dash: Dash, + pub color: Hsla, + pub radii: [f32; 4], +} + +/// A border's color, width and line, and its corners: one radius for all, or each its own once split. Uneven corners always show split; joining them gives all the first one's radius. +#[derive(IntoElement)] +pub struct BorderEditor { + id: ElementId, + border: Border, + on_change: Option>, +} + +impl BorderEditor { + pub fn new(id: impl Into, border: Border) -> Self { + Self { + id: id.into(), + border, + on_change: None, + } + } + + pub fn on_change(mut self, handler: impl Fn(Border, &mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +const CORNERS: [&str; 4] = ["TL", "TR", "BR", "BL"]; + +impl RenderOnce for BorderEditor { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, border, on_change) = (self.id, self.border, &self.on_change); + let parted = window.use_keyed_state((id.clone(), "split"), cx, |_, _| false); + let uneven = border.radii.iter().any(|radius| *radius != border.radii[0]); + let split = *parted.read(cx) || uneven; + let at = |edit: fn(&mut Border, f64)| editing("border editor", &border, on_change, edit); + let scrub = |name: &'static str, value: f32| { + ScrubInput::new((id.clone(), name), name, value as f64) + .range(0.0, FAR) + .precision(0) + }; + let dash = { + let set = editing("border editor", &border, on_change, |border, dash: Dash| { + border.dash = dash + }); + Dash::ALL + .iter() + .fold( + SegmentedControl::new((id.clone(), "dash"), border.dash.word()), + |control, dash| control.segment(dash.word(), dash.word(), None), + ) + .on_change(move |word, window, cx| { + let dash = *Dash::ALL + .iter() + .find(|dash| dash.word() == word.as_ref()) + .expect("a dash the control offers"); + set(dash, window, cx) + }) + }; + let join = editing("border editor", &border, on_change, |border, _: ()| { + border.radii = [border.radii[0]; 4] + }); + let corners = ToggleButton::new( + (id.clone(), "corners"), + ToggleItem::new("corners") + .icon(IconName::SquareRoundCorner) + .tooltip("Each corner its own"), + split, + ) + .on_toggle(move |on, window, cx| { + log::info!( + "border editor: corners {}", + if on { "split" } else { "joined" } + ); + parted.update(cx, |parted, cx| { + *parted = on; + cx.notify(); + }); + if !on && uneven { + join((), window, cx); + } + }); + let corner = |ix: usize| { + let edit = editing( + "border editor", + &border, + on_change, + move |border, value: f64| border.radii[ix] = value as f32, + ); + scrub(CORNERS[ix], border.radii[ix]).on_change(edit) + }; + div() + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_2() + .child(color_well( + (id.clone(), "color").into(), + border.color, + editing( + "border editor", + &border, + on_change, + |border, color: Hsla| border.color = color, + ), + )) + .child(div().flex_1().min_w_0().child( + scrub("Width", border.width).on_change(at(|b, v| b.width = v as f32)), + )), + ) + .child(dash) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(div().flex_1().min_w_0().child(if split { + Ellipsis::new("Corners").into_any_element() + } else { + scrub("Radius", border.radii[0]) + .on_change(at(|b, v| b.radii = [v as f32; 4])) + .into_any_element() + })) + .child(corners), + ) + .when(split, |editor| { + editor + .child(pair(corner(0), corner(1))) + .child(pair(corner(3), corner(2))) + }) + } +} diff --git a/src/canvas/tests/mod.rs b/src/canvas/tests/mod.rs index b1832f2..b17319d 100644 --- a/src/canvas/tests/mod.rs +++ b/src/canvas/tests/mod.rs @@ -7,6 +7,7 @@ use gpui::{ use super::{Frame, InfiniteCanvas, MiniMap, Tool, Viewport, ZoomControls}; use crate::{primitives::FocusNext, theme::Theme}; +mod panels; mod tools; /// A view that shows one canvas part and keeps the viewports and words it heard. @@ -41,6 +42,7 @@ fn stage( cx.update(|cx| { Theme::init(cx); Theme::update(cx, |theme| theme.reduced_motion = true); + crate::forms::bind_keys(cx); cx.bind_keys([gpui::KeyBinding::new("tab", FocusNext, None)]); }); let (host, cx) = cx.add_window_view(|_, _| Stage { diff --git a/src/canvas/tests/panels.rs b/src/canvas/tests/panels.rs new file mode 100644 index 0000000..69c106c --- /dev/null +++ b/src/canvas/tests/panels.rs @@ -0,0 +1,137 @@ +use gpui::{AnyElement, Entity, IntoElement, TestAppContext, VisualTestContext}; + +use super::{Stage, said, say, settle, stage}; +use crate::canvas::{ + AssetPanel, AutoLayout, AutoLayoutControls, ExportFormat, ExportPanel, ExportSetting, Flow, + Frame, LayerPanel, Place, Shape, ShapeKind, +}; + +fn tab(stops: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + (0..stops).for_each(|_| window.focus_next()); + }); + settle(cx); +} + +fn tap(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn layers(_: &Stage, owner: Entity) -> AnyElement { + let shapes = ["a", "b", "c"].map(|key| { + Shape::new( + key, + key.to_uppercase(), + ShapeKind::Rect, + Frame::new(0.0, 0.0, 10.0, 10.0), + ) + }); + LayerPanel::new("layers", shapes) + .on_show(move |keys, _, cx| { + let mut keys = keys.to_vec(); + keys.sort(); + say(&owner, format!("shown {keys:?}"), cx) + }) + .into_any_element() +} + +#[gpui::test] +fn space_on_the_top_layer_hides_it(cx: &mut TestAppContext) { + let (host, cx) = stage(layers, cx); + tab(1, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["shown [\"a\", \"b\"]"], + "c paints last, so it heads the list" + ); +} + +fn places(_: &Stage, owner: Entity) -> AnyElement { + let layout = AutoLayout { + flow: Flow::Row, + gap: 8.0, + padding: (8.0, 8.0), + place: (Place::Start, Place::Start), + }; + AutoLayoutControls::new("auto", layout) + .on_change(move |next, _, cx| say(&owner, format!("{:?}", next.place), cx)) + .into_any_element() +} + +#[gpui::test] +fn arrows_move_the_place_and_stop_at_the_edge(cx: &mut TestAppContext) { + let (host, cx) = stage(places, cx); + let grid = cx.debug_bounds("places").expect("the place grid"); + cx.simulate_click(grid.center(), gpui::Modifiers::none()); + settle(cx); + for key in ["right", "down", "left", "up"] { + tap(key, cx); + } + assert_eq!( + said(&host, cx), + ["(Center, Center)", "(Center, Start)", "(Start, Center)"], + "a press picks; each arrow steps from the owner's place, and none steps past the edge" + ); +} + +fn sizes(_: &Stage, owner: Entity) -> AnyElement { + let (added, sent) = (owner.clone(), owner); + ExportPanel::new( + "export", + "Card", + [ExportSetting { + scale: 1.0, + format: ExportFormat::Png, + }], + ) + .on_change(move |next, _, cx| { + let scales: Vec = next.iter().map(|setting| setting.scale).collect(); + say(&added, format!("sizes {scales:?}"), cx) + }) + .on_export(move |files, _, cx| say(&sent, format!("files {files:?}"), cx)) + .into_any_element() +} + +#[gpui::test] +fn plus_adds_the_next_size_and_export_hands_on_the_files(cx: &mut TestAppContext) { + let (host, cx) = stage(sizes, cx); + tab(1, cx); + tap("space", cx); + tab(5, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["sizes [1.0, 2.0]", "files [\"Card.png\"]"] + ); +} + +fn assets(_: &Stage, owner: Entity) -> AnyElement { + let shape = + |key: &'static str, kind| Shape::new(key, key, kind, Frame::new(0.0, 0.0, 40.0, 40.0)); + AssetPanel::new("assets") + .group( + "Shapes", + [ + shape("Button", ShapeKind::Rect), + shape("Hexagon", ShapeKind::Polygon(6)), + ], + ) + .on_pick(move |shape, _, cx| say(&owner, format!("pick {}", shape.key), cx)) + .into_any_element() +} + +#[gpui::test] +fn a_search_narrows_the_assets_and_enter_picks_the_first(cx: &mut TestAppContext) { + let (host, cx) = stage(assets, cx); + tab(1, cx); + cx.simulate_input("hex"); + settle(cx); + tap("enter", cx); + assert_eq!(said(&host, cx), ["pick Hexagon"]); +} diff --git a/src/canvas/typography.rs b/src/canvas/typography.rs new file mode 100644 index 0000000..d91de99 --- /dev/null +++ b/src/canvas/typography.rs @@ -0,0 +1,186 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, IntoElement, ParentElement, Pixels, RenderOnce, + SharedString, Styled, TextAlign, Window, div, prelude::*, relative, +}; + +use super::panel::{OnEdit, editing, pair}; +use crate::{ + buttons::SegmentedControl, + forms::{Choice, FontPicker, ScrubInput, Select, TextInput}, + primitives::IconName, + theme::{ActiveTheme, Radius}, +}; + +/// How text is set: its family and weight, its size in pixels, its line height as a share of the size, its tracking in pixels, and its alignment. +#[derive(Clone, Debug, PartialEq)] +pub struct TypeStyle { + pub family: SharedString, + pub weight: FontWeight, + pub size: f32, + pub line_height: f32, + pub tracking: f32, + pub align: TextAlign, +} + +const WEIGHTS: [(f32, &str); 9] = [ + (100.0, "Thin"), + (200.0, "Extra light"), + (300.0, "Light"), + (400.0, "Regular"), + (500.0, "Medium"), + (600.0, "Semibold"), + (700.0, "Bold"), + (800.0, "Extra bold"), + (900.0, "Black"), +]; + +const ALIGNS: [(TextAlign, &str, IconName); 3] = [ + (TextAlign::Left, "Left", IconName::AlignLeft), + (TextAlign::Center, "Center", IconName::AlignCenter), + (TextAlign::Right, "Right", IconName::AlignRight), +]; + +/// A text style's family, weight, size, line height, tracking and alignment, over a line set in it. The line shows all but the tracking, which gpui does not set. +#[derive(IntoElement)] +pub struct TypographyPanel { + id: ElementId, + style: TypeStyle, + search: Entity, + on_change: Option>, +} + +impl TypographyPanel { + /// `search` is the family field's text, which the owner keeps. + pub fn new(id: impl Into, style: TypeStyle, search: &Entity) -> Self { + Self { + id: id.into(), + style, + search: search.clone(), + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(TypeStyle, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TypographyPanel { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, style, on_change) = (self.id, self.style, &self.on_change); + let theme = cx.theme(); + let family = editing( + "typography panel", + &style, + on_change, + |style, family: SharedString| style.family = family, + ); + let weight = editing( + "typography panel", + &style, + on_change, + |style, weight: f32| style.weight = FontWeight(weight), + ); + let align = editing( + "typography panel", + &style, + on_change, + |style, align: TextAlign| style.align = align, + ); + let at = + |edit: fn(&mut TypeStyle, f64)| editing("typography panel", &style, on_change, edit); + let scrub = |name: &'static str, value: f32| { + ScrubInput::new((id.clone(), name), name, value as f64) + }; + let named = WEIGHTS + .iter() + .find(|(weight, _)| *weight == style.weight.0) + .unwrap_or_else(|| panic!("typography panel: no weight {}", style.weight.0)); + let (_, word, _) = ALIGNS + .iter() + .find(|(align, _, _)| *align == style.align) + .expect("every alignment is offered"); + let sample = div() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .overflow_hidden() + .font_family(style.family.clone()) + .font_weight(style.weight) + .text_size(Pixels::from(style.size)) + .line_height(relative(style.line_height)) + .text_color(theme.colors.fg) + .map(|line| match style.align { + TextAlign::Left => line.text_left(), + TextAlign::Center => line.text_center(), + TextAlign::Right => line.text_right(), + }) + .child("The quick brown fox"); + div() + .flex() + .flex_col() + .gap_3() + .child( + FontPicker::new((id.clone(), "family"), &self.search) + .selected(style.family.clone()) + .on_change(move |chosen, window, cx| family(chosen.clone(), window, cx)), + ) + .child(pair( + Select::new( + (id.clone(), "weight"), + WEIGHTS + .iter() + .map(|(weight, name)| Choice::new(weight.to_string(), *name)), + ) + .selected(named.0.to_string()) + .on_change(move |value, window, cx| { + weight( + value.parse().expect("a weight the select offers"), + window, + cx, + ) + }), + scrub("Size", style.size) + .range(1.0, 999.0) + .precision(0) + .on_change(at(|s, v| s.size = v as f32)), + )) + .child( + scrub("Line height", style.line_height) + .range(0.5, 4.0) + .step(0.05) + .precision(2) + .on_change(at(|s, v| s.line_height = v as f32)), + ) + .child( + scrub("Tracking", style.tracking) + .range(-20.0, 100.0) + .step(0.1) + .precision(1) + .on_change(at(|s, v| s.tracking = v as f32)), + ) + .child( + ALIGNS + .iter() + .fold( + SegmentedControl::new((id, "align"), *word), + |control, (_, word, icon)| control.segment(*word, *word, Some(*icon)), + ) + .on_change(move |word, window, cx| { + let (chosen, _, _) = ALIGNS + .iter() + .find(|(_, name, _)| *name == word.as_ref()) + .expect("an alignment the control offers"); + align(*chosen, window, cx) + }), + ) + .child(sample) + } +} diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index c10527f..4f77cf5 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -39,9 +39,17 @@ icons! { Activity => "activity", AlarmClock => "alarm-clock", AlignCenter => "align-center", + AlignCenterHorizontal => "align-center-horizontal", + AlignCenterVertical => "align-center-vertical", + AlignEndHorizontal => "align-end-horizontal", + AlignEndVertical => "align-end-vertical", + AlignHorizontalDistributeCenter => "align-horizontal-distribute-center", AlignJustify => "align-justify", AlignLeft => "align-left", AlignRight => "align-right", + AlignStartHorizontal => "align-start-horizontal", + AlignStartVertical => "align-start-vertical", + AlignVerticalDistributeCenter => "align-vertical-distribute-center", AppWindow => "app-window", Archive => "archive", ArrowDownLeft => "arrow-down-left", @@ -284,8 +292,10 @@ icons! { Square => "square", SquareFunction => "square-function", SquarePen => "square-pen", + SquareRoundCorner => "square-round-corner", Star => "star", StepForward => "step-forward", + StickyNote => "sticky-note", Strikethrough => "strikethrough", Sun => "sun", Table => "table", diff --git a/src/theme/canvas.rs b/src/theme/canvas.rs index 780b655..c0e2593 100644 --- a/src/theme/canvas.rs +++ b/src/theme/canvas.rs @@ -26,6 +26,15 @@ pub struct CanvasSizes { pub map: (Rems, Rems), /// A handle, square. pub handle: Rems, + /// An asset's tile, square, and the room around its drawing. + pub asset: Rems, + pub asset_inset: Rems, + /// The constraints picture, square. + pub pins: Rems, + /// A place in the auto layout grid, square; the chosen one's mark and the rest's pips. + pub place: Rems, + pub place_mark: Rems, + pub place_pip: Rems, } impl Theme { @@ -42,6 +51,12 @@ impl Theme { note_corner: px_to_rems(4.0), map: (px_to_rems(200.0), px_to_rems(140.0)), handle: px_to_rems(8.0), + asset: px_to_rems(64.0), + asset_inset: px_to_rems(10.0), + pins: px_to_rems(72.0), + place: px_to_rems(20.0), + place_mark: px_to_rems(8.0), + place_pip: px_to_rems(3.0), } } } diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 1129883..f35fba3 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -56,18 +56,18 @@ ## T32c Canvas · Panels -- [ ] LayerPanel -- [ ] AssetPanel -- [ ] InspectorPanel -- [ ] AlignmentToolbar -- [ ] ColorPanel -- [ ] GradientPanel → `forms::GradientEditor` -- [ ] ShadowEditor -- [ ] BorderEditor -- [ ] TypographyPanel -- [ ] ConstraintsEditor -- [ ] AutoLayoutControls -- [ ] ExportPanel +- [x] LayerPanel +- [x] AssetPanel +- [x] InspectorPanel +- [x] AlignmentToolbar +- [x] ColorPanel +- [x] GradientPanel → `forms::GradientEditor` +- [x] ShadowEditor +- [x] BorderEditor +- [x] TypographyPanel +- [x] ConstraintsEditor +- [x] AutoLayoutControls +- [x] ExportPanel ## T32d Canvas · Graphs and boards