1446 lines
67 KiB
Rust
1446 lines
67 KiB
Rust
//! The right panel: Design, Prototype and Inspect tabs.
|
||
|
||
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||
use figcraft_color::{BlendMode, Paint, PaintKind};
|
||
use figcraft_doc::*;
|
||
use serde_json::{Value, json};
|
||
|
||
use crate::icons::{self, Icon};
|
||
use crate::theme::{ACCENT, Tokens, regular, semibold};
|
||
use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD};
|
||
use crate::{FigApp, RightTab};
|
||
|
||
/// Common value of `f` across `ids` (None when mixed or empty).
|
||
pub(crate) fn common<T: PartialEq + Clone>(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> T) -> Option<T> {
|
||
let mut it = ids.iter().filter_map(|i| doc.node(*i));
|
||
let first = f(it.next()?);
|
||
if it.all(|n| f(n) == first) { Some(first) } else { None }
|
||
}
|
||
|
||
impl FigApp {
|
||
/// Set properties on `ids` (one undo step).
|
||
pub fn set_props(&mut self, ids: &[NodeId], props: Value) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
self.run("node.set", json!({"ids": ids, "props": props}));
|
||
}
|
||
|
||
/// Apply a field edit: scrubs are live previews committed as one step.
|
||
pub fn field(&mut self, ids: &[NodeId], key: &str, edit: Option<FieldEdit>, map: impl Fn(f64) -> Value) {
|
||
if ids.is_empty() {
|
||
return;
|
||
}
|
||
self.field_run(edit, "node.set", |v| json!({"ids": ids, "props": { key: map(v) }}));
|
||
}
|
||
|
||
/// Apply a field edit through any command: `params` builds the command's params from the value.
|
||
pub fn field_run(&mut self, edit: Option<FieldEdit>, cmd: &str, params: impl Fn(f64) -> Value) {
|
||
match edit {
|
||
Some(FieldEdit::Set(v)) => {
|
||
self.run(cmd, params(v));
|
||
}
|
||
Some(FieldEdit::Scrub(v, done)) => {
|
||
let _ = self.session.begin_interaction("Edit");
|
||
let _ = self.session.execute(cmd, ¶ms(v));
|
||
if done {
|
||
let _ = self.session.commit_interaction();
|
||
}
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let w = ui.available_width();
|
||
// Top bar: avatar, present, share.
|
||
let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
|
||
ui.painter().circle_filled(pos2(top.min.x + 26.0, top.center().y), 13.0, Color32::from_rgb(0x14, 0xAE, 0x5C));
|
||
ui.painter().text(pos2(top.min.x + 26.0, top.center().y), Align2::CENTER_CENTER, "Y", semibold(12.0), Color32::WHITE);
|
||
icons::paint(ui.painter(), Rect::from_center_size(pos2(top.min.x + 48.0, top.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
|
||
let share = Rect::from_min_size(pos2(top.max.x - 70.0, top.center().y - 16.0), vec2(56.0, 32.0));
|
||
let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).on_hover_text("Export and share");
|
||
ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT });
|
||
ui.painter().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.5), Color32::WHITE);
|
||
if sresp.clicked() {
|
||
app.ui.export_preview = true;
|
||
}
|
||
let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0));
|
||
let presp = ui.interact(play, ui.id().with("play"), Sense::click()).on_hover_text("Present ⌥⌘↵");
|
||
if presp.hovered() {
|
||
ui.painter().rect_filled(play, CornerRadius::same(5), t.hover);
|
||
}
|
||
let tri = Rect::from_center_size(play.center() - vec2(7.0, 0.0), vec2(16.0, 16.0));
|
||
let c = tri.center();
|
||
ui.painter().add(egui::Shape::closed_line(vec![c + vec2(-5.0, -7.0), c + vec2(7.0, 0.0), c + vec2(-5.0, 7.0)], Stroke::new(1.4, t.icon)));
|
||
icons::paint(ui.painter(), Rect::from_center_size(play.center() + vec2(12.0, 0.0), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
|
||
if presp.clicked() {
|
||
app.run("proto.present", json!({}));
|
||
}
|
||
// Tabs row.
|
||
let (tabs, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||
ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border));
|
||
let mut x = tabs.min.x + 8.0;
|
||
for (tab, label) in [(RightTab::Design, "Design"), (RightTab::Prototype, "Prototype")] {
|
||
let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text);
|
||
let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0));
|
||
let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click());
|
||
let active = app.ui.right_tab == tab;
|
||
if active {
|
||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||
}
|
||
ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary });
|
||
if resp.clicked() {
|
||
app.ui.right_tab = tab;
|
||
}
|
||
x = r.max.x + 2.0;
|
||
}
|
||
// Zoom menu.
|
||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||
let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0));
|
||
let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click());
|
||
if zresp.hovered() {
|
||
ui.painter().rect_filled(zr, CornerRadius::same(5), t.hover);
|
||
}
|
||
ui.painter().text(pos2(zr.min.x + 6.0, zr.center().y), Align2::LEFT_CENTER, format!("{}%", (zoom * 100.0).round()), regular(11.0), t.text);
|
||
icons::paint(ui.painter(), Rect::from_center_size(pos2(zr.max.x - 9.0, zr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
|
||
egui::Popup::menu(&zresp).width(200.0).show(|ui| {
|
||
for (label, cmd) in [
|
||
("Zoom in", "view.zoomIn"),
|
||
("Zoom out", "view.zoomOut"),
|
||
("Zoom to fit", "view.zoomFit"),
|
||
("Zoom to 50%", "view.zoom50"),
|
||
("Zoom to 100%", "view.zoom100"),
|
||
("Zoom to 200%", "view.zoom200"),
|
||
("Zoom to selection", "view.zoomSelection"),
|
||
] {
|
||
if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) {
|
||
app.run(cmd, json!({}));
|
||
ui.close();
|
||
}
|
||
}
|
||
ui.separator();
|
||
for (label, cmd) in [
|
||
("Pixel preview", "view.pixelPreview"),
|
||
("Pixel grid", "view.pixelGrid"),
|
||
("Snap to pixel grid", "view.snapPixel"),
|
||
("Layout grids", "view.layoutGrids"),
|
||
("Rulers", "view.rulers"),
|
||
("Outlines", "view.outlines"),
|
||
] {
|
||
if menu_item(ui, label, figcraft_engine::catalog::shortcut(cmd).unwrap_or("")) {
|
||
app.run(cmd, json!({}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
ui.painter().line_segment([tabs.left_bottom(), tabs.right_bottom()], Stroke::new(1.0, t.border));
|
||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||
ui.spacing_mut().item_spacing = vec2(8.0, 0.0);
|
||
if app.session.tool.tool == figcraft_engine::ToolId::Comment {
|
||
crate::comments::panel(app, ui);
|
||
ui.add_space(80.0);
|
||
return;
|
||
}
|
||
match app.ui.right_tab {
|
||
// The Frame tool shows the frame presets.
|
||
RightTab::Design if app.session.tool.tool == figcraft_engine::ToolId::Frame => crate::frame_presets::panel(app, ui),
|
||
RightTab::Design => design(app, ui),
|
||
RightTab::Prototype => crate::proto_panel::prototype(app, ui),
|
||
RightTab::Inspect => inspect(app, ui),
|
||
}
|
||
ui.add_space(80.0);
|
||
});
|
||
}
|
||
|
||
pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
|
||
let t = Tokens::get(ui.ctx());
|
||
let w = ui.available_width().max(180.0);
|
||
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT);
|
||
}
|
||
let col = if resp.hovered() { Color32::WHITE } else { t.text };
|
||
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.5), col);
|
||
if !shortcut.is_empty() {
|
||
ui.painter().text(
|
||
pos2(r.max.x - 8.0, r.center().y),
|
||
Align2::RIGHT_CENTER,
|
||
crate::toolbar::pretty_shortcut(shortcut),
|
||
regular(11.0),
|
||
if resp.hovered() { Color32::WHITE } else { t.text_secondary },
|
||
);
|
||
}
|
||
resp.clicked()
|
||
}
|
||
|
||
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
|
||
block(ui, h, (h - FIELD_H) / 2.0, add);
|
||
}
|
||
|
||
/// A panel row `h` tall whose content is inset `inset_y` from its top and bottom (for content
|
||
/// taller than one field).
|
||
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) {
|
||
let w = ui.available_width();
|
||
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
|
||
let mut child =
|
||
ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center)));
|
||
child.spacing_mut().item_spacing = vec2(8.0, 0.0);
|
||
add(&mut child);
|
||
}
|
||
|
||
fn design(app: &mut FigApp, ui: &mut Ui) {
|
||
let Ok(ds) = app.session.ds() else { return };
|
||
let doc = ds.doc.clone();
|
||
let sel = ds.selection.clone();
|
||
let vector_edit = ds.vector_edit.clone();
|
||
if sel.is_empty() {
|
||
page_section(app, ui, &doc);
|
||
return;
|
||
}
|
||
header(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
match vector_edit {
|
||
Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve),
|
||
None => position(app, ui, &doc, &sel),
|
||
}
|
||
widgets::separator(ui);
|
||
crate::layout_panel::layout_section(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
appearance(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) {
|
||
crate::typography::typography(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
}
|
||
paints(app, ui, &doc, &sel, true);
|
||
widgets::separator(ui);
|
||
paints(app, ui, &doc, &sel, false);
|
||
widgets::separator(ui);
|
||
effects(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
|
||
grids(app, ui, &doc, &sel);
|
||
widgets::separator(ui);
|
||
}
|
||
export(app, ui, &doc, &sel);
|
||
}
|
||
|
||
fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Ok(page) = app.session.page() else { return };
|
||
let bg = match doc.node(page).map(|n| &n.kind) {
|
||
Some(NodeKind::Page { background, .. }) => *background,
|
||
_ => figcraft_color::Color::DEFAULT_PAGE,
|
||
};
|
||
widgets::section_header(ui, "Page", &[], false);
|
||
let mut open_picker = false;
|
||
let mut new_hex = None;
|
||
row(ui, 32.0, |ui| {
|
||
let p = Paint::solid(bg);
|
||
if widgets::swatch(ui, &p, 16.0).clicked() {
|
||
open_picker = true;
|
||
}
|
||
if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") {
|
||
new_hex = Some(h);
|
||
}
|
||
widgets::label(ui, "100 %", t.text);
|
||
});
|
||
if open_picker {
|
||
app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);
|
||
}
|
||
if let Some(h) = new_hex
|
||
&& let Some(c) = figcraft_color::Color::from_hex(&h)
|
||
{
|
||
app.run("page.setBackground", json!({"color": c.to_css_hex()}));
|
||
}
|
||
widgets::separator(ui);
|
||
// Local variables & styles.
|
||
let vars = doc.variables.variables.len();
|
||
widgets::section_header(ui, "Local variables", &[], vars == 0);
|
||
row(ui, 32.0, |ui| {
|
||
let r = widgets::dropdown(ui, &format!("Open variables ({vars})"), 200.0, Some(Icon::Styles));
|
||
if r.clicked() {
|
||
app.ui.preferences = false;
|
||
app.ui.palette = None;
|
||
ui.data_mut(|d| d.insert_temp(Id::new("variables_open"), true));
|
||
}
|
||
});
|
||
widgets::separator(ui);
|
||
let styles: Vec<(String, String, String)> = doc.styles.iter().map(|s| (s.id.clone(), s.name.clone(), s.kind.type_name().to_string())).collect();
|
||
widgets::section_header(ui, "Local styles", &[], styles.is_empty());
|
||
for (_, name, ty) in &styles {
|
||
row(ui, 28.0, |ui| {
|
||
let icon = match ty.as_str() {
|
||
"text" => Icon::Text,
|
||
"effect" => Icon::Effects,
|
||
"grid" => Icon::Grid,
|
||
_ => Icon::Fill,
|
||
};
|
||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||
icons::paint(ui.painter(), r, icon, t.icon_secondary);
|
||
widgets::label(ui, name, t.text);
|
||
});
|
||
}
|
||
widgets::separator(ui);
|
||
let all: Vec<NodeId> = doc.node(page).map(|p| p.children.clone()).unwrap_or_default();
|
||
widgets::section_header(ui, "Export", &[(Icon::Plus, "Export page")], false);
|
||
row(ui, 32.0, |ui| {
|
||
let w = ui.available_width();
|
||
let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click());
|
||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||
ui.painter().text(r.center(), Align2::CENTER_CENTER, "Export page as PNG", regular(11.0), t.text);
|
||
if resp.clicked() {
|
||
export_now(app, &all, "png", 1.0);
|
||
}
|
||
});
|
||
}
|
||
|
||
fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let label = match common(doc, sel, |n| n.kind.label()) {
|
||
Some(l) if sel.len() == 1 => l.to_string(),
|
||
Some(l) => format!("{l}s"),
|
||
None => "Mixed".into(),
|
||
};
|
||
let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame));
|
||
let w = ui.available_width();
|
||
let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover());
|
||
let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0));
|
||
let resp = ui.interact(lr, ui.id().with("typedd"), Sense::click());
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover);
|
||
}
|
||
ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(13.0), t.text);
|
||
if is_frame {
|
||
icons::paint(
|
||
ui.painter(),
|
||
Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)),
|
||
Icon::Chevron,
|
||
t.icon_secondary,
|
||
);
|
||
let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0));
|
||
let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).on_hover_text("Swap orientation");
|
||
if sresp.hovered() {
|
||
ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover);
|
||
}
|
||
icons::paint(ui.painter(), sr.shrink(5.0), Icon::Rotate, t.icon);
|
||
if sresp.clicked() {
|
||
app.run("frame.swapOrientation", json!({"ids": sel}));
|
||
}
|
||
let ids: Vec<NodeId> = sel.to_vec();
|
||
egui::Popup::menu(&resp).width(260.0).show(|ui| {
|
||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||
let mut cat = "";
|
||
for (c, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS {
|
||
if *c != cat {
|
||
cat = c;
|
||
ui.label(egui::RichText::new(*c).font(semibold(11.0)).color(t.text_secondary));
|
||
}
|
||
if menu_item(ui, name, &format!("{}×{}", pw, ph)) {
|
||
app.set_props(&ids, json!({"width": pw, "height": ph}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
// Quick actions on the right.
|
||
let mut x = r.max.x - 8.0;
|
||
let actions: Vec<(Icon, &str, &str)> = vec![
|
||
(Icon::Component, "Create component ⌥⌘K", "component.create"),
|
||
(Icon::Mask, "Use as mask", "object.mask"),
|
||
(Icon::Union, "Boolean groups", "bool.union"),
|
||
];
|
||
for (icon, tip, cmd) in actions.into_iter().rev() {
|
||
let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0));
|
||
let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).on_hover_text(tip);
|
||
if bresp.hovered() {
|
||
ui.painter().rect_filled(br, CornerRadius::same(5), t.hover);
|
||
}
|
||
icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon);
|
||
if cmd == "bool.union" {
|
||
egui::Popup::menu(&bresp).width(180.0).show(|ui| {
|
||
for (l, c) in [
|
||
("Union", "bool.union"),
|
||
("Subtract", "bool.subtract"),
|
||
("Intersect", "bool.intersect"),
|
||
("Exclude", "bool.exclude"),
|
||
("Flatten", "vector.flatten"),
|
||
] {
|
||
if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) {
|
||
app.run(c, json!({}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
} else if bresp.clicked() {
|
||
app.run(cmd, json!({}));
|
||
}
|
||
x -= 26.0;
|
||
}
|
||
crate::component_panel::instance_section(app, ui, doc, sel);
|
||
}
|
||
|
||
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
widgets::section_header(ui, "Position", &[], false);
|
||
let ids = sel.to_vec();
|
||
let col = widgets::col_width(ui.available_width());
|
||
widgets::sublabels(ui, "Alignment", None);
|
||
row(ui, 32.0, |ui| {
|
||
let items = [
|
||
(Icon::AlignLeft, "Align left ⌥A"),
|
||
(Icon::AlignHCenter, "Align horizontal centers ⌥H"),
|
||
(Icon::AlignRight, "Align right ⌥D"),
|
||
(Icon::AlignTop, "Align top ⌥W"),
|
||
(Icon::AlignVCenter, "Align vertical centers ⌥V"),
|
||
(Icon::AlignBottom, "Align bottom ⌥S"),
|
||
];
|
||
let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"];
|
||
let w = col / 3.0;
|
||
if let Some(i) = widgets::segmented(ui, &items[..3], None, w) {
|
||
app.run(cmds[i], json!({"ids": ids}));
|
||
}
|
||
if let Some(i) = widgets::segmented(ui, &items[3..], None, w) {
|
||
app.run(cmds[i + 3], json!({"ids": ids}));
|
||
}
|
||
if sel.len() > 2 {
|
||
let more = icons::button(ui, Icon::DistributeH, 24.0, false, "Distribute and tidy");
|
||
egui::Popup::menu(&more).show(|ui| {
|
||
for (l, c) in [
|
||
("Distribute horizontal spacing", "distribute.horizontal"),
|
||
("Distribute vertical spacing", "distribute.vertical"),
|
||
("Tidy up", "arrange.tidy"),
|
||
] {
|
||
if menu_item(ui, l, figcraft_engine::catalog::shortcut(c).unwrap_or("")) {
|
||
app.run(c, json!({"ids": ids}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
let x = common(doc, sel, |n| (n.transform.as_coeffs()[4] * 100.0).round() / 100.0);
|
||
let y = common(doc, sel, |n| (n.transform.as_coeffs()[5] * 100.0).round() / 100.0);
|
||
let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0);
|
||
let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
|
||
let absolute = common(doc, sel, |n| n.child_layout.absolute);
|
||
widgets::sublabels(ui, "Position", None);
|
||
row(ui, 32.0, |ui| {
|
||
let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0);
|
||
app.field(&ids, "x", e, |v| json!(v));
|
||
let e = widgets::number_field(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0);
|
||
app.field(&ids, "y", e, |v| json!(v));
|
||
// Children of auto layout frames can ignore the layout (absolute position).
|
||
if parent_al {
|
||
let on = absolute.unwrap_or(false);
|
||
if icons::button(ui, Icon::AbsolutePosition, 24.0, on, if on { "Use auto layout" } else { "Ignore auto layout (absolute position)" })
|
||
.clicked()
|
||
{
|
||
app.set_props(&ids, json!({"absolute": !on}));
|
||
}
|
||
}
|
||
});
|
||
widgets::sublabels(ui, "Rotation", None);
|
||
row(ui, 32.0, |ui| {
|
||
let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius), rot, "°", col, 1.0);
|
||
app.field(&ids, "rotation", e, |v| json!(v));
|
||
let items = [(Icon::Rotate, "Rotate 90° right"), (Icon::FlipH, "Flip horizontal ⇧H"), (Icon::FlipV, "Flip vertical ⇧V")];
|
||
if let Some(i) = widgets::segmented(ui, &items, None, col / 3.0) {
|
||
app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids}));
|
||
}
|
||
});
|
||
// Constraints (children of frames without auto layout, or ignoring it).
|
||
let in_frame = sel.iter().all(|i| {
|
||
doc.node(*i).is_some_and(|n| {
|
||
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))
|
||
})
|
||
});
|
||
if in_frame {
|
||
let c = common(doc, sel, |n| n.constraints);
|
||
widgets::sublabels(ui, "Constraints", None);
|
||
row(ui, 32.0, |ui| {
|
||
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
|
||
let resp = widgets::dropdown(ui, &pretty(&h), col, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for (l, v) in [("Left", "left"), ("Right", "right"), ("Left & right", "leftRight"), ("Center", "center"), ("Scale", "scale")] {
|
||
if menu_item(ui, l, "") {
|
||
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
|
||
let resp = widgets::dropdown(ui, &pretty(&v), col, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for (l, val) in [("Top", "top"), ("Bottom", "bottom"), ("Top & bottom", "topBottom"), ("Center", "center"), ("Scale", "scale")] {
|
||
if menu_item(ui, l, "") {
|
||
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
ui.add_space(8.0);
|
||
}
|
||
|
||
fn pretty(s: &str) -> String {
|
||
match s {
|
||
"LeftRight" => "Left & right".into(),
|
||
"TopBottom" => "Top & bottom".into(),
|
||
other => other.to_string(),
|
||
}
|
||
}
|
||
|
||
fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let ids = sel.to_vec();
|
||
let visible = common(doc, sel, |n| n.visible).unwrap_or(true);
|
||
let hdr = widgets::section_header(
|
||
ui,
|
||
"Appearance",
|
||
&[(if visible { Icon::Eye } else { Icon::EyeOff }, "Show/Hide ⇧⌘H"), (Icon::Blend, "Blend mode")],
|
||
false,
|
||
);
|
||
if hdr == Some(0) {
|
||
app.run("object.toggleVisible", json!({"ids": ids}));
|
||
}
|
||
if hdr == Some(1) {
|
||
ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::<bool>(Id::new("blend_open")).unwrap_or(false)));
|
||
}
|
||
let col = widgets::col_width(ui.available_width());
|
||
let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round());
|
||
let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius()));
|
||
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
|
||
widgets::sublabels(ui, "Opacity", radius_ok.then_some("Corner radius"));
|
||
row(ui, 32.0, |ui| {
|
||
let e = widgets::number_field(ui, Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col, 1.0);
|
||
app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0)));
|
||
if radius_ok {
|
||
let e =
|
||
crate::variable_ui::number_field(app, ui, &ids, "cornerRadius", Id::new("ap_rad"), Lead::Icon(Icon::CornerRadius), r.flatten(), col);
|
||
app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0)));
|
||
let open = ui.data(|d| d.get_temp::<bool>(Id::new("indep_corners"))).unwrap_or(false);
|
||
if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Individual corners").clicked() {
|
||
ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open));
|
||
}
|
||
}
|
||
});
|
||
if radius_ok && ui.data(|d| d.get_temp::<bool>(Id::new("indep_corners"))).unwrap_or(false) {
|
||
let radii = common(doc, sel, |n| n.corners.radii);
|
||
let keys = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"];
|
||
row(ui, 32.0, |ui| {
|
||
for (k, key) in keys.iter().enumerate().take(2) {
|
||
let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0);
|
||
app.field(&ids, key, e, |v| json!(v.max(0.0)));
|
||
}
|
||
});
|
||
row(ui, 32.0, |ui| {
|
||
for (k, key) in keys.iter().enumerate().skip(2) {
|
||
let e = widgets::number_field(ui, Id::new(("corner", k)), Lead::Icon(Icon::CornerRadius), radii.map(|r| r[k]), "", col, 1.0);
|
||
app.field(&ids, key, e, |v| json!(v.max(0.0)));
|
||
}
|
||
});
|
||
let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round());
|
||
widgets::sublabels(ui, "Corner smoothing", None);
|
||
row(ui, 32.0, |ui| {
|
||
let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0);
|
||
app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0)));
|
||
widgets::label(ui, "iOS: 60%", Tokens::get(ui.ctx()).text_tertiary);
|
||
});
|
||
}
|
||
let blend = common(doc, sel, |n| n.blend);
|
||
let show_blend = ui.data(|d| d.get_temp::<bool>(Id::new("blend_open"))).unwrap_or(false)
|
||
|| blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough));
|
||
if show_blend {
|
||
widgets::sublabels(ui, "Blend mode", None);
|
||
row(ui, 32.0, |ui| {
|
||
let label = blend.map(|b| b.label()).unwrap_or("Mixed");
|
||
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend));
|
||
egui::Popup::menu(&resp).width(200.0).show(|ui| {
|
||
for b in BlendMode::ALL {
|
||
if menu_item(ui, b.label(), "") {
|
||
app.set_props(&ids, json!({"blend": b.css()}));
|
||
ui.close();
|
||
}
|
||
if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) {
|
||
ui.separator();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
}
|
||
ui.add_space(8.0);
|
||
}
|
||
|
||
fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let ids = sel.to_vec();
|
||
let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() });
|
||
let title = if fills { "Fill" } else { "Stroke" };
|
||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
|
||
if let Some(i) =
|
||
widgets::section_header(ui, title, &[(Icon::Styles, "Styles"), (Icon::Plus, if fills { "Add fill" } else { "Add stroke" })], empty)
|
||
{
|
||
if i == 1 {
|
||
app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids}));
|
||
} else {
|
||
ui.data_mut(|d| d.insert_temp(slot.menu_id(), true));
|
||
}
|
||
}
|
||
style_picker(app, ui, doc, &ids, slot);
|
||
style_chip(app, ui, doc, &ids, slot);
|
||
match &list {
|
||
None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)),
|
||
Some(l) => {
|
||
// Top of the list paints on top: show in reverse like Figma.
|
||
for (i, p) in l.iter().enumerate().rev() {
|
||
paint_row(app, ui, &ids, i, p, fills);
|
||
}
|
||
}
|
||
}
|
||
if !fills && !empty {
|
||
let weight = common(doc, sel, |n| n.stroke.weight);
|
||
let align = common(doc, sel, |n| n.stroke.align);
|
||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||
row(ui, 32.0, |ui| {
|
||
let resp = widgets::dropdown(
|
||
ui,
|
||
match align {
|
||
Some(StrokeAlign::Inside) => "Inside",
|
||
Some(StrokeAlign::Center) => "Center",
|
||
Some(StrokeAlign::Outside) => "Outside",
|
||
None => "Mixed",
|
||
},
|
||
half,
|
||
None,
|
||
);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for (l, v) in [("Inside", "inside"), ("Center", "center"), ("Outside", "outside")] {
|
||
if menu_item(ui, l, "") {
|
||
app.set_props(&ids, json!({"strokeAlign": v}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
|
||
let weight_w = half - if sided { 60.0 } else { 32.0 };
|
||
let e = crate::variable_ui::number_field(app, ui, &ids, "strokeWeight", Id::new("stroke_w"), Lead::Icon(Icon::Menu), weight, weight_w);
|
||
app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0)));
|
||
if sided {
|
||
let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, "Individual strokes");
|
||
egui::Popup::menu(&sides_btn).show(|ui| {
|
||
let w = weight.unwrap_or(1.0);
|
||
for (l, v) in [
|
||
("All", Value::Null),
|
||
("Top", json!([w, 0.0, 0.0, 0.0])),
|
||
("Right", json!([0.0, w, 0.0, 0.0])),
|
||
("Bottom", json!([0.0, 0.0, w, 0.0])),
|
||
("Left", json!([0.0, 0.0, 0.0, w])),
|
||
("Custom", json!([w, w, w, w])),
|
||
] {
|
||
if menu_item(ui, l, "") {
|
||
app.set_props(&ids, json!({"strokeSides": v}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
let adv_id = Id::new("stroke_adv");
|
||
let open = ui.data(|d| d.get_temp::<bool>(adv_id)).unwrap_or(false);
|
||
if icons::button(ui, Icon::Dots, 24.0, open, "Advanced stroke").clicked() {
|
||
ui.data_mut(|d| d.insert_temp(adv_id, !open));
|
||
}
|
||
});
|
||
// Per-side weights.
|
||
if let Some(sides) = common(doc, sel, |n| n.stroke.sides).flatten() {
|
||
let quarter = (ui.available_width() - PAD * 2.0 - 3.0 * 6.0) / 4.0;
|
||
row(ui, 32.0, |ui| {
|
||
ui.spacing_mut().item_spacing.x = 6.0;
|
||
for (k, label) in ["T", "R", "B", "L"].iter().enumerate() {
|
||
let v = sides.get(k).copied();
|
||
let e = widgets::number_field(ui, Id::new(("stroke_side", k)), Lead::Text(label), v, "", quarter, 1.0);
|
||
app.field(&ids, "strokeSides", e, |x| {
|
||
let mut n = sides;
|
||
if let Some(slot) = n.get_mut(k) {
|
||
*slot = x.max(0.0);
|
||
}
|
||
json!(n)
|
||
});
|
||
}
|
||
});
|
||
}
|
||
if ui.data(|d| d.get_temp::<bool>(Id::new("stroke_adv"))).unwrap_or(false) {
|
||
stroke_settings(app, ui, doc, sel);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Advanced stroke settings: dash pattern, join and the end points of open paths.
|
||
fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let ids = sel.to_vec();
|
||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||
let st = common(doc, sel, |n| n.stroke.clone());
|
||
let dashes = st.as_ref().map(|s| s.dashes.clone());
|
||
let weight = st.as_ref().map(|s| s.weight).unwrap_or(1.0).max(1.0);
|
||
widgets::sublabels(ui, "Stroke style", None);
|
||
row(ui, 32.0, |ui| {
|
||
let dashed = dashes.as_ref().is_some_and(|d| !d.is_empty());
|
||
let resp = widgets::dropdown(ui, if dashed { "Dash" } else { "Solid" }, half, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
if menu_item(ui, "Solid", "") {
|
||
app.set_props(&ids, json!({"dashes": []}));
|
||
ui.close();
|
||
}
|
||
if menu_item(ui, "Dash", "") {
|
||
app.set_props(&ids, json!({"dashes": [weight * 4.0, weight * 4.0]}));
|
||
ui.close();
|
||
}
|
||
});
|
||
});
|
||
if let Some(d) = dashes.filter(|d| !d.is_empty()) {
|
||
let (dash, gap) = (d.first().copied().unwrap_or(4.0), d.get(1).copied().unwrap_or_else(|| d.first().copied().unwrap_or(4.0)));
|
||
widgets::sublabels(ui, "Dash", Some("Gap"));
|
||
row(ui, 32.0, |ui| {
|
||
let e = widgets::number_field(ui, Id::new("stroke_dash"), Lead::None, Some(dash), "", half, 1.0);
|
||
app.field(&ids, "dashes", e, |v| json!([v.max(0.0), gap]));
|
||
let e = widgets::number_field(ui, Id::new("stroke_gap"), Lead::None, Some(gap), "", half, 1.0);
|
||
app.field(&ids, "dashes", e, |v| json!([dash, v.max(0.0)]));
|
||
});
|
||
}
|
||
let join = st.as_ref().map(|s| s.join);
|
||
widgets::sublabels(ui, "Join", (join == Some(StrokeJoin::Miter)).then_some("Miter angle"));
|
||
row(ui, 32.0, |ui| {
|
||
let label = match join {
|
||
Some(StrokeJoin::Miter) => "Miter",
|
||
Some(StrokeJoin::Bevel) => "Bevel",
|
||
Some(StrokeJoin::Round) => "Round",
|
||
None => "Mixed",
|
||
};
|
||
let resp = widgets::dropdown(ui, label, half, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for (l, v) in [("Miter", "miter"), ("Bevel", "bevel"), ("Round", "round")] {
|
||
if menu_item(ui, l, "") {
|
||
app.set_props(&ids, json!({"strokeJoin": v}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
if join == Some(StrokeJoin::Miter) {
|
||
let limit = st.as_ref().map(|s| s.miter_limit);
|
||
let e = widgets::number_field(ui, Id::new("stroke_miter"), Lead::None, limit, "", half, 0.5);
|
||
app.field(&ids, "miterLimit", e, |v| json!(v.clamp(1.0, 100.0)));
|
||
}
|
||
});
|
||
// End points only mean something on open paths.
|
||
let open_paths = sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Line | NodeKind::Vector { .. })));
|
||
if open_paths {
|
||
widgets::sublabels(ui, "Start point", Some("End point"));
|
||
row(ui, 32.0, |ui| {
|
||
for (key, cap) in [("startCap", st.as_ref().map(|s| s.cap_start)), ("endCap", st.as_ref().map(|s| s.cap_end))] {
|
||
let resp = widgets::dropdown(ui, cap.map(StrokeCap::label).unwrap_or("Mixed"), half, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for c in StrokeCap::ALL {
|
||
if menu_item(ui, c.label(), "") {
|
||
app.set_props(&ids, json!({key: format!("{c:?}")}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
ui.add_space(4.0);
|
||
}
|
||
|
||
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let ids = ids.to_vec();
|
||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||
row(ui, 32.0, |ui| {
|
||
let w = ui.available_width() - 56.0;
|
||
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||
widgets::paint_swatch(ui.painter(), sw, p);
|
||
let sresp = ui.interact(sw.expand(2.0), Id::new(("swatch", fills, i)), Sense::click());
|
||
if sresp.clicked() {
|
||
app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() });
|
||
}
|
||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||
let bound = (i == 0)
|
||
.then(|| app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, &ids, if fills { "fill" } else { "stroke" })))
|
||
.flatten();
|
||
match &p.kind {
|
||
PaintKind::Solid { .. } if bound.is_some() => {
|
||
let name = bound.clone().unwrap_or_default();
|
||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
|
||
child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text(
|
||
pos2(chip.min.x + 5.0, r.center().y),
|
||
Align2::LEFT_CENTER,
|
||
short,
|
||
regular(11.0),
|
||
t.text,
|
||
);
|
||
let _ = ui.interact(chip, Id::new(("bound_paint", fills)), Sense::hover()).on_hover_text(&name);
|
||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||
let dresp = ui.interact(dr, Id::new(("detach_paint", fills)), Sense::click()).on_hover_text("Detach variable");
|
||
icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||
if dresp.clicked() {
|
||
app.run("variable.bind", json!({"ids": ids, "field": if fills { "fill" } else { "stroke" }, "variable": null}));
|
||
}
|
||
}
|
||
PaintKind::Solid { color } => {
|
||
if let Some(h) =
|
||
widgets::text_field_inline(&mut child, Id::new(("hex", fills, i)), &color.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
|
||
&& let Some(c) = figcraft_color::Color::from_hex(&h)
|
||
{
|
||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||
}
|
||
}
|
||
other => {
|
||
let label = match other {
|
||
PaintKind::Gradient(g) => format!("{:?}", g.kind),
|
||
_ => "Image".into(),
|
||
};
|
||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text);
|
||
}
|
||
}
|
||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||
ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel));
|
||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r));
|
||
let e =
|
||
widgets::number_field(&mut child, Id::new(("pop", fills, i)), Lead::None, Some((f64::from(p.opacity) * 100.0).round()), "%", 48.0, 1.0);
|
||
match e {
|
||
Some(FieldEdit::Set(v)) => {
|
||
app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)}));
|
||
}
|
||
Some(FieldEdit::Scrub(v, done)) => {
|
||
let _ = app.session.begin_interaction("Opacity");
|
||
let _ = app.session.execute(cmd, &json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)}));
|
||
if done {
|
||
let _ = app.session.commit_interaction();
|
||
}
|
||
}
|
||
None => {}
|
||
}
|
||
if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle visibility").clicked() {
|
||
app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));
|
||
}
|
||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||
app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i}));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// A style slot on a layer: what a style picker lists and what a style chip shows.
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
enum StyleSlot {
|
||
Fill,
|
||
Stroke,
|
||
Effect,
|
||
Grid,
|
||
}
|
||
|
||
impl StyleSlot {
|
||
/// The slot's name in `style.apply {target}` / `style.detach {which}`.
|
||
fn key(self) -> &'static str {
|
||
match self {
|
||
StyleSlot::Fill => "fill",
|
||
StyleSlot::Stroke => "stroke",
|
||
StyleSlot::Effect => "effect",
|
||
StyleSlot::Grid => "grid",
|
||
}
|
||
}
|
||
fn menu_id(self) -> Id {
|
||
Id::new(("style_menu", self.key()))
|
||
}
|
||
fn linked(self, n: &Node) -> Option<String> {
|
||
match self {
|
||
StyleSlot::Fill => n.styles.fill.clone(),
|
||
StyleSlot::Stroke => n.styles.stroke.clone(),
|
||
StyleSlot::Effect => n.styles.effect.clone(),
|
||
StyleSlot::Grid => n.styles.grid.clone(),
|
||
}
|
||
}
|
||
fn paints(self) -> bool {
|
||
matches!(self, StyleSlot::Fill | StyleSlot::Stroke)
|
||
}
|
||
}
|
||
|
||
/// The applied style's name with a detach button.
|
||
fn style_chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Some(name) = common(doc, ids, |n| slot.linked(n)).flatten().and_then(|s| doc.style(&s).map(|st| st.name.clone())) else { return };
|
||
row(ui, 32.0, |ui| {
|
||
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
|
||
icons::paint(ui.painter(), r, Icon::Styles, t.icon_secondary);
|
||
widgets::label(ui, &name, t.text);
|
||
if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() {
|
||
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The style list under a section header: the document's styles for `slot` (click to apply) and
|
||
/// "Create style" from the first selected layer; paint slots also list colour variables.
|
||
fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
|
||
let key = slot.menu_id();
|
||
if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
|
||
return;
|
||
}
|
||
let t = Tokens::get(ui.ctx());
|
||
let styles: Vec<(String, String, Option<Paint>)> = doc
|
||
.styles
|
||
.iter()
|
||
.filter_map(|s| match (&s.kind, slot) {
|
||
(StyleKind::Paint { paints }, StyleSlot::Fill | StyleSlot::Stroke) => Some((s.id.clone(), s.name.clone(), paints.first().cloned())),
|
||
(StyleKind::Effect { .. }, StyleSlot::Effect) | (StyleKind::Grid { .. }, StyleSlot::Grid) => Some((s.id.clone(), s.name.clone(), None)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let (title, create_type, default_name) = match slot {
|
||
StyleSlot::Fill => ("Color styles", "paint", "Color"),
|
||
StyleSlot::Stroke => ("Color styles", "stroke", "Color"),
|
||
StyleSlot::Effect => ("Effect styles", "effect", "Effect"),
|
||
StyleSlot::Grid => ("Grid styles", "grid", "Grid"),
|
||
};
|
||
let mut close = false;
|
||
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(16, 8)).show(ui, |ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.label(egui::RichText::new(title).font(semibold(11.0)));
|
||
if ui.small_button("Create style").clicked() {
|
||
let n = styles.len() + 1;
|
||
app.run("style.create", json!({"type": create_type, "name": format!("{default_name} {n}"), "from": ids.first()}));
|
||
close = true;
|
||
}
|
||
if ui.small_button("Close").clicked() {
|
||
close = true;
|
||
}
|
||
});
|
||
if styles.is_empty() {
|
||
ui.label(egui::RichText::new("No styles yet").font(regular(11.0)).color(t.text_secondary));
|
||
}
|
||
for (id, name, paint) in &styles {
|
||
ui.horizontal(|ui| {
|
||
match paint {
|
||
Some(p) => {
|
||
widgets::swatch(ui, p, 14.0);
|
||
}
|
||
None => {
|
||
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||
icons::paint(ui.painter(), r, if slot == StyleSlot::Grid { Icon::Grid } else { Icon::Effects }, t.icon_secondary);
|
||
}
|
||
}
|
||
if ui.selectable_label(false, name).clicked() {
|
||
let mut q = json!({"ids": ids, "style": id});
|
||
if slot.paints() {
|
||
q["target"] = json!(slot.key());
|
||
}
|
||
app.run("style.apply", q);
|
||
close = true;
|
||
}
|
||
});
|
||
}
|
||
if !slot.paints() {
|
||
return;
|
||
}
|
||
// Colour variables bind the first paint's colour.
|
||
let modes = std::collections::BTreeMap::new();
|
||
let colors: Vec<(String, String, figcraft_color::Color)> = doc
|
||
.variables
|
||
.variables
|
||
.iter()
|
||
.filter(|v| v.kind == VariableType::Color)
|
||
.filter_map(|v| match doc.variables.resolve(&v.id, &modes) {
|
||
Some(VariableValue::Color(c)) => Some((v.id.clone(), v.name.clone(), c)),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
if !colors.is_empty() {
|
||
ui.add_space(4.0);
|
||
ui.label(egui::RichText::new("Color variables").font(semibold(11.0)));
|
||
for (vid, name, c) in &colors {
|
||
ui.horizontal(|ui| {
|
||
widgets::swatch(ui, &Paint::solid(*c), 14.0);
|
||
if ui.selectable_label(false, name).clicked() {
|
||
app.run("variable.bind", json!({"ids": ids, "field": slot.key(), "variable": vid}));
|
||
close = true;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
if close {
|
||
ui.data_mut(|d| d.remove::<bool>(key));
|
||
}
|
||
}
|
||
|
||
fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let ids = sel.to_vec();
|
||
let list = common(doc, sel, |n| n.effects.clone());
|
||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||
if let Some(i) = widgets::section_header(ui, "Effects", &[(Icon::Styles, "Styles"), (Icon::Plus, "Add effect")], empty) {
|
||
if i == 1 {
|
||
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
|
||
} else {
|
||
ui.data_mut(|d| d.insert_temp(StyleSlot::Effect.menu_id(), true));
|
||
}
|
||
}
|
||
style_picker(app, ui, doc, &ids, StyleSlot::Effect);
|
||
style_chip(app, ui, doc, &ids, StyleSlot::Effect);
|
||
let Some(list) = list else { return };
|
||
for (i, e) in list.iter().enumerate().rev() {
|
||
let mut open = ui.data(|d| d.get_temp::<bool>(Id::new(("fx_open", i)))).unwrap_or(false);
|
||
row(ui, 32.0, |ui| {
|
||
if icons::button(ui, Icon::Effects, 24.0, open, "Effect settings").clicked() {
|
||
open = !open;
|
||
ui.data_mut(|d| d.insert_temp(Id::new(("fx_open", i)), open));
|
||
}
|
||
let w = ui.available_width() - 56.0;
|
||
let resp = widgets::dropdown(ui, e.label(), w, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for (l, ty) in [
|
||
("Drop shadow", "dropShadow"),
|
||
("Inner shadow", "innerShadow"),
|
||
("Layer blur", "layerBlur"),
|
||
("Background blur", "backgroundBlur"),
|
||
("Noise", "noise"),
|
||
("Texture", "texture"),
|
||
] {
|
||
if menu_item(ui, l, "") {
|
||
let ne = match ty {
|
||
"dropShadow" => Effect::drop_shadow(),
|
||
"innerShadow" => Effect::inner_shadow(),
|
||
"layerBlur" => Effect::layer_blur(),
|
||
"backgroundBlur" => Effect::background_blur(),
|
||
"noise" => Effect {
|
||
kind: EffectKind::Noise { size: 0.5, density: 0.5, color: figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25) },
|
||
visible: true,
|
||
},
|
||
_ => Effect { kind: EffectKind::Texture { size: 0.5, radius: 4.0 }, visible: true },
|
||
};
|
||
app.run("effect.set", json!({"ids": ids, "index": i, "effect": ne}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
if icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() {
|
||
let mut e2 = e.clone();
|
||
e2.visible = !e2.visible;
|
||
app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2}));
|
||
}
|
||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||
app.run("effect.remove", json!({"ids": ids, "index": i}));
|
||
}
|
||
});
|
||
if open {
|
||
effect_settings(app, ui, &ids, i, e);
|
||
}
|
||
}
|
||
let _ = t;
|
||
}
|
||
|
||
fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) {
|
||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||
let set = |app: &mut FigApp, e2: Effect, scrub: Option<bool>| {
|
||
let p = json!({"ids": ids, "index": i, "effect": e2});
|
||
match scrub {
|
||
None => {
|
||
app.run("effect.set", p);
|
||
}
|
||
Some(done) => {
|
||
let _ = app.session.begin_interaction("Effect");
|
||
let _ = app.session.execute("effect.set", &p);
|
||
if done {
|
||
let _ = app.session.commit_interaction();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
let edit = |app: &mut FigApp, ed: Option<FieldEdit>, f: &dyn Fn(f64) -> Effect| match ed {
|
||
Some(FieldEdit::Set(v)) => set(app, f(v), None),
|
||
Some(FieldEdit::Scrub(v, d)) => set(app, f(v), Some(d)),
|
||
None => {}
|
||
};
|
||
match &e.kind {
|
||
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => {
|
||
let inner = matches!(e.kind, EffectKind::InnerShadow(_));
|
||
let mk = |s2: Shadow| Effect { kind: if inner { EffectKind::InnerShadow(s2) } else { EffectKind::DropShadow(s2) }, visible: e.visible };
|
||
row(ui, 32.0, |ui| {
|
||
let ed = widgets::number_field(ui, Id::new(("fx_x", i)), Lead::Text("X"), Some(s.offset.0), "", half, 1.0);
|
||
edit(app, ed, &|v| mk(Shadow { offset: (v, s.offset.1), ..s.clone() }));
|
||
let ed = widgets::number_field(ui, Id::new(("fx_y", i)), Lead::Text("Y"), Some(s.offset.1), "", half, 1.0);
|
||
edit(app, ed, &|v| mk(Shadow { offset: (s.offset.0, v), ..s.clone() }));
|
||
});
|
||
row(ui, 32.0, |ui| {
|
||
let ed = widgets::number_field(ui, Id::new(("fx_b", i)), Lead::Text("Blur"), Some(s.radius), "", half, 1.0);
|
||
edit(app, ed, &|v| mk(Shadow { radius: v.max(0.0), ..s.clone() }));
|
||
let ed = widgets::number_field(ui, Id::new(("fx_s", i)), Lead::Text("Spread"), Some(s.spread), "", half, 1.0);
|
||
edit(app, ed, &|v| mk(Shadow { spread: v, ..s.clone() }));
|
||
});
|
||
row(ui, 32.0, |ui| {
|
||
let p = Paint::solid(s.color);
|
||
if widgets::swatch(ui, &p, 16.0).clicked() {
|
||
app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i });
|
||
}
|
||
if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i)), &s.color.to_hex(), 80.0, "")
|
||
&& let Some(c) = figcraft_color::Color::from_hex(&h)
|
||
{
|
||
set(app, mk(Shadow { color: c.with_alpha(s.color.a), ..s.clone() }), None);
|
||
}
|
||
let ed = widgets::number_field(ui, Id::new(("fx_a", i)), Lead::None, Some((f64::from(s.color.a) * 100.0).round()), "%", 56.0, 1.0);
|
||
edit(app, ed, &|v| mk(Shadow { color: s.color.with_alpha((v / 100.0) as f32), ..s.clone() }));
|
||
});
|
||
if !inner {
|
||
row(ui, 32.0, |ui| {
|
||
if widgets::checkbox(ui, s.show_behind, "Show behind transparent areas") {
|
||
set(app, mk(Shadow { show_behind: !s.show_behind, ..s.clone() }), None);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius } => {
|
||
let bg = matches!(e.kind, EffectKind::BackgroundBlur { .. });
|
||
row(ui, 32.0, |ui| {
|
||
let ed = widgets::number_field(ui, Id::new(("fx_r", i)), Lead::Text("Blur"), Some(*radius), "", half * 2.0 + 8.0, 1.0);
|
||
edit(app, ed, &|v| Effect {
|
||
kind: if bg { EffectKind::BackgroundBlur { radius: v.max(0.0) } } else { EffectKind::LayerBlur { radius: v.max(0.0) } },
|
||
visible: e.visible,
|
||
});
|
||
});
|
||
}
|
||
EffectKind::Noise { size, density, color } => {
|
||
row(ui, 32.0, |ui| {
|
||
let ed = widgets::number_field(ui, Id::new(("fx_ns", i)), Lead::Text("Size"), Some(*size), "", half, 0.1);
|
||
edit(app, ed, &|v| Effect { kind: EffectKind::Noise { size: v.max(0.1), density: *density, color: *color }, visible: e.visible });
|
||
let ed = widgets::number_field(ui, Id::new(("fx_nd", i)), Lead::Text("Density"), Some((density * 100.0).round()), "%", half, 1.0);
|
||
edit(app, ed, &|v| Effect {
|
||
kind: EffectKind::Noise { size: *size, density: (v / 100.0).clamp(0.0, 1.0), color: *color },
|
||
visible: e.visible,
|
||
});
|
||
});
|
||
}
|
||
EffectKind::Texture { size, radius } => {
|
||
row(ui, 32.0, |ui| {
|
||
let ed = widgets::number_field(ui, Id::new(("fx_ts", i)), Lead::Text("Size"), Some(*size), "", half, 0.1);
|
||
edit(app, ed, &|v| Effect { kind: EffectKind::Texture { size: v.max(0.1), radius: *radius }, visible: e.visible });
|
||
let ed = widgets::number_field(ui, Id::new(("fx_tr", i)), Lead::Text("Radius"), Some(*radius), "", half, 1.0);
|
||
edit(app, ed, &|v| Effect { kind: EffectKind::Texture { size: *size, radius: v.max(0.0) }, visible: e.visible });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let ids = sel.to_vec();
|
||
let list = common(doc, sel, |n| n.grids.clone());
|
||
let empty = list.as_ref().is_none_or(|l| l.is_empty());
|
||
if let Some(i) = widgets::section_header(ui, "Layout grid", &[(Icon::Styles, "Styles"), (Icon::Plus, "Add layout grid")], empty) {
|
||
if i == 1 {
|
||
app.run("grid.add", json!({"ids": ids}));
|
||
} else {
|
||
ui.data_mut(|d| d.insert_temp(StyleSlot::Grid.menu_id(), true));
|
||
}
|
||
}
|
||
style_picker(app, ui, doc, &ids, StyleSlot::Grid);
|
||
style_chip(app, ui, doc, &ids, StyleSlot::Grid);
|
||
let Some(list) = list else { return };
|
||
for (i, g) in list.iter().enumerate() {
|
||
row(ui, 32.0, |ui| {
|
||
let label = match g.kind {
|
||
GridKind::Grid => format!("Grid ({})", g.size),
|
||
GridKind::Columns => format!("{} columns", g.count),
|
||
GridKind::Rows => format!("{} rows", g.count),
|
||
};
|
||
let w = ui.available_width() - 56.0;
|
||
let resp = widgets::dropdown(ui, &label, w, Some(Icon::Grid));
|
||
egui::Popup::menu(&resp).width(220.0).show(|ui| {
|
||
for (l, kind) in [("Grid", GridKind::Grid), ("Columns", GridKind::Columns), ("Rows", GridKind::Rows)] {
|
||
if menu_item(ui, l, "") {
|
||
let mut gs = list.clone();
|
||
if let Some(slot) = gs.get_mut(i) {
|
||
*slot = match kind {
|
||
GridKind::Grid => LayoutGrid::default(),
|
||
GridKind::Columns => LayoutGrid::columns(),
|
||
GridKind::Rows => LayoutGrid::rows(),
|
||
};
|
||
}
|
||
app.set_props(&ids, json!({"grids": gs}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() {
|
||
let mut gs = list.clone();
|
||
if let Some(slot) = gs.get_mut(i) {
|
||
slot.visible = !slot.visible;
|
||
}
|
||
app.set_props(&ids, json!({"grids": gs}));
|
||
}
|
||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||
app.run("grid.remove", json!({"ids": ids, "index": i}));
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
fn export_now(app: &mut FigApp, ids: &[NodeId], format: &str, scale: f64) {
|
||
let name = ids.first().and_then(|i| app.session.doc().ok()?.node(*i).map(|n| n.name.clone())).unwrap_or_else(|| "Export".into());
|
||
let suffix = if (scale - 1.0).abs() > 1e-9 && format != "svg" && format != "pdf" { format!("@{scale}x") } else { String::new() };
|
||
if let Some(p) = app.services.pick_save("Export", &format!("{name}{suffix}.{format}"), &[format]) {
|
||
let ps = p.to_string_lossy().to_string();
|
||
if app.run("file.export", json!({"ids": ids, "format": format, "scale": scale, "path": ps})).is_some() {
|
||
app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn export(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let ids = sel.to_vec();
|
||
let list = common(doc, sel, |n| n.exports.clone()).unwrap_or_default();
|
||
if widgets::section_header(ui, "Export", &[(Icon::Plus, "Add export setting")], list.is_empty()).is_some() {
|
||
let next = match list.last().map(|e| e.size) {
|
||
Some(ExportSize::Scale { value }) => {
|
||
ExportSetting { size: ExportSize::Scale { value: value + 1.0 }, suffix: format!("@{}x", value + 1.0), format: ExportFormat::Png }
|
||
}
|
||
_ => ExportSetting::default(),
|
||
};
|
||
app.run("export.add", json!({"ids": ids, "setting": next}));
|
||
}
|
||
for (i, e) in list.iter().enumerate() {
|
||
row(ui, 32.0, |ui| {
|
||
let scale = match e.size {
|
||
ExportSize::Scale { value } => format!("{value}x"),
|
||
ExportSize::Width { value } => format!("{value}w"),
|
||
ExportSize::Height { value } => format!("{value}h"),
|
||
};
|
||
let resp = widgets::dropdown(ui, &scale, 64.0, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for s in [0.5, 0.75, 1.0, 1.5, 2.0, 3.0, 4.0] {
|
||
if menu_item(ui, &format!("{s}x"), "") {
|
||
let mut l = list.clone();
|
||
if let Some(slot) = l.get_mut(i) {
|
||
slot.size = ExportSize::Scale { value: s };
|
||
}
|
||
app.set_props(&ids, json!({"exports": l}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
if let Some(sfx) = widgets::text_field(ui, Id::new(("exp_sfx", i)), &e.suffix, 64.0, "Suffix") {
|
||
let mut l = list.clone();
|
||
if let Some(slot) = l.get_mut(i) {
|
||
slot.suffix = sfx;
|
||
}
|
||
app.set_props(&ids, json!({"exports": l}));
|
||
}
|
||
let resp = widgets::dropdown(ui, &e.format.ext().to_uppercase(), 56.0, None);
|
||
egui::Popup::menu(&resp).show(|ui| {
|
||
for f in [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Webp] {
|
||
if menu_item(ui, &f.ext().to_uppercase(), "") {
|
||
let mut l = list.clone();
|
||
if let Some(slot) = l.get_mut(i) {
|
||
slot.format = f;
|
||
}
|
||
app.set_props(&ids, json!({"exports": l}));
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
|
||
app.run("export.remove", json!({"ids": ids, "index": i}));
|
||
}
|
||
});
|
||
}
|
||
let name = if sel.len() == 1 {
|
||
sel.first().and_then(|i| doc.node(*i)).map(|n| n.name.clone()).unwrap_or_default()
|
||
} else {
|
||
format!("{} layers", sel.len())
|
||
};
|
||
row(ui, 32.0, |ui| {
|
||
let w = ui.available_width();
|
||
let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click());
|
||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||
if resp.hovered() {
|
||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||
}
|
||
let galley = ui.painter().layout(format!("Export {name}"), regular(11.0), t.text, w - 16.0);
|
||
ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||
if resp.clicked() {
|
||
let e = list.first().cloned().unwrap_or_default();
|
||
let scale = match e.size {
|
||
ExportSize::Scale { value } => value,
|
||
_ => 1.0,
|
||
};
|
||
export_now(app, &ids, e.format.ext(), scale);
|
||
}
|
||
});
|
||
}
|
||
|
||
/// CSS for a node (Inspect / Copy as CSS).
|
||
pub fn css_for(doc: &Document, id: NodeId) -> String {
|
||
let Some(n) = doc.node(id) else { return String::new() };
|
||
let mut out = format!("/* {} */\n", n.name);
|
||
out.push_str(&format!("width: {}px;\nheight: {}px;\n", crate::canvas::fmt(n.width), crate::canvas::fmt(n.height)));
|
||
if let Some(l) = &n.layout {
|
||
out.push_str("display: flex;\n");
|
||
out.push_str(&format!("flex-direction: {};\n", if l.mode == LayoutMode::Vertical { "column" } else { "row" }));
|
||
if l.wrap {
|
||
out.push_str("flex-wrap: wrap;\n");
|
||
}
|
||
out.push_str(&format!("gap: {}px;\n", crate::canvas::fmt(l.gap)));
|
||
let [pt, pr, pb, pl] = l.padding;
|
||
out.push_str(&format!(
|
||
"padding: {}px {}px {}px {}px;\n",
|
||
crate::canvas::fmt(pt),
|
||
crate::canvas::fmt(pr),
|
||
crate::canvas::fmt(pb),
|
||
crate::canvas::fmt(pl)
|
||
));
|
||
out.push_str(&format!(
|
||
"justify-content: {};\nalign-items: {};\n",
|
||
match l.main_align {
|
||
MainAlign::Min => "flex-start",
|
||
MainAlign::Center => "center",
|
||
MainAlign::Max => "flex-end",
|
||
MainAlign::SpaceBetween => "space-between",
|
||
},
|
||
match l.cross_align {
|
||
CrossAlign::Min => "flex-start",
|
||
CrossAlign::Center => "center",
|
||
CrossAlign::Max => "flex-end",
|
||
CrossAlign::Baseline => "baseline",
|
||
}
|
||
));
|
||
}
|
||
if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) {
|
||
out.push_str(&format!("border-radius: {}px;\n", crate::canvas::fmt(r)));
|
||
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||
let [a, b, c, d] = n.corners.radii;
|
||
out.push_str(&format!("border-radius: {}px {}px {}px {}px;\n", a, b, c, d));
|
||
}
|
||
if n.opacity < 1.0 {
|
||
out.push_str(&format!("opacity: {};\n", n.opacity));
|
||
}
|
||
for p in n.fills.iter().filter(|p| p.visible) {
|
||
match &p.kind {
|
||
PaintKind::Solid { color } => {
|
||
let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" };
|
||
out.push_str(&format!("{key}: {};\n", color.with_alpha(color.a * p.opacity).to_css()));
|
||
}
|
||
PaintKind::Gradient(g) => {
|
||
let stops: Vec<String> = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect();
|
||
out.push_str(&format!("background: linear-gradient({});\n", stops.join(", ")));
|
||
}
|
||
PaintKind::Image(_) => out.push_str("background: url(image.png) center / cover;\n"),
|
||
}
|
||
}
|
||
if n.has_visible_stroke()
|
||
&& let Some(c) = n.strokes.first().and_then(|p| p.solid_color())
|
||
{
|
||
out.push_str(&format!("border: {}px solid {};\n", crate::canvas::fmt(n.stroke.weight), c.to_css()));
|
||
}
|
||
let shadows: Vec<String> = n
|
||
.effects
|
||
.iter()
|
||
.filter(|e| e.visible)
|
||
.filter_map(|e| match &e.kind {
|
||
EffectKind::DropShadow(s) => Some(format!("{}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())),
|
||
EffectKind::InnerShadow(s) => Some(format!("inset {}px {}px {}px {}px {}", s.offset.0, s.offset.1, s.radius, s.spread, s.color.to_css())),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
if !shadows.is_empty() {
|
||
out.push_str(&format!("box-shadow: {};\n", shadows.join(", ")));
|
||
}
|
||
for e in n.effects.iter().filter(|e| e.visible) {
|
||
match e.kind {
|
||
EffectKind::LayerBlur { radius } => out.push_str(&format!("filter: blur({}px);\n", radius / 2.0)),
|
||
EffectKind::BackgroundBlur { radius } => out.push_str(&format!("backdrop-filter: blur({}px);\n", radius / 2.0)),
|
||
_ => {}
|
||
}
|
||
}
|
||
if let NodeKind::Text(t) = &n.kind {
|
||
out.push_str(&format!(
|
||
"font-family: \"{}\";\nfont-size: {}px;\nfont-weight: {};\n",
|
||
t.style.font_family,
|
||
t.style.font_size,
|
||
t.style.weight()
|
||
));
|
||
if t.style.italic() {
|
||
out.push_str("font-style: italic;\n");
|
||
}
|
||
match t.style.line_height {
|
||
LineHeight::Pixels(v) => out.push_str(&format!("line-height: {v}px;\n")),
|
||
LineHeight::Percent(p) => out.push_str(&format!("line-height: {p}%;\n")),
|
||
LineHeight::Auto => {}
|
||
}
|
||
if let LetterSpacing::Pixels(v) = t.style.letter_spacing
|
||
&& v != 0.0
|
||
{
|
||
out.push_str(&format!("letter-spacing: {v}px;\n"));
|
||
}
|
||
if let LetterSpacing::Percent(p) = t.style.letter_spacing
|
||
&& p != 0.0
|
||
{
|
||
out.push_str(&format!("letter-spacing: {}em;\n", p / 100.0));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
||
let t = Tokens::get(ui.ctx());
|
||
let Ok(ds) = app.session.ds() else { return };
|
||
let doc = ds.doc.clone();
|
||
let sel = ds.selection.clone();
|
||
widgets::section_header(ui, "Inspect", &[], false);
|
||
let Some(first) = sel.first().copied() else {
|
||
row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its code and measurements", t.text_secondary));
|
||
return;
|
||
};
|
||
if let Some(b) = doc.world_bounds(first) {
|
||
row(ui, 32.0, |ui| {
|
||
widgets::label(
|
||
ui,
|
||
&format!(
|
||
"{} × {} at {}, {}",
|
||
crate::canvas::fmt(b.width()),
|
||
crate::canvas::fmt(b.height()),
|
||
crate::canvas::fmt(b.x0),
|
||
crate::canvas::fmt(b.y0)
|
||
),
|
||
t.text,
|
||
);
|
||
});
|
||
}
|
||
widgets::separator(ui);
|
||
widgets::section_header(ui, "CSS", &[(Icon::Export, "Copy")], false);
|
||
let css = css_for(&doc, first);
|
||
egui::Frame::NONE
|
||
.fill(t.field)
|
||
.corner_radius(CornerRadius::same(6))
|
||
.inner_margin(egui::Margin::same(10))
|
||
.outer_margin(egui::Margin::symmetric(16, 0))
|
||
.show(ui, |ui| {
|
||
ui.label(egui::RichText::new(&css).monospace().color(t.text));
|
||
});
|
||
ui.add_space(8.0);
|
||
row(ui, 32.0, |ui| {
|
||
if ui.button("Copy CSS").clicked() {
|
||
app.services.clipboard_set_text(&css);
|
||
app.toast("Copied CSS");
|
||
}
|
||
});
|
||
}
|