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