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.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-27 07:58:27 -04:00
1 parent 7d493b669b
commit c04c67d39a
32 files changed
+3118 -18

No files matched your search

+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),
}
}
}