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

LayerPanel lists the canvas's layers topmost first on lists::Tree: a box
shows or hides each, F2 renames, a drag restacks into a new paint order.
InspectorPanel edits the one selected shape's frame, fill, visibility and
lock. AlignmentToolbar lines up or spreads the selection through
`aligned`. AssetPanel finds shapes by name in a picks grid, each drawn
to fit its tile. ColorPanel, ShadowEditor, BorderEditor and
TypographyPanel edit a look; GradientPanel is forms::GradientEditor.
ConstraintsEditor pins a shape to its frame, with `Constraints::placed`;
AutoLayoutControls sets flow, gap, padding and a place grid, with
`AutoLayout::laid`; ExportPanel names each file once. The panels share
`canvas::panel`: editing, pair, caption, heading and color_well.

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

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
use std::rc::Rc;
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
};
use super::panel::{OnEdit, editing, heading, pair};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::{Choice, Select},
primitives::IconName,
theme::{ActiveTheme, TextSize},
typography::Ellipsis,
};
/// A kind of file to export to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ExportFormat {
Png,
Jpg,
Svg,
Pdf,
}
impl ExportFormat {
pub const ALL: [ExportFormat; 4] = [
ExportFormat::Png,
ExportFormat::Jpg,
ExportFormat::Svg,
ExportFormat::Pdf,
];
pub fn extension(self) -> &'static str {
match self {
ExportFormat::Png => "png",
ExportFormat::Jpg => "jpg",
ExportFormat::Svg => "svg",
ExportFormat::Pdf => "pdf",
}
}
/// Whether it keeps shapes as lines, so no scale applies.
pub fn vector(self) -> bool {
matches!(self, ExportFormat::Svg | ExportFormat::Pdf)
}
}
/// One file to export: a scale and a format.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ExportSetting {
pub scale: f32,
pub format: ExportFormat,
}
/// The scales a setting offers, in the order plus adds them.
const SCALES: [f32; 5] = [1.0, 2.0, 3.0, 4.0, 0.5];
impl ExportSetting {
/// The file it writes for a layer called `name`: a scale other than one after an @, then the extension. A vector format takes no scale.
pub fn file(&self, name: &str) -> String {
let extension = self.format.extension();
if self.format.vector() || self.scale == 1.0 {
format!("{name}.{extension}")
} else {
format!("{name}@{}x.{extension}", self.scale)
}
}
}
/// The files `settings` write for `name`, each once, in order.
pub fn export_files(name: &str, settings: &[ExportSetting]) -> Vec<String> {
settings.iter().fold(Vec::new(), |mut files, setting| {
let file = setting.file(name);
if !files.contains(&file) {
files.push(file);
}
files
})
}
/// What plus adds: the first scale no PNG has yet.
fn added(settings: &[ExportSetting]) -> Option<ExportSetting> {
SCALES
.iter()
.find(|scale| {
!settings
.iter()
.any(|setting| setting.format == ExportFormat::Png && setting.scale == **scale)
})
.map(|scale| ExportSetting {
scale: *scale,
format: ExportFormat::Png,
})
}
/// What a layer exports to. Each row picks a scale and a format and minus drops it; plus adds the next scale as PNG; Export hands the owner the files, each once, and rests disabled with none.
#[derive(IntoElement)]
pub struct ExportPanel {
id: ElementId,
name: SharedString,
settings: Vec<ExportSetting>,
on_change: Option<OnEdit<Vec<ExportSetting>>>,
on_export: Option<OnEdit<Vec<String>>>,
}
impl ExportPanel {
/// `name` is the layer's, which the files take.
pub fn new(
id: impl Into<ElementId>,
name: impl Into<SharedString>,
settings: impl IntoIterator<Item = ExportSetting>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
settings: settings.into_iter().collect(),
on_change: None,
on_export: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<ExportSetting>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
pub fn on_export(
mut self,
handler: impl Fn(Vec<String>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_export = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ExportPanel {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let (id, settings, on_change) = (self.id, self.settings, &self.on_change);
let files = export_files(&self.name, &settings);
let next = added(&settings);
let add = editing("export panel", &settings, on_change, move |all, _: ()| {
all.extend(next)
});
let rows = settings.iter().enumerate().map(|(ix, setting)| {
let scale = editing(
"export panel",
&settings,
on_change,
move |all, scale: f32| all[ix].scale = scale,
);
let format = editing(
"export panel",
&settings,
on_change,
move |all, format: ExportFormat| all[ix].format = format,
);
let remove = editing("export panel", &settings, on_change, move |all, _: ()| {
all.remove(ix);
});
div()
.flex()
.items_center()
.gap_2()
.child(
div().flex_1().min_w_0().child(pair(
Select::new(
(id.clone(), format!("scale-{ix}")),
SCALES
.iter()
.map(|scale| Choice::new(scale.to_string(), format!("{scale}x"))),
)
.selected(setting.scale.to_string())
.disabled(setting.format.vector())
.on_change(move |value, window, cx| {
scale(
value.parse().expect("a scale the select offers"),
window,
cx,
)
}),
Select::new(
(id.clone(), format!("format-{ix}")),
ExportFormat::ALL.iter().map(|format| {
Choice::new(format.extension(), format.extension().to_uppercase())
}),
)
.selected(setting.format.extension())
.on_change(move |value, window, cx| {
let chosen = *ExportFormat::ALL
.iter()
.find(|format| format.extension() == value.as_ref())
.expect("a format the select offers");
format(chosen, window, cx)
}),
)),
)
.child(
IconButton::new((id.clone(), format!("remove-{ix}")), IconName::Minus)
.tooltip("Remove the size")
.on_click(move |_, window, cx| remove((), window, cx)),
)
});
let theme = cx.theme();
let (exported, on_export) = (files.clone(), self.on_export);
div()
.flex()
.flex_col()
.gap_3()
.child(heading(
"Export",
IconButton::new((id.clone(), "add"), IconName::Plus)
.tooltip("Add a size")
.disabled(next.is_none())
.on_click(move |_, window, cx| add((), window, cx)),
cx,
))
.children(rows)
.child(
div()
.flex()
.flex_col()
.gap_0p5()
.text_size(theme.text_size(TextSize::Xs))
.text_color(theme.colors.fg_subtle)
.children(files.into_iter().map(Ellipsis::new)),
)
.child(
Button::new((id, "export"), format!("Export {}", self.name))
.variant(ButtonVariant::Primary)
.disabled(exported.is_empty())
.on_click(move |_, window, cx| {
log::info!("export panel: {exported:?}");
if let Some(on_export) = &on_export {
on_export(exported.clone(), window, cx);
}
}),
)
}
}
#[cfg(test)]
mod tests {
use super::{ExportFormat, ExportSetting, added, export_files};
fn at(scale: f32, format: ExportFormat) -> ExportSetting {
ExportSetting { scale, format }
}
#[test]
fn files_name_their_scale_and_come_once() {
let settings = [
at(1.0, ExportFormat::Png),
at(2.0, ExportFormat::Png),
at(0.5, ExportFormat::Jpg),
at(2.0, ExportFormat::Svg),
at(3.0, ExportFormat::Svg),
at(2.0, ExportFormat::Png),
];
assert_eq!(
export_files("Card", &settings),
["Card.png", "Card@2x.png", "Card@0.5x.jpg", "Card.svg"]
);
}
#[test]
fn plus_adds_the_first_scale_no_png_has() {
assert_eq!(added(&[]), Some(at(1.0, ExportFormat::Png)));
assert_eq!(
added(&[at(1.0, ExportFormat::Png), at(2.0, ExportFormat::Jpg)]),
Some(at(2.0, ExportFormat::Png))
);
let every: Vec<_> = [1.0, 2.0, 3.0, 4.0, 0.5]
.iter()
.map(|scale| at(*scale, ExportFormat::Png))
.collect();
assert_eq!(added(&every), None);
}
}