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.
No files matched your search
@@ -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`).
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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()))
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||