Canvas · Panels: layers, inspector, alignment, assets, style and layout editors

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 07:58:27 -04:00
1 parent c637347a2a
commit 07b875b0d3
32 files changed
+3118 -18

No files matched your search

+2
View File
@@ -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`).
+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-center-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 12h20" />
<path d="M10 16v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-4" />
<path d="M10 8V4a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v4" />
<path d="M20 16v1a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-1" />
<path d="M14 8V7c0-1.1.9-2 2-2h2a2 2 0 0 1 2 2v1" />
</svg>

After

Width:  |  Height:  |  Size: 551 B

+19
View File
@@ -0,0 +1,19 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-center-vertical"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v20" />
<path d="M8 10H4a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h4" />
<path d="M16 10h4a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2h-4" />
<path d="M8 20H7a2 2 0 0 1-2-2v-2c0-1.1.9-2 2-2h1" />
<path d="M16 14h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-1" />
</svg>

After

Width:  |  Height:  |  Size: 549 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-end-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="6" height="16" x="4" y="2" rx="2" />
<rect width="6" height="9" x="14" y="9" rx="2" />
<path d="M22 22H2" />
</svg>

After

Width:  |  Height:  |  Size: 427 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-end-vertical"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="16" height="6" x="2" y="4" rx="2" />
<rect width="9" height="6" x="9" y="14" rx="2" />
<path d="M22 22V2" />
</svg>

After

Width:  |  Height:  |  Size: 425 B

@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-horizontal-distribute-center"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="6" height="14" x="4" y="5" rx="2" />
<rect width="6" height="10" x="14" y="7" rx="2" />
<path d="M17 22v-5" />
<path d="M17 7V2" />
<path d="M7 22v-3" />
<path d="M7 5V2" />
</svg>

After

Width:  |  Height:  |  Size: 512 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-start-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="6" height="16" x="4" y="6" rx="2" />
<rect width="6" height="9" x="14" y="6" rx="2" />
<path d="M22 2H2" />
</svg>

After

Width:  |  Height:  |  Size: 428 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-start-vertical"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="9" height="6" x="6" y="14" rx="2" />
<rect width="16" height="6" x="6" y="4" rx="2" />
<path d="M2 2v20" />
</svg>

After

Width:  |  Height:  |  Size: 426 B

@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-align-vertical-distribute-center"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 17h-3" />
<path d="M22 7h-5" />
<path d="M5 17H2" />
<path d="M7 7H2" />
<rect x="5" y="14" width="14" height="6" rx="2" />
<rect x="7" y="4" width="10" height="6" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 510 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-square-round-corner"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 11a8 8 0 0 0-8-8" />
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
</svg>

After

Width:  |  Height:  |  Size: 406 B

+16
View File
@@ -0,0 +1,16 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-sticky-note"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 9a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 15 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2z" />
<path d="M15 3v5a1 1 0 0 0 1 1h5" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

+317
View File
@@ -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<GradientStop>,
shadows: Vec<Shadow>,
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<ExportSetting>,
exported: Vec<String>,
}
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()))
}),
),
)
}
+5
View File
@@ -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()
}
+188
View File
@@ -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<Shape> {
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<Shape> {
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<Shape> = 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<SharedString> = studio.selected.clone();
let frames: Vec<Frame> = 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)),
)
}
+6 -6
View File
@@ -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<Shape>,
selected: Vec<SharedString>,
pub(super) struct Studio {
pub(super) shapes: Vec<Shape>,
pub(super) selected: Vec<SharedString>,
tool: Tool,
brush: Brush,
view: Viewport,
steps: Vec<(SharedString, Vec<Shape>)>,
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<Studio> {
pub(super) fn studio(window: &mut Window, cx: &mut App) -> Entity<Studio> {
keep("canvas-studio", Studio::new, window, cx)
}
+222
View File
@@ -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<dyn Fn(Align, &mut Window, &mut App)>;
/// 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<Frame> {
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::<Vec<_>>();
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<Frame> {
let mut order: Vec<usize> = (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<OnAlign>,
}
impl AlignmentToolbar {
/// `count` shapes are selected.
pub fn new(id: impl Into<ElementId>, 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<Frame> {
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::<Vec<_>>()
};
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::<Vec<_>>()
};
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::<Vec<_>>(),
[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);
}
}
+136
View File
@@ -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<dyn Fn(&Shape, &mut Window, &mut App)>;
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<Shape>)>,
on_pick: Option<OnShape>,
}
impl AssetPanel {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
groups: Vec::new(),
on_pick: None,
}
}
pub fn group(
mut self,
title: impl Into<SharedString>,
shapes: impl IntoIterator<Item = Shape>,
) -> 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<Shape>)> = 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<Vec<Shape>> =
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()
}
+315
View File
@@ -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<Frame> {
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::<f32>()
+ 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<OnEdit<AutoLayout>>,
}
impl AutoLayoutControls {
pub fn new(id: impl Into<ElementId>, 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);
}
}
+267
View File
@@ -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<OnEdit<Constraints>>,
}
impl ConstraintsEditor {
pub fn new(id: impl Into<ElementId>, 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));
}
}
+281
View File
@@ -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<String> {
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<ExportSetting> {
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<ExportSetting>,
on_change: Option<OnEdit<Vec<ExportSetting>>>,
on_export: Option<OnEdit<Vec<String>>>,
}
impl ExportPanel {
/// `name` is the layer's, which the files take.
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
settings: impl IntoIterator<Item = ExportSetting>,
) -> 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<ExportSetting>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_export(
mut self,
handler: impl Fn(Vec<String>, &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);
}
}
+143
View File
@@ -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<Shape>,
on_change: Option<OnEdit<Shape>>,
}
impl InspectorPanel {
pub fn new(id: impl Into<ElementId>, selection: impl IntoIterator<Item = Shape>) -> 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()
}
}
+180
View File
@@ -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<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App)>;
/// 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<SharedString> {
assert!(order.contains(dragged), "layer {dragged} is not listed");
let mut rest: Vec<SharedString> = 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<Shape>,
selected: Vec<SharedString>,
on_select: Option<OnKeys>,
on_show: Option<OnKeys>,
on_rename: Option<OnRename>,
on_restack: Option<OnKeys>,
}
impl LayerPanel {
/// `shapes` in paint order, bottom first, as the canvas takes them.
pub fn new(id: impl Into<ElementId>, shapes: impl IntoIterator<Item = Shape>) -> 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<Item = impl Into<SharedString>>) -> 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<SharedString> = self.shapes.iter().map(|shape| shape.key.clone()).collect();
let shown: Vec<SharedString> = 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<SharedString> {
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);
}
}
+19
View File
@@ -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};
+94
View File
@@ -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<V> = Rc<dyn Fn(V, &mut Window, &mut App)>;
/// A handler that hands `on_change` a copy of `value` changed by `edit`.
pub(crate) fn editing<V, T>(
what: &'static str,
value: &V,
on_change: &Option<OnEdit<V>>,
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<SharedString>, 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<SharedString>,
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),
)
}
+31
View File
@@ -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 {
+372
View File
@@ -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<OnEdit<Hsla>>,
}
impl ColorPanel {
pub fn new(id: impl Into<ElementId>, color: impl Into<Hsla>) -> 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<Item = (impl Into<SharedString>, impl Into<Hsla>)>,
) -> 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<OnEdit<Hsla>>| {
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<Shadow>,
on_change: Option<OnEdit<Vec<Shadow>>>,
}
impl ShadowEditor {
pub fn new(id: impl Into<ElementId>, shadows: impl IntoIterator<Item = Shadow>) -> Self {
Self {
id: id.into(),
shadows: shadows.into_iter().collect(),
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<Shadow>, &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<Shadow>, 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<OnEdit<Border>>,
}
impl BorderEditor {
pub fn new(id: impl Into<ElementId>, 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)))
})
}
}
+2
View File
@@ -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 {
+137
View File
@@ -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<Stage>) -> 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<Stage>) -> 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<Stage>) -> 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<f32> = 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<Stage>) -> 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"]);
}
+186
View File
@@ -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<TextInput>,
on_change: Option<OnEdit<TypeStyle>>,
}
impl TypographyPanel {
/// `search` is the family field's text, which the owner keeps.
pub fn new(id: impl Into<ElementId>, style: TypeStyle, search: &Entity<TextInput>) -> 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)
}
}
+10
View File
@@ -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",
+15
View File
@@ -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),
}
}
}
+12 -12
View File
@@ -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