Files
figcraft/crates/ui-egui/src/right.rs
T
BFlatandClaude Opus 5.5 4565c4ab93 M3: reorder paints, effects and layout grids in the Design panel; grid and instance property variable bindings (#81)
Fill, stroke, effect and layout grid rows drag to reorder by their grip (a handle shows over the
row, an insertion line where it lands, the row's label follows the pointer like a dragged layer;
Escape cancels; lists that differ across the selection have nothing to drag). New commands
`fill.move`, `stroke.move` and `grid.move {ids?, from, to}` join `effect.move` (lists bottom first,
one undo step; items keep everything, linked list styles are unlinked).

Variable bindings on list items follow their items when a list is reordered or shrinks:
paint colours bind per paint (`fills/<i>/color`, `strokes/<i>/color`; `fill`/`stroke` stay the
first paint's), so a moved bound paint keeps its variable and its neighbours are never recoloured.
`fill.remove`, `stroke.remove` and `grid.remove` refuse negative indices.

Layout grids get settings in the Design panel (size; count, type, width or height, margin or
offset, gutter; colour) whose numbers bind to number variables (`layoutGrids/<i>/<field>`),
re-applied on variable and mode changes. Instance boolean and text properties bind to boolean and
string variables (`componentProperties/<name>`, from the instance's properties panel), kept across
instance rebuilds, following property renames and dropped on delete, reset and detach.
Inspect names the variable of each paint.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:21:05 +07:00

1823 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The right panel: Design, Prototype and Inspect tabs.
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::*;
use figcraft_engine::cmd::lists::moved_index;
use serde_json::{Value, json};
use crate::icons::{self, Icon};
use crate::list_drag::{ListDrag, move_flags};
use crate::style_picker::{self, StyleSlot};
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, &params(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;
}
// Play presents; its chevron opens the play menu (present, or preview in a pane).
let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0));
let (tri_r, chev_r) =
(Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max));
let present_keys = crate::toolbar::pretty_shortcut(crate::present::PRESENT_SHORTCUT);
let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).on_hover_text(format!("Present {present_keys}"));
let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).on_hover_text("Present or preview");
if presp.hovered() || mresp.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!({}));
}
egui::Popup::menu(&mresp).width(200.0).show(|ui| {
if menu_item(ui, "Present", &present_keys) {
app.run("proto.present", json!({}));
ui.close();
}
let (label, cmd) = if app.ui.preview.is_some() { ("Close preview", "proto.closePreview") } else { ("Preview", "proto.preview") };
if menu_item(ui, label, "") {
app.run(cmd, json!({}));
ui.close();
}
});
// 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();
let Ok(v) = app.session.ds().map(|d| d.view.clone()) else { return };
for (label, scale) in [("Pixel preview 1x", 1u8), ("Pixel preview 2x", 2)] {
let on = v.pixel_preview && v.pixel_scale == scale;
if menu_check(ui, on, label, "") {
app.run("view.pixelPreview", json!({"on": !on, "scale": scale}));
ui.close();
}
}
let toggles: [(&str, &str, bool); 5] = [
("Pixel grid", "view.pixelGrid", v.pixel_grid),
("Snap to pixel grid", "view.snapPixel", v.snap_pixel),
("Layout grids", "view.layoutGrids", v.layout_grids),
("Rulers", "view.rulers", v.rulers),
("Outlines", "view.outlines", v.outlines),
];
for (label, cmd, on) in toggles {
if menu_check(ui, on, 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 {
menu_row(ui, label, shortcut, |_, _, _| {}).clicked()
}
/// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`).
pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool {
menu_row(ui, label, shortcut, art).clicked()
}
/// A menu row (see [`menu_item_with`]); its response tells hovering too.
pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response {
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 },
);
}
art(ui.painter(), r, col);
resp
}
/// A menu item with a check mark when `on` (the unchecked ones line up with it).
pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool {
menu_item(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut)
}
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
row_rect(ui, h, add);
}
/// [`row`], returning the area the row takes.
pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect {
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); returns the area the row takes.
pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {
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);
r
}
fn design(app: &mut FigApp, ui: &mut Ui) {
let Ok(ds) = app.session.ds() else { return };
// The panel shows the document itself, not a live text preview over it.
let doc = ds.text_preview.clone().unwrap_or_else(|| 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);
// Editing a vector with points selected, the points' fields stand in for the layer's position
// and dimensions.
let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty());
match vector_edit {
Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve),
None => {
crate::section_panel::section(app, ui, &doc, &sel);
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
crate::scale_panel::section(app, ui, &doc, &sel);
widgets::separator(ui);
}
position(app, ui, &doc, &sel);
}
}
widgets::separator(ui);
if !points_selected {
crate::layout_panel::layout_section(app, ui, &doc, &sel);
widgets::separator(ui);
}
appearance(app, ui, &doc, &sel);
widgets::separator(ui);
if variable_modes(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);
}
crate::export_panel::section(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);
widgets::section_header(ui, "Color profile", &[], false);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None);
egui::Popup::menu(&resp).show(|ui| {
for p in figcraft_color::ColorProfile::ALL {
if menu_item(ui, p.label(), "") {
if p != doc.color_profile {
app.ui.color_profile = Some(p);
}
ui.close();
}
}
});
});
widgets::separator(ui);
// Local variables & styles.
let vars = doc.variables.variables.iter().filter(|v| !figcraft_engine::library::is_remote_variable(doc, &v.id)).count();
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;
app.ui.variables.get_or_insert_with(Default::default);
}
});
widgets::separator(ui);
let styles: Vec<(String, String, String)> =
figcraft_engine::library::local_styles(doc).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);
crate::export_panel::page_section(app, ui, doc, page);
}
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::Variable, "Apply variable to visibility"),
(Icon::Blend, "Blend mode"),
],
false,
);
if hdr == Some(0) {
app.run("object.toggleVisible", json!({"ids": ids}));
}
if hdr == Some(1) {
crate::variable_ui::open_at_pointer(ui, &ids, "visible", VariableType::Boolean);
}
if hdr == Some(2) {
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());
crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye), col * 2.0 + 8.0);
// Masks: how they mask.
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) {
let mt = common(doc, sel, |n| n.mask_type);
widgets::sublabels(ui, "Mask", None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, mt.map(MaskType::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Mask));
egui::Popup::menu(&resp).show(|ui| {
for m in MaskType::ALL {
if menu_item(ui, m.label(), "") {
app.set_props(&ids, json!({"maskType": m.id()}));
ui.close();
}
}
});
});
}
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 = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity), op, "%", col);
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 = crate::variable_ui::number_field(
app,
ui,
&ids,
key,
Id::new(("corner", k)),
Lead::Icon(Icon::CornerRadius),
radii.map(|r| r[k]),
"",
col,
);
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 = crate::variable_ui::number_field(
app,
ui,
&ids,
key,
Id::new(("corner", k)),
Lead::Icon(Icon::CornerRadius),
radii.map(|r| r[k]),
"",
col,
);
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);
}
/// The variable modes of the selected frames (sections, components, instances): a row per
/// collection with more than one mode, Auto (the mode the layer inherits from its parent) or a mode
/// of its own (`variable.setMode`). Collections from enabled libraries follow under their
/// library's name (`library.setVariableMode` copies the collection in). Returns whether it showed.
fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
if !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
return false;
}
let libs = crate::variable_ui::libraries(app, doc);
let rows = crate::variable_ui::mode_rows(doc, &libs);
if rows.is_empty() {
return false;
}
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, "Variable modes", &[], false);
// The collection's name, then a wider mode picker ("Auto (Light)") across both field columns.
let col = widgets::col_width(ui.available_width());
let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round());
// Auto resolves from the parent of the first selected layer.
let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default();
let mut group = None;
for r in &rows {
let c = r.coll;
// Library collections under their library's name.
if r.library != group {
group = r.library;
if let Some((_, lib)) = r.library {
widgets::sublabels(ui, lib, None);
}
}
let name_of = |m: &str| c.modes.iter().find(|x| x.id == m).map_or("", |x| x.name.as_str());
// Each layer's own mode (None: Auto); None overall when they differ.
let own = match r.local {
Some(l) => common(doc, sel, |n| n.var_modes.get(l).cloned()),
None => Some(None),
};
let inherited = r.local.and_then(|l| parent_modes.get(l)).or(c.default_mode()).map_or("", |m| name_of(m));
let auto = format!("Auto ({inherited})");
let label = match &own {
Some(Some(m)) => name_of(m).to_string(),
Some(None) => auto.clone(),
None => "Mixed".into(),
};
let hover = match r.library {
Some((_, lib)) => format!("{} (from {lib})", c.name),
None => c.name.clone(),
};
row(ui, 32.0, |ui| {
let (rect, _) = ui.allocate_exact_size(vec2(name_w, FIELD_H), Sense::hover());
let name = widgets::elide(ui, &c.name, name_w);
ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text);
if name != c.name || r.library.is_some() {
ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover);
}
let resp = widgets::elided_dropdown(ui, &label, pick_w);
egui::Popup::menu(&resp).width(180.0).show(|ui| {
let mut pick = None;
if menu_check(ui, own == Some(None), &auto, "") {
pick = Some(Value::Null);
}
ui.separator();
for m in &c.modes {
if menu_check(ui, own.as_ref().is_some_and(|o| o.as_ref() == Some(&m.id)), &m.name, "") {
pick = Some(json!(m.id));
}
}
if let Some(mode) = pick {
set_variable_mode(app, sel, r, mode);
ui.close();
}
});
});
}
ui.add_space(8.0);
true
}
/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto). A library collection goes
/// through `library.setVariableMode` (copying it in); Auto on one never used here is already so.
fn set_variable_mode(app: &mut FigApp, sel: &[NodeId], row: &crate::variable_ui::ModeRow, mode: Value) {
match (row.library, row.key, row.local) {
(Some((lib, _)), Some(key), _) if !mode.is_null() => {
app.run("library.setVariableMode", json!({"library": lib, "collection": key, "ids": sel, "mode": mode}));
}
(_, _, Some(local)) => {
app.run("variable.setMode", json!({"ids": sel, "collection": local, "mode": mode}));
}
_ => {}
}
}
/// While a range of text is selected, the Fill section edits that range's colour.
fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: usize, end: usize) {
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, "Fill", &[], false);
// The fills over the range, if every character shares them.
let fills: Option<Vec<Paint>> = doc.node(id).and_then(|n| n.kind.text().map(|tx| (n, tx))).and_then(|(n, tx)| {
let mut it =
tx.spans().into_iter().filter(|(s, e, _)| *s < end && *e > start).map(|(_, _, st)| st.fills.clone().unwrap_or_else(|| n.fills.clone()));
let first = it.next()?;
it.all(|f| f == first).then_some(first)
});
row(ui, 32.0, |ui| {
let w = ui.available_width() - 28.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));
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 4.0, r.max.y));
match fills.as_ref().and_then(|f| f.first()) {
Some(p) => {
widgets::paint_swatch(ui.painter(), sw, p);
if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
}
if let Some(c) = p.solid_color() {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
if let Some(h) =
widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(nc) = figcraft_color::Color::from_hex(&h)
{
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]}));
}
} else {
ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Gradient", regular(11.0), t.text);
}
}
None => {
ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Mixed", regular(11.0), t.text_secondary);
if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });
}
}
}
// Back to the layer's fill.
if icons::button(ui, Icon::Detach, 24.0, false, "Use the layer's fill").clicked() {
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": null}));
}
});
}
fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) {
if fills
&& let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session)
&& a < b
&& sel.contains(&id)
{
range_fill(app, ui, doc, id, a, b);
return;
}
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 {
style_picker::open(app, ui, &ids, slot);
}
}
style_picker::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) => {
// The last paint is on top: shown first. Rows drag to reorder.
let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" });
for (i, p) in l.iter().enumerate().rev() {
let r = paint_row(app, ui, &ids, i, p, fills);
drag.row(ui, i, r, r, || paint_label(p));
}
if let Some((from, to)) = drag.finish(ui) {
app.run(if fills { "fill.move" } else { "stroke.move" }, json!({"ids": ids, "from": from, "to": to}));
// An open colour picker keeps editing the paint it was on.
if let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
{
*index = moved_index(*index, from, to);
}
}
}
}
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);
}
}
}
/// A width profile drawn as a filled band across `rect` (thickest point = full height); `None`
/// draws a uniform stroke.
pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<&WidthProfile>, col: Color32) {
const STEPS: u32 = 40;
let max = profile.map_or(1.0, WidthProfile::max_scale).max(1e-6);
let (cy, half) = (rect.center().y, rect.height() / 2.0);
let mut mesh = egui::Mesh::default();
let (mut top, mut bottom) = (Vec::new(), Vec::new());
for i in 0..=STEPS {
let t = f64::from(i) / f64::from(STEPS);
let s = profile.map_or(0.5, |p| p.scale_at(t) / max) as f32;
let x = rect.min.x + rect.width() * t as f32;
let h = (s * half).max(0.5);
top.push(pos2(x, cy - h));
bottom.push(pos2(x, cy + h));
mesh.colored_vertex(pos2(x, cy - h), col);
mesh.colored_vertex(pos2(x, cy + h), col);
}
for i in 0..STEPS {
let k = 2 * i;
mesh.add_triangle(k, k + 1, k + 2);
mesh.add_triangle(k + 1, k + 3, k + 2);
}
painter.add(egui::Shape::mesh(mesh));
// The mesh isn't anti-aliased: smooth its edges with hairlines.
for edge in [top, bottom] {
painter.add(egui::Shape::line(edge, Stroke::new(0.75, col)));
}
}
/// Stroke settings ▸ Width profile: the presets (each with its shape) and a flip.
fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Option<Option<WidthProfile>>, half: f32) {
let t = Tokens::get(ui.ctx());
widgets::sublabels(ui, "Width profile", None);
row(ui, 32.0, |ui| {
let label = match &profile {
None => "Mixed",
Some(None) => WidthPreset::Uniform.label(),
Some(Some(p)) => p.preset().map_or("Custom", WidthPreset::label),
};
let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0 - 32.0, None);
if let Some(p) = &profile {
let r = resp.rect;
let art = Rect::from_min_max(pos2(r.max.x - 22.0 - 56.0, r.center().y - 5.0), pos2(r.max.x - 22.0, r.center().y + 5.0));
profile_art(ui.painter(), art, p.as_ref(), t.icon_secondary);
}
egui::Popup::menu(&resp).show(|ui| {
for preset in WidthPreset::ALL {
let prof = preset.profile();
let art = |pt: &egui::Painter, r: Rect, col: Color32| {
let a = Rect::from_min_max(pos2(r.max.x - 8.0 - 64.0, r.center().y - 5.0), pos2(r.max.x - 8.0, r.center().y + 5.0));
profile_art(pt, a, prof.as_ref(), col);
};
if menu_item_with(ui, preset.label(), "", art) {
app.run("stroke.widthProfile", json!({"ids": ids, "preset": preset.id()}));
ui.close();
}
}
});
// Flip: the profile runs from the other end of the path.
let custom = profile.clone().flatten();
let flip = icons::button(ui, Icon::FlipH, 24.0, false, "Flip width profile");
if flip.clicked()
&& let Some(p) = custom
{
let pts: Vec<[f64; 2]> = p.points.iter().rev().map(|q| [1.0 - q.t, q.scale]).collect();
app.run("stroke.widthProfile", json!({"ids": ids, "points": pts}));
}
});
}
/// Advanced stroke settings: dash pattern, width profile, 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)]));
});
}
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()), half);
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_with(ui, c.label(), "", |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(&ids, json!({key: format!("{c:?}")}));
ui.close();
}
}
});
}
});
}
ui.add_space(4.0);
}
/// A short stroke ending in `cap` (on its left end for a start point), drawn at the right of a menu
/// row.
fn cap_art(p: &egui::Painter, r: Rect, col: Color32, cap: StrokeCap, start: bool) {
let y = r.center().y;
let (left, right) = (r.max.x - 58.0, r.max.x - 14.0);
// The capped end, the plain end, and the direction from the stroke into its cap.
let (tip, tail, d) = if start { (left, right, -1.0) } else { (right, left, 1.0) };
let at = |back: f32, dy: f32| pos2(tip - d * back, y + dy);
let line = |to: Pos2, w: f32| {
p.line_segment([pos2(tail, y), to], Stroke::new(w, col));
};
let fill = |pts: Vec<Pos2>| {
p.add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
};
match cap {
StrokeCap::None => line(at(0.0, 0.0), 4.0),
StrokeCap::Round => {
line(at(2.0, 0.0), 4.0);
p.circle_filled(at(2.0, 0.0), 2.0, col);
}
StrokeCap::Square => line(at(-2.0, 0.0), 4.0),
StrokeCap::LineArrow => {
line(at(0.0, 0.0), 1.5);
p.add(egui::Shape::line(vec![at(5.5, -5.5), at(0.0, 0.0), at(5.5, 5.5)], Stroke::new(1.5, col)));
}
StrokeCap::TriangleArrow => {
line(at(7.0, 0.0), 1.5);
fill(vec![at(0.0, 0.0), at(8.0, -4.5), at(8.0, 4.5)]);
}
StrokeCap::ReversedTriangle => {
line(at(8.0, 0.0), 1.5);
fill(vec![at(8.0, 0.0), at(0.0, -4.5), at(0.0, 4.5)]);
}
StrokeCap::CircleFilled => {
line(at(4.0, 0.0), 1.5);
p.circle_filled(at(4.0, 0.0), 4.0, col);
}
StrokeCap::DiamondFilled => {
line(at(5.0, 0.0), 1.5);
fill(vec![at(0.0, 0.0), at(5.0, -5.0), at(10.0, 0.0), at(5.0, 5.0)]);
}
}
}
/// Paint `i`'s row (swatch, colour or kind, opacity, visibility, remove); returns its area.
fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, fills: bool) -> Rect {
let t = Tokens::get(ui.ctx());
let ids = ids.to_vec();
let cmd = if fills { "fill.set" } else { "stroke.set" };
row_rect(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));
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
let bound = app.session.doc().ok().and_then(|d| {
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
});
match &p.kind {
PaintKind::Solid { .. } if bound.is_some() => {
let (key, 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, i)), 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, i)), 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": key, "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()}));
}
}
_ => {
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), 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 paint row's label (also what a drag of it carries): its colour, gradient kind or "Image".
fn paint_label(p: &Paint) -> String {
match &p.kind {
PaintKind::Solid { color } => color.to_hex(),
PaintKind::Gradient(g) => format!("{:?}", g.kind),
PaintKind::Image(_) => "Image".into(),
}
}
fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
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 {
style_picker::open(app, ui, &ids, StyleSlot::Effect);
}
}
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
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);
let header = row_rect(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 Effect::TYPES {
if menu_item(ui, l, "")
&& let Some(ne) = Effect::of_type(ty)
{
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);
}
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || e.label().to_string());
}
if let Some((from, to)) = drag.finish(ui) {
app.run("effect.move", json!({"ids": ids, "from": from, "to": to}));
move_flags(ui, "fx_open", list.len(), from, to);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
*index = moved_index(*index, from, to);
}
}
}
/// A number field of effect `i` that can be bound to a variable (`effects/<i>/<prop>`).
fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, lead: Lead, value: f64, width: f32) -> Option<FieldEdit> {
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::new(("fx", prop, i)), lead, Some(value), "", width)
}
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 => {}
};
// A new colour for the effect (`secondary`: a duo noise's second colour).
let edit_color = |app: &mut FigApp, secondary: bool, c: figcraft_color::Color, scrub: Option<bool>| {
let mut e2 = e.clone();
if let Some(slot) = e2.color_mut(secondary) {
*slot = c;
}
set(app, e2, scrub);
};
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 = fx_field(app, ui, ids, i, "offsetX", Lead::Text("X"), s.offset.0, half);
edit(app, ed, &|v| mk(Shadow { offset: (v, s.offset.1), ..s.clone() }));
let ed = fx_field(app, ui, ids, i, "offsetY", Lead::Text("Y"), s.offset.1, half);
edit(app, ed, &|v| mk(Shadow { offset: (s.offset.0, v), ..s.clone() }));
});
row(ui, 32.0, |ui| {
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), s.radius, half);
edit(app, ed, &|v| mk(Shadow { radius: v.max(0.0), ..s.clone() }));
let ed = fx_field(app, ui, ids, i, "spread", Lead::Text("Spread"), s.spread, half);
edit(app, ed, &|v| mk(Shadow { spread: v, ..s.clone() }));
});
// The colour: a bound variable's chip, else swatch, hex and alpha with a variable button.
let color_field = format!("effects/{i}/color");
row(ui, 32.0, |ui| {
crate::variable_ui::bindable(app, ui, ids, &color_field, VariableType::Color, half * 2.0 + 8.0, |app, ui| {
fx_color(app, ui, ids, i, e, false, &edit_color)
});
});
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 = fx_field(app, ui, ids, i, "radius", Lead::Text("Blur"), *radius, half * 2.0 + 8.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(nz) => {
let with = |f: &dyn Fn(&mut Noise)| {
tweaked(e, |k| {
if let EffectKind::Noise(n) = k {
f(n);
}
})
};
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, nz.mode.label(), half * 2.0 + 8.0, None);
egui::Popup::menu(&resp).show(|ui| {
for m in NoiseMode::ALL {
if menu_item(ui, m.label(), "") {
set(app, with(&|n| n.mode = m), None);
ui.close();
}
}
});
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_ns", i)), Lead::Text("Size"), Some(nz.size), "", half, 0.1);
edit(app, ed, &|v| {
with(&|n| {
n.size = v.clamp(0.01, 1000.0);
n.size_y = None;
})
});
let ed = widgets::number_field(ui, Id::new(("fx_nd", i)), Lead::Text("Density"), Some((nz.density * 100.0).round()), "%", half, 1.0);
edit(app, ed, &|v| with(&|n| n.density = (v / 100.0).clamp(0.0, 1.0)));
});
match nz.mode {
NoiseMode::Mono => row(ui, 32.0, |ui| fx_color(app, ui, ids, i, e, false, &edit_color)),
NoiseMode::Duo => {
row(ui, 32.0, |ui| fx_color(app, ui, ids, i, e, false, &edit_color));
row(ui, 32.0, |ui| fx_color(app, ui, ids, i, e, true, &edit_color));
}
NoiseMode::Multi => row(ui, 32.0, |ui| {
let op = Some((f64::from(nz.opacity) * 100.0).round());
let ed = widgets::number_field(ui, Id::new(("fx_no", i)), Lead::Text("Opacity"), op, "%", half, 1.0);
edit(app, ed, &|v| with(&|n| n.opacity = (v / 100.0).clamp(0.0, 1.0) as f32));
}),
}
}
EffectKind::Texture(tx) => {
let with = |f: &dyn Fn(&mut Texture)| {
tweaked(e, |k| {
if let EffectKind::Texture(t) = k {
f(t);
}
})
};
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_ts", i)), Lead::Text("Size"), Some(tx.size), "", half, 0.1);
edit(app, ed, &|v| {
with(&|t| {
t.size = v.clamp(0.01, 1000.0);
t.size_y = None;
})
});
let ed = widgets::number_field(ui, Id::new(("fx_tr", i)), Lead::Text("Radius"), Some(tx.radius), "", half, 1.0);
edit(app, ed, &|v| with(&|t| t.radius = v.max(0.0)));
});
row(ui, 32.0, |ui| {
if widgets::checkbox(ui, tx.clip_to_shape, "Clip to shape") {
set(app, with(&|t| t.clip_to_shape = !tx.clip_to_shape), None);
}
});
}
EffectKind::Glass(g) => {
let with = |f: &dyn Fn(&mut Glass)| {
tweaked(e, |k| {
if let EffectKind::Glass(gl) = k {
f(gl);
}
})
};
let pct = |v: f64| Some((v * 100.0).round());
let unit = |v: f64| (v / 100.0).clamp(0.0, 1.0);
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_gl", i)), Lead::Text("Light"), Some(g.light_angle.round()), "°", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::None, pct(g.light_intensity), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_gr", i)), Lead::Text("Refraction"), pct(g.refraction), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.refraction = unit(v)));
let ed = widgets::number_field(ui, Id::new(("fx_gd", i)), Lead::Text("Depth"), Some(g.depth), "", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.depth = v.clamp(1.0, 1000.0)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_gs", i)), Lead::Text("Dispersion"), pct(g.dispersion), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.dispersion = unit(v)));
let ed = widgets::number_field(ui, Id::new(("fx_gf", i)), Lead::Text("Frost"), Some(g.frost), "", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.frost = v.clamp(0.0, 1000.0)));
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::new(("fx_gp", i)), Lead::Text("Splay"), pct(g.splay), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.splay = unit(v)));
});
}
}
}
/// Applies a new colour to an effect: (app, secondary colour, colour, scrubbing: `Some(done)`).
type ColorEdit<'a> = dyn Fn(&mut FigApp, bool, figcraft_color::Color, Option<bool>) + 'a;
/// `e` with `f` applied to its kind.
fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect {
let mut e2 = e.clone();
f(&mut e2.kind);
e2
}
/// The swatch (opens the colour picker), hex and opacity of effect `i`'s colour (`secondary`: a
/// duo noise's second colour), in the current row; `edit` applies a new one.
fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) {
let Some(c) = e.color(secondary) else { return };
if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() {
app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });
}
if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")
&& let Some(nc) = figcraft_color::Color::from_hex(&h)
{
edit(app, secondary, nc.with_alpha(c.a), None);
}
let alpha = |v: f64| c.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32);
match widgets::number_field(ui, Id::new(("fx_a", i, secondary)), Lead::None, Some((f64::from(c.a) * 100.0).round()), "%", 56.0, 1.0) {
Some(FieldEdit::Set(v)) => edit(app, secondary, alpha(v), None),
Some(FieldEdit::Scrub(v, done)) => edit(app, secondary, alpha(v), Some(done)),
None => {}
}
}
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 {
style_picker::open(app, ui, &ids, StyleSlot::Grid);
}
}
style_picker::chip(app, ui, doc, &ids, StyleSlot::Grid);
let Some(list) = list else { return };
// The last grid first, like paints and effects; rows drag to reorder.
let mut drag = ListDrag::new(ui, "grids");
for (i, g) in list.iter().enumerate().rev() {
let open_id = Id::new(("grid_open", i));
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
let header = row_rect(ui, 32.0, |ui| {
if icons::button(ui, Icon::Grid, 24.0, open, "Layout grid settings").clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(open_id, open));
}
let w = ui.available_width() - 56.0;
let resp = widgets::dropdown(ui, &grid_label(g), w, None);
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 fresh = match kind {
GridKind::Grid => LayoutGrid::default(),
GridKind::Columns => LayoutGrid::columns(),
GridKind::Rows => LayoutGrid::rows(),
};
app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| *x = fresh)}));
ui.close();
}
}
});
if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, "Toggle").clicked() {
app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)}));
}
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
app.run("grid.remove", json!({"ids": ids, "index": i}));
}
});
if open {
grid_settings(app, ui, &ids, &list, i, g);
}
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || grid_label(g));
}
if let Some((from, to)) = drag.finish(ui) {
app.run("grid.move", json!({"ids": ids, "from": from, "to": to}));
move_flags(ui, "grid_open", list.len(), from, to);
}
}
/// A layout grid's name in its row ("Grid (10)", "5 columns").
fn grid_label(g: &LayoutGrid) -> String {
match g.kind {
GridKind::Grid => format!("Grid ({})", g.size),
GridKind::Columns => format!("{} columns", g.count),
GridKind::Rows => format!("{} rows", g.count),
}
}
/// `list` with grid `i` changed by `f`.
fn grids_with(list: &[LayoutGrid], i: usize, f: impl FnOnce(&mut LayoutGrid)) -> Vec<LayoutGrid> {
let mut gs = list.to_vec();
if let Some(g) = gs.get_mut(i) {
f(g);
}
gs
}
/// Layout grid `i`'s settings: size (grids); count, type, width or height, margin or offset and
/// gutter (columns and rows); colour. The numbers can be bound to number variables
/// (`layoutGrids/<i>/<size|count|gutter|offset>`).
fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGrid], i: usize, g: &LayoutGrid) {
let col = widgets::col_width(ui.available_width());
let set = |f: &dyn Fn(&mut LayoutGrid)| json!({"ids": ids, "props": {"grids": grids_with(list, i, f)}});
// One bindable number field, applied with `apply` (a scrub is one undo step).
let field = |app: &mut FigApp, ui: &mut Ui, name: &str, value: f64, apply: &dyn Fn(&mut LayoutGrid, f64)| {
let key = format!("layoutGrids/{i}/{name}");
let e = crate::variable_ui::number_field(app, ui, ids, &key, Id::new(("grid_field", name, i)), Lead::None, Some(value), "", col);
app.field_run(e, "node.set", |v| set(&|x| apply(x, v)));
};
let size = |x: &mut LayoutGrid, v: f64| x.size = v.max(1.0);
let offset = |x: &mut LayoutGrid, v: f64| x.offset = v.max(0.0);
let gutter = |x: &mut LayoutGrid, v: f64| x.gutter = v.max(0.0);
if g.kind == GridKind::Grid {
widgets::sublabels(ui, "Size", None);
row(ui, 32.0, |ui| field(app, ui, "size", g.size, &size));
} else {
let cols = g.kind == GridKind::Columns;
widgets::sublabels(ui, "Count", Some("Type"));
row(ui, 32.0, |ui| {
field(app, ui, "count", f64::from(g.count), &|x, v| x.count = v.round().clamp(1.0, 1000.0) as u32);
let names = if cols { ["Stretch", "Left", "Center", "Right"] } else { ["Stretch", "Top", "Center", "Bottom"] };
let aligns = [GridAlign::Stretch, GridAlign::Min, GridAlign::Center, GridAlign::Max];
let cur = aligns.iter().position(|a| *a == g.align).unwrap_or(0);
let resp = widgets::dropdown(ui, names.get(cur).copied().unwrap_or_default(), col, None);
egui::Popup::menu(&resp).show(|ui| {
for (k, (name, a)) in names.iter().zip(aligns).enumerate() {
if menu_check(ui, k == cur, name, "") {
// Fixed-size columns need a size of their own.
app.run(
"node.set",
set(&|x| {
x.align = a;
if a != GridAlign::Stretch && !(x.size.is_finite() && x.size > 0.0) {
x.size = 60.0;
}
}),
);
ui.close();
}
}
});
});
let span = if cols { "Width" } else { "Height" };
match g.align {
GridAlign::Stretch => {
widgets::sublabels(ui, "Margin", Some("Gutter"));
row(ui, 32.0, |ui| {
field(app, ui, "offset", g.offset, &offset);
field(app, ui, "gutter", g.gutter, &gutter);
});
}
GridAlign::Center => {
widgets::sublabels(ui, span, Some("Gutter"));
row(ui, 32.0, |ui| {
field(app, ui, "size", g.size, &size);
field(app, ui, "gutter", g.gutter, &gutter);
});
}
GridAlign::Min | GridAlign::Max => {
widgets::sublabels(ui, span, Some("Offset"));
row(ui, 32.0, |ui| {
field(app, ui, "size", g.size, &size);
field(app, ui, "offset", g.offset, &offset);
});
widgets::sublabels(ui, "Gutter", None);
row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter));
}
}
}
// The colour: swatch, hex and alpha.
widgets::sublabels(ui, "Color", None);
row(ui, 32.0, |ui| {
let (sw, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
widgets::paint_swatch(ui.painter(), sw, &Paint::solid(g.color));
if let Some(h) = widgets::text_field(ui, Id::new(("grid_hex", i)), &g.color.to_hex(), 80.0, "")
&& let Some(c) = figcraft_color::Color::from_hex(&h)
{
app.run("node.set", set(&|x| x.color = c.with_alpha(x.color.a)));
}
let e = widgets::number_field(ui, Id::new(("grid_alpha", i)), Lead::None, Some((f64::from(g.color.a) * 100.0).round()), "%", 56.0, 1.0);
app.field_run(e, "node.set", |v| set(&|x| x.color = x.color.with_alpha((v / 100.0).clamp(0.0, 1.0) as f32)));
});
}
/// One group of inspected properties: label, value (with a swatch for colours) and the style or
/// variable it comes from. Clicking a row copies its value.
fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine::devinspect::Section) {
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, s.title, semibold(11.0), t.text);
for p in &s.rows {
prop_row(app, ui, p);
}
ui.add_space(4.0);
}
/// An inspected property row: label, value (with a swatch for colours) and the style or variable
/// it comes from as a chip. Clicking it copies the value.
pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devinspect::Prop) {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let resp = resp.on_hover_text(format!("Copy {}", p.value));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
let painter = ui.painter();
painter.text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, &p.label, regular(11.0), t.text_secondary);
// The style or variable, right-aligned as a chip.
let mut right = r.max.x - PAD;
if let Some(token) = &p.token {
let galley = painter.layout_no_wrap(token.clone(), regular(10.0), t.text_secondary);
let chip =
Rect::from_min_max(pos2((right - galley.size().x - 12.0).max(r.min.x + 196.0), r.center().y - 9.0), pos2(right, r.center().y + 9.0));
painter.rect_filled(chip, CornerRadius::same(4), t.field);
painter.with_clip_rect(chip.shrink2(vec2(6.0, 0.0))).galley(
pos2(chip.min.x + 6.0, chip.center().y - galley.size().y / 2.0),
galley,
t.text_secondary,
);
right = chip.min.x - 6.0;
}
let mut x = r.min.x + 96.0;
if let Some(c) = p.color.as_deref().and_then(figcraft_color::Color::from_hex) {
let sw = Rect::from_min_size(pos2(x, r.center().y - 6.0), vec2(12.0, 12.0));
widgets::paint_swatch(painter, sw, &Paint::solid(c));
x += 18.0;
}
let value_r = Rect::from_min_max(pos2(x, r.min.y), pos2(right.max(x + 10.0), r.max.y));
painter.with_clip_rect(value_r).text(pos2(x, r.center().y), Align2::LEFT_CENTER, &p.value, regular(11.0), t.text);
if resp.clicked() {
app.services.clipboard_set_text(&p.value);
app.toast(format!("Copied {}", p.label.to_lowercase()));
}
}
/// The selection's layers with export settings: click one to export it, or export them all.
fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) {
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
let all = widgets::section_header(ui, "Assets", if assets.is_empty() { &[] } else { &[(Icon::Export, "Export all")] }, false).is_some();
if assets.is_empty() {
row(ui, 28.0, |ui| widgets::label(ui, "No export settings. Add them in the Design tab.", t.text_secondary));
return;
}
let mut export: Option<Vec<NodeId>> = all.then(|| assets.iter().map(|a| a.id).collect());
for a in assets {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let resp = resp.on_hover_text(format!("Export {}", a.name));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon);
}
let name_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y), pos2(r.min.x + 96.0 - 4.0, r.max.y));
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, r.center().y), Align2::LEFT_CENTER, &a.name, regular(11.0), t.text);
let fmt_r = Rect::from_min_max(pos2(r.min.x + 96.0, r.min.y), pos2(r.max.x - PAD - 16.0, r.max.y));
ui.painter().with_clip_rect(fmt_r).text(
pos2(fmt_r.min.x, r.center().y),
Align2::LEFT_CENTER,
a.exports.join(", "),
regular(11.0),
t.text_secondary,
);
if resp.clicked() {
export = Some(vec![a.id]);
}
}
if let Some(ids) = export {
crate::export_panel::export_layers(app, &ids);
}
ui.add_space(4.0);
}
/// The Inspect tab (Dev Mode): the handoff status, annotations, properties, assets and code of
/// the selected layer, or the list of what's ready for dev.
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();
let Some(first) = sel.first().copied() else {
widgets::section_header(ui, "Inspect", &[], false);
row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its properties and code", t.text_secondary));
row(ui, 32.0, |ui| widgets::label(ui, "Hover other layers to measure the distance", t.text_secondary));
crate::dev_mode::ready_list(app, ui);
return;
};
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
widgets::section_header(ui, &name, &[], false);
crate::dev_mode::status(app, ui, &doc, &sel);
crate::dev_mode::annotations_section(app, ui, &doc, first);
for s in figcraft_engine::devinspect::properties(&doc, first) {
inspect_section(app, ui, &s);
}
inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel));
use figcraft_engine::codegen::CodeLang;
let lang_id = Id::new("inspect_lang");
let lang = ui.data(|d| d.get_temp::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();
widgets::separator(ui);
let copy = widgets::section_header(ui, "Code", &[(Icon::Export, "Copy")], false).is_some();
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
egui::Popup::menu(&resp).show(|ui| {
for (i, l) in CodeLang::ALL.iter().enumerate() {
if menu_item(ui, l.label(), "") {
ui.data_mut(|d| d.insert_temp(lang_id, i));
ui.close();
}
}
});
});
let code = crate::dev_mode::code(app, first, lang);
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(&code).monospace().color(t.text));
});
ui.add_space(8.0);
let mut copy_clicked = copy;
row(ui, 32.0, |ui| {
copy_clicked |= ui.button(format!("Copy {}", lang.label())).clicked();
});
if copy_clicked {
app.services.clipboard_set_text(&code);
app.toast(format!("Copied {}", lang.label()));
}
}