refactor(ui-egui): split panels/right.rs into right/

This commit is contained in:
aiongg
2026-10-26 20:17:48 +00:00
parent 6167d19e52
commit 99edc6c815
6 changed files with 1828 additions and 1798 deletions
File diff suppressed because it is too large Load Diff
+541
View File
@@ -0,0 +1,541 @@
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::*;
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
/// open picker and settings flags aligned when rows move or are removed.
pub(super) 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());
let (clicked, header) = widgets::section_header_resp(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty);
if let Some(i) = clicked {
if i == 1 {
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
} else {
style_picker::open(app, ui, &ids, StyleSlot::Effect);
}
}
context_popup(&header).show(|ui| {
if app.session.clipboard.effect.is_some() && menu_item(ui, tr("Paste effect"), "") {
app.run("effect.paste", json!({"ids": ids}));
ui.close();
}
});
style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
let mut drag = ListDrag::new(ui, "effects");
let mut removed = None;
for (i, e) in list.iter().enumerate().rev() {
let mut open = effect_open(ui.ctx(), i);
let header = row_rect(ui, 32.0, |ui| {
if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() {
open = !open;
ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open));
}
let w = ui.available_width() - 56.0;
let resp = widgets::dropdown(ui, tr(e.label()), w, None);
menu_popup(&resp).show(|ui| {
for (l, ty) in Effect::TYPES {
if menu_item(ui, tr(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, tr("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, tr("Remove")).clicked()
&& app.run("effect.remove", json!({"ids": ids, "index": i})).is_some()
{
removed = Some(i);
}
});
let can_paste = app.session.clipboard.effect.is_some();
row_context_menu(ui, header, Id::unique(("effect_menu", i))).show(|ui| {
if menu_item(ui, tr("Copy effect"), "") {
app.run("effect.copy", json!({"ids": ids, "index": i}));
ui.close();
}
if can_paste && menu_item(ui, tr("Paste effect"), "") {
app.run("effect.paste", json!({"ids": ids, "index": i}));
ui.close();
}
});
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)), || tr(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);
}
}
// Open settings and an open colour picker stay with their effects, or go with theirs.
if let Some(i) = removed {
remove_flags(ui, FX_OPEN, list.len(), i);
if let Some(crate::colorpicker::PickerTarget::Effect { index, .. }) = &mut app.ui.picker {
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
}
}
}
}
/// Salt of the flags that keep effect rows' settings open.
const FX_OPEN: &str = "fx_open";
/// Whether effect `i`'s settings are open in the Design panel.
pub(crate) fn effect_open(ctx: &egui::Context, i: usize) -> bool {
ctx.data(|d| d.get_temp::<bool>(Id::unique((FX_OPEN, i)))).unwrap_or(false)
}
/// 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::unique(("fx", prop, i)), lead, Some(value), "", width)
}
/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live
/// scrub edits that stay one undo step and bindable colour fields.
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(tr("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(tr("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, tr("Show behind transparent areas")) {
set(app, mk(Shadow { show_behind: !s.show_behind, ..s.clone() }), None);
}
});
}
}
EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b) => {
let with = |f: &dyn Fn(&mut Blur)| {
let mut e2 = e.clone();
if let Some(b2) = e2.blur_mut() {
f(b2);
}
e2
};
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, tr(b.blur_type.label()), half * 2.0 + 8.0, None);
menu_popup(&resp).show(|ui| {
for t in BlurType::ALL {
if menu_item(ui, tr(t.label()), "") {
set(app, with(&|b2| b2.blur_type = t), None);
ui.close();
}
}
});
});
row(ui, 32.0, |ui| {
if b.is_progressive() {
// The ramp's start and end points are dragged on the canvas.
let ed = fx_field(app, ui, ids, i, "startRadius", Lead::Text(tr("Start")), b.start_radius, half);
edit(app, ed, &|v| with(&|b2| b2.start_radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text(tr("End")), b.radius, half);
edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
} else {
let ed = fx_field(app, ui, ids, i, "radius", Lead::Text(tr("Blur")), b.radius, half * 2.0 + 8.0);
edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS)));
}
});
}
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, tr(nz.mode.label()), half * 2.0 + 8.0, None);
menu_popup(&resp).show(|ui| {
for m in NoiseMode::ALL {
if menu_item(ui, tr(m.label()), "") {
set(app, with(&|n| n.mode = m), None);
ui.close();
}
}
});
});
row(ui, 32.0, |ui| {
let ed = widgets::number_field(ui, Id::unique(("fx_ns", i)), Lead::Text(tr("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::unique(("fx_nd", i)),
Lead::Text(tr("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::unique(("fx_no", i)), Lead::Text(tr("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::unique(("fx_ts", i)), Lead::Text(tr("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::unique(("fx_tr", i)), Lead::Text(tr("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, tr("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::unique(("fx_gl", i)), Lead::Text(tr("Light|glass")), 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::unique(("fx_gi", i)), Lead::Text(tr("Intensity")), 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::unique(("fx_gr", i)), Lead::Text(tr("Refraction")), pct(g.refraction), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.refraction = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gd", i)), Lead::Text(tr("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::unique(("fx_gs", i)), Lead::Text(tr("Dispersion")), pct(g.dispersion), "%", half, 1.0);
edit(app, ed, &|v| with(&|gl| gl.dispersion = unit(v)));
let ed = widgets::number_field(ui, Id::unique(("fx_gf", i)), Lead::Text(tr("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::unique(("fx_gp", i)), Lead::Text(tr("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::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&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::unique(("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 => {}
}
}
/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings,
/// and keep open-settings flags aligned when rows move or are removed.
pub(super) 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, tr("Layout grid"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("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");
let mut removed = None;
for (i, g) in list.iter().enumerate().rev() {
let open_id = Id::unique(("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, tr("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);
menu_popup(&resp).width(220.0).show(|ui| {
for (l, kind) in [(tr("Grid"), GridKind::Grid), (tr("Columns"), GridKind::Columns), (tr("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, tr("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, tr("Remove")).clicked()
&& app.run("grid.remove", json!({"ids": ids, "index": i})).is_some()
{
removed = Some(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);
}
// Open settings stay with their grids, or go with theirs.
if let Some(i) = removed {
remove_flags(ui, "grid_open", list.len(), i);
}
}
/// A layout grid's name in its row ("Grid (10)", "5 columns").
fn grid_label(g: &LayoutGrid) -> String {
match g.kind {
GridKind::Grid => trf("Grid ({size})", &[("size", &g.size)]),
GridKind::Columns if auto_count(g) => tr("Auto columns").to_string(),
GridKind::Rows if auto_count(g) => tr("Auto rows").to_string(),
GridKind::Columns => trn("{n} columns", g.count.max(1) as usize, &[]),
GridKind::Rows => trn("{n} rows", g.count.max(1) as usize, &[]),
}
}
/// Fixed-size columns or rows counted Auto: as many as fit.
fn auto_count(g: &LayoutGrid) -> bool {
g.count == 0 && g.align != GridAlign::Stretch
}
/// `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::unique(("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, tr("Size"), None);
row(ui, 32.0, |ui| field(app, ui, "size", g.size, &size));
} else {
let cols = g.kind == GridKind::Columns;
widgets::sublabels(ui, tr("Count"), Some(tr("Type")));
row(ui, 32.0, |ui| {
// 0 is Auto for fixed-size columns (rows); a stretched grid has at least one.
let count = (!auto_count(g)).then(|| f64::from(g.count.max(1)));
let key = format!("layoutGrids/{i}/count");
let id = Id::unique(("grid_field", "count", i));
// A stretched grid has at least one band, so 0 (Auto) isn't a count it can take.
let e = if g.align == GridAlign::Stretch {
crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "")
} else {
crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "")
};
app.field_run(e, "node.set", |v| {
set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32)
});
let names =
if cols { [tr("Stretch"), tr("Left"), tr("Center"), tr("Right")] } else { [tr("Stretch"), tr("Top"), tr("Center"), tr("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);
menu_popup(&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;
}
if a == GridAlign::Stretch {
x.count = x.count.max(1);
}
}),
);
ui.close();
}
}
});
});
let span = if cols { tr("Width") } else { tr("Height") };
match g.align {
GridAlign::Stretch => {
widgets::sublabels(ui, span, Some(tr("Margin")));
row(ui, 32.0, |ui| {
// The frame, divided by the count: shown as Auto and not editable.
ui.add_enabled_ui(false, |ui| {
let auto = widgets::Empty::Text(tr("Auto"));
widgets::number_field_bindable(ui, Id::unique(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto)
});
field(app, ui, "offset", g.offset, &offset);
});
widgets::sublabels(ui, tr("Gutter"), None);
row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter));
}
GridAlign::Center => {
widgets::sublabels(ui, span, Some(tr("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(tr("Offset")));
row(ui, 32.0, |ui| {
field(app, ui, "size", g.size, &size);
field(app, ui, "offset", g.offset, &offset);
});
widgets::sublabels(ui, tr("Gutter"), None);
row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter));
}
}
}
// The colour: swatch, hex and alpha.
widgets::sublabels(ui, tr("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::unique(("grid_hex", i)), &g.color.to_hex(), 80.0, "")
&& let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
{
app.run("node.set", set(&|x| x.color = c.with_alpha(x.color.a)));
}
let e = widgets::number_field(ui, Id::unique(("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)));
});
}
+269
View File
@@ -0,0 +1,269 @@
use super::menu::{menu_item, menu_popup, row};
use super::*;
/// 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.
pub(crate) 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, tr(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.tip(trf("Copy {value}", &[("value", &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();
// The engine's property labels ("Width") are keys of their own.
let label = tr(&p.label);
// Elided before the value column (some languages' labels run longer).
painter.text(
pos2(r.min.x + PAD, r.center().y),
Align2::LEFT_CENTER,
widgets::elide(ui, label, 96.0 - PAD - 6.0),
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.clipboard_set_text(&p.value);
app.toast(trf("Copied {value}", &[("value", &label.to_lowercase())]));
}
}
/// The thumbnails and chosen export formats of the Inspect tab's assets, kept between frames: the
/// thumbnails until the document changes, the formats for the session.
#[derive(Clone, Default)]
struct AssetUi {
revision: u64,
thumbs: std::collections::HashMap<NodeId, Option<egui::TextureHandle>>,
formats: std::collections::HashMap<NodeId, usize>,
}
/// An asset row's height and its preview's side.
const ASSET_ROW_H: f32 = 56.0;
const ASSET_THUMB: f32 = 40.0;
/// The selection's assets (layers with export settings, icons, images) as a list of a preview, a
/// name and a format: click one to export it in that format, or export them all with their own
/// export settings.
fn inspect_assets(app: &mut FigApp, ui: &mut Ui, doc: &Document, assets: &[figcraft_engine::devinspect::Asset]) {
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
let export_all = [(Icon::Export, tr("Export all"))];
let all = widgets::section_header(ui, tr("Assets"), if assets.is_empty() { &[] } else { &export_all }, false).is_some();
if assets.is_empty() {
widgets::note(ui, tr("No assets: no export settings, icons or images. Add export settings in the Design tab."), t.text_secondary);
return;
}
let mut export_all_ids: Option<Vec<NodeId>> = all.then(|| assets.iter().map(|a| a.id).collect());
let mut export_one: Option<(NodeId, ExportSetting, String)> = None;
let revision = app.session.ds().map(|d| d.revision).unwrap_or(0);
let cache_id = Id::unique("inspect_asset_ui");
let mut state: AssetUi = ui.data(|d| d.get_temp(cache_id)).unwrap_or_default();
if state.revision != revision {
state.revision = revision;
state.thumbs.clear();
}
let px = ASSET_THUMB * ui.ctx().pixels_per_point();
for a in assets {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ASSET_ROW_H), Sense::click());
let resp = resp.tip(trf("Export {name}", &[("name", &a.name)]));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 1.0)), CornerRadius::same(6), 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);
}
// The preview, as exporting it paints it (a slice shows the page under it). Rows scrolled
// out of view draw nothing, so they don't pay for a thumbnail either.
let thumb = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - ASSET_THUMB / 2.0), vec2(ASSET_THUMB, ASSET_THUMB));
ui.painter().rect_filled(thumb, CornerRadius::same(4), t.field);
let tex = if ui.is_rect_visible(r) {
state.thumbs.entry(a.id).or_insert_with(|| crate::assets_panel::thumbnail(ui.ctx(), doc, a.id, px)).clone()
} else {
None
};
if let Some(tex) = tex {
let size = tex.size_vec2();
let inner = thumb.shrink(3.0);
// Never past 1:1 physical pixels, so small assets stay crisp.
let k = (inner.width() / size.x).min(inner.height() / size.y).min(1.0 / ui.ctx().pixels_per_point()).max(0.0);
let img = Rect::from_center_size(thumb.center(), size * k);
ui.painter().with_clip_rect(inner).image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(thumb, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
// The name, and the format to export it as (its first export setting's, until chosen).
let x = thumb.max.x + 10.0;
let name_r = Rect::from_min_max(pos2(x, r.min.y + 5.0), pos2(r.max.x - PAD, r.min.y + 27.0));
let name = widgets::elide(ui, &a.name, name_r.width());
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, name_r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text);
let idx = state
.formats
.get(&a.id)
.copied()
.filter(|i| *i < ExportFormat::ALL.len())
.unwrap_or_else(|| ExportFormat::ALL.iter().position(|f| Some(*f) == a.settings.first().map(|s| s.format)).unwrap_or(0));
let fmt = ExportFormat::ALL[idx];
let btn = Rect::from_min_size(pos2(x, r.min.y + 31.0), vec2(74.0, 20.0));
let btn_resp = ui.interact(btn, Id::unique(("inspect_asset_format", a.id)), Sense::click());
a11y::combo(&btn_resp, tr("Format"), fmt.label(), a11y::menu_open(&btn_resp));
if btn_resp.hovered() {
ui.painter().rect_filled(btn, CornerRadius::same(5), t.field);
ui.painter().rect_stroke(btn, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), egui::StrokeKind::Inside);
} else {
ui.painter().rect_filled(btn, CornerRadius::same(5), t.field);
}
ui.painter().text(pos2(btn.min.x + 8.0, btn.center().y), Align2::LEFT_CENTER, fmt.label(), regular(11.0), t.text);
icons::paint(ui.painter(), Rect::from_center_size(pos2(btn.max.x - 9.0, btn.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
menu_popup(&btn_resp).show(|ui| {
for (i, f) in ExportFormat::ALL.iter().enumerate() {
if menu_item(ui, f.label(), "") {
state.formats.insert(a.id, i);
ui.close();
}
}
});
if resp.clicked() && !btn_resp.hovered() {
let mut setting = a.settings.first().cloned().unwrap_or_default();
setting.format = fmt;
let file = format!("{}{}.{}", a.name.rsplit('/').next().unwrap_or(&a.name), setting.file_suffix(), fmt.ext());
export_one = Some((a.id, setting, file));
}
}
ui.data_mut(|d| d.insert_temp(cache_id, state));
if let Some(ids) = export_all_ids.take() {
crate::export_panel::export_layers(app, &ids, true);
} else if let Some((id, setting, file)) = export_one {
crate::export_panel::save_one(app, &[id], &setting, &file);
}
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.
pub(super) 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, tr("Inspect"), &[], false);
widgets::note(ui, tr("Select a layer to see its properties and code"), t.text_secondary);
widgets::note(ui, tr("Hover other layers to measure the distance"), t.text_secondary);
crate::dev_mode::ready_list(app, ui);
crate::dev_mode::categories_section(app, ui);
crate::dev_mode::measurements_section(app, ui, &doc, &sel);
crate::dev_mode::plugins_section(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);
crate::dev_mode::dev_resources_section(app, ui, &doc, first);
crate::dev_mode::measurements_section(app, ui, &doc, &sel);
use figcraft_engine::devinspect::ColorFormat;
let fmt_id = Id::unique("inspect_color_format");
let fmt = ui.data(|d| d.get_temp::<usize>(fmt_id)).and_then(|i| ColorFormat::ALL.get(i).copied()).unwrap_or_default();
row(ui, 32.0, |ui| {
widgets::label(ui, tr("Color format"), t.text_secondary);
let resp = widgets::dropdown(ui, fmt.label(), 72.0, None);
menu_popup(&resp).show(|ui| {
for (i, f) in ColorFormat::ALL.iter().enumerate() {
if menu_item(ui, f.label(), "") {
ui.data_mut(|d| d.insert_temp(fmt_id, i));
ui.close();
}
}
});
});
for s in figcraft_engine::devinspect::properties_with(&doc, first, fmt) {
inspect_section(app, ui, &s);
}
inspect_assets(app, ui, &doc, &figcraft_engine::devinspect::assets(&doc, &sel));
use figcraft_engine::codegen::CodeLang;
let lang_id = Id::unique("inspect_lang");
let lang = ui.data(|d| d.get_temp::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();
// A component tried with other property values: its code replaces the layer's below.
let played = crate::dev_mode::playground_section(app, ui, &doc, first, lang);
widgets::separator(ui);
let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some();
// Pixels or rem (of a 16 px root) for the web languages.
let rem_id = Id::unique("inspect_rem");
let web = lang.is_web();
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
menu_popup(&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();
}
}
});
if web {
let resp = widgets::dropdown(ui, if rem { "rem" } else { "px" }, 56.0, None);
menu_popup(&resp).show(|ui| {
for (on, label) in [(false, "px"), (true, "rem")] {
if menu_item(ui, label, "") {
ui.data_mut(|d| d.insert_temp(rem_id, on));
ui.close();
}
}
});
}
});
let code = played.unwrap_or_else(|| crate::dev_mode::code(app, ui, first, lang));
let code = if rem { figcraft_engine::codegen::px_to_rem(&code, 16.0) } else { code };
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(trf("Copy {value}", &[("value", &lang.label())])).clicked();
});
if copy_clicked {
app.clipboard_set_text(&code);
app.toast(trf("Copied {value}", &[("value", &lang.label())]));
}
crate::dev_mode::connect_section(app, ui, &doc, first, lang);
crate::dev_mode::plugins_section(app, ui);
}
+135
View File
@@ -0,0 +1,135 @@
use super::*;
/// The smallest width a popup menu is measured in (see [`menu_popup`]).
const MENU_MIN_W: f32 = 48.0;
/// A menu row's inset either side, and the gap between its label and its shortcut or art.
const MENU_PAD: f32 = 8.0;
const MENU_GAP: f32 = 24.0;
/// The style of popup menus: egui's, with text kept on one line (a menu grows to its widest item).
pub(crate) fn menu_style(s: &mut egui::Style) {
egui::containers::menu::menu_style(s);
s.wrap_mode = Some(egui::TextWrapMode::Extend);
}
/// The menu a dropdown or button opens: as wide as its widest row and at least as wide as
/// `anchor`. egui measures a popup in a first, invisible pass as wide as the width it is given
/// (else its 600 px default area width) and keeps the size measured; rows ([`menu_row`]) take the
/// larger of that width and their content, so the menu fits its content. Chain `.width(w)` for a
/// menu at least `w` wide.
/// The blend modes as menu items in groups (pass-through only for layers); the one picked.
pub fn blend_menu(ui: &mut Ui, with_pass_through: bool) -> Option<BlendMode> {
let mut picked = None;
for b in BlendMode::ALL.into_iter().filter(|b| with_pass_through || *b != BlendMode::PassThrough) {
if menu_item(ui, tr(b.label()), "") {
picked = Some(b);
ui.close();
}
if matches!(b, BlendMode::Normal | BlendMode::ColorBurn | BlendMode::ColorDodge | BlendMode::HardLight | BlendMode::Exclusion) {
ui.separator();
}
}
picked
}
pub fn menu_popup(anchor: &egui::Response) -> egui::Popup<'static> {
egui::Popup::menu(anchor).width(anchor.rect.width().max(MENU_MIN_W)).style(menu_style)
}
/// A context menu (secondary click on `resp`) sized like [`menu_popup`].
pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> {
egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style)
}
/// A context menu for a property row: a secondary click anywhere over `rect` opens it, even over
/// the row's fields (which would otherwise take the click), like the incumbent's row menus.
pub(super) fn row_context_menu(ui: &Ui, rect: Rect, id: Id) -> egui::Popup<'static> {
let resp = ui.interact(rect, id, Sense::hover());
let open = ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.secondary_clicked());
let at_pointer = egui::Popup::menu(&resp).width(MENU_MIN_W).style(menu_style).at_pointer_fixed();
at_pointer.open_memory(open.then_some(egui::SetOpenCommand::Bool(true)))
}
pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked()
}
/// A menu row with art drawn into it after the label (`art(painter, row rect, text colour)`),
/// taking `art_w` from the row's right edge.
pub fn menu_item_with(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> bool {
menu_row(ui, label, shortcut, art_w, art).clicked()
}
/// The width a menu row needs for a label `label_w` wide and, at its right end, a shortcut or
/// art `trail` wide (0 for neither).
pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 {
MENU_PAD + label_w + if trail > 0.0 { MENU_GAP + trail } else { MENU_PAD }
}
/// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and
/// is at least as wide as its label, shortcut and art.
pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response {
let t = Tokens::get(ui.ctx());
let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER);
let keys =
(!shortcut.is_empty()).then(|| ui.painter().layout_no_wrap(crate::toolbar::pretty_shortcut(shortcut), regular(11.0), Color32::PLACEHOLDER));
let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w);
let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil());
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
a11y::menu_item(&resp, label, shortcut, None);
// The keyboard's item is highlighted like the hovered one.
a11y::own_focus(&resp);
let hovered = resp.hovered() || resp.has_focus();
if hovered {
ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover);
}
// These menus sit on the theme's popup fill (white in the light theme), not on the dark
// `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972).
let col = if hovered { t.menu_text } else { t.text };
ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col);
if let Some(k) = keys {
let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0);
ui.painter().galley(at, k, if hovered { t.menu_text } 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 {
marked_row(ui, Some(on), label, shortcut)
}
/// A plain menu item lined up with the check items above or below it.
pub fn menu_item_beside_checks(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
marked_row(ui, None, label, shortcut)
}
fn marked_row(ui: &mut Ui, on: Option<bool>, label: &str, shortcut: &str) -> bool {
let resp = menu_row(ui, &format!("{}{label}", if on == Some(true) { "✓ " } else { " " }), shortcut, 0.0, |_, _, _| {});
a11y::menu_item(&resp, label, shortcut, on);
resp.clicked()
}
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
}
+553
View File
@@ -0,0 +1,553 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::stroke::stroke_settings;
use super::*;
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
/// the blend-mode row.
pub(super) 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,
tr("Appearance"),
&[
(if visible { Icon::Eye } else { Icon::EyeOff }, crate::shortcuts::tip(tr("Show/Hide"), "object.toggleVisible")),
(Icon::Variable, tr("Apply variable to visibility")),
(Icon::Blend, tr("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::unique("blend_open"), !d.get_temp::<bool>(Id::unique("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, tr("Visible")), 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, tr("Mask"), None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, mt.map(|m| tr(m.label())).unwrap_or(tr("Mixed")), col * 2.0 + 8.0, Some(Icon::Mask));
menu_popup(&resp).show(|ui| {
for m in MaskType::ALL {
if menu_item(ui, tr(m.label()), "") {
app.set_props(&ids, json!({"maskType": m.id()}));
ui.close();
}
}
});
});
}
let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round();
let op = common(doc, sel, op_of);
let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius()));
// Only rectangles and frames round each corner on its own.
let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners()));
// For polygons and stars the same panel holds only the Corner smoothing row.
let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing()));
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius")));
row(ui, 32.0, |ui| {
let lead = Lead::Icon(Icon::Opacity, tr("Opacity"));
let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::unique("ap_op"), lead, op, "%", col);
app.field_of(&ids, "opacity", e, op_of, |v| json!((v / 100.0).clamp(0.0, 1.0)));
if radius_ok {
let e = crate::variable_ui::number_field_mixed(
app,
ui,
&ids,
"cornerRadius",
Id::unique("ap_rad"),
Lead::Icon(Icon::CornerRadius, tr("Corner radius")),
r.flatten(),
"",
col,
);
// A layer with corners of its own starts from its largest.
let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max));
app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0)));
let open = ui.data(|d| d.get_temp::<bool>(Id::unique("indep_corners"))).unwrap_or(false);
// Polygons and stars have no corners of their own: the button opens only Corner smoothing.
let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") };
if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() {
ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open));
}
}
});
if smooth_ok && ui.data(|d| d.get_temp::<bool>(Id::unique("indep_corners"))).unwrap_or(false) {
let radii = common(doc, sel, |n| n.corners.radii);
// `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit
// where their corners are: top left, top right / bottom left, bottom right.
let corners = [
("topLeftRadius", tr("Top left radius"), Icon::CornerTopLeft, 0),
("topRightRadius", tr("Top right radius"), Icon::CornerTopRight, 1),
("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3),
("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2),
];
// Polygons and stars have one radius, so no field per corner.
for pair in corners.chunks(2).filter(|_| indep_ok) {
row(ui, 32.0, |ui| {
for (key, name, icon, k) in pair {
let e = crate::variable_ui::number_field(
app,
ui,
&ids,
key,
Id::unique(("corner", *k)),
Lead::Icon(*icon, name),
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, tr("Corner smoothing"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0);
app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0)));
if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() {
app.set_props(&ids, json!({"cornerSmoothing": 0.6}));
}
});
}
let blend = common(doc, sel, |n| n.blend);
let show_blend = ui.data(|d| d.get_temp::<bool>(Id::unique("blend_open"))).unwrap_or(false)
|| blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough));
if show_blend {
widgets::sublabels(ui, tr("Blend mode"), None);
row(ui, 32.0, |ui| {
let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed"));
let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend));
menu_popup(&resp).width(200.0).show(|ui| {
if let Some(b) = blend_menu(ui, true) {
app.set_props(&ids, json!({"blend": b.label()}));
}
});
});
}
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.
pub(super) 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, tr("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 as for the first selected layer: from its parent, or an instance's main.
let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).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 = trf("Auto ({mode})", &[("mode", &inherited)]);
let label = match &own {
Some(Some(m)) => name_of(m).to_string(),
Some(None) => auto.clone(),
None => tr("Mixed").into(),
};
let hover = match r.library {
Some((_, lib)) => trf("{name} (from {library})", &[("name", &c.name), ("library", &lib)]),
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::unique(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover);
}
let resp = widgets::dropdown(ui, &label, pick_w, None);
menu_popup(&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, tr("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::unique("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::unique("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0))
&& let Some(nc) = figcraft_color::Color::from_hex_or_name(&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, tr("Gradient"), regular(11.0), t.text);
}
}
None => {
ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary);
if ui.interact(r, Id::unique("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, tr("Use the layer's fill")).clicked() {
app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": null}));
}
});
}
/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder,
/// stroke align/weight, per-side weights and advanced stroke settings.
pub(super) 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 { tr("Fill") } else { tr("Stroke") };
let empty = list.as_ref().is_none_or(|l| l.is_empty());
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
// The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`).
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let (clicked, header) = widgets::section_header_resp(
ui,
title,
&[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })],
empty,
);
if let Some(i) = clicked {
if i == 1 {
// The new paint goes on top; open its picker. A mixed selection has no one index to edit.
if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()
&& let Some(l) = &list
{
app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() });
}
} else {
style_picker::open(app, ui, &ids, slot);
}
}
context_popup(&header).show(|ui| {
if app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills) && menu_item(ui, paste, "") {
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids}));
ui.close();
}
});
style_picker::chip(app, ui, doc, &ids, slot);
match &list {
None => widgets::note(ui, tr("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) => tr("Inside"),
Some(StrokeAlign::Center) => tr("Center"),
Some(StrokeAlign::Outside) => tr("Outside"),
None => tr("Mixed"),
},
half,
None,
);
menu_popup(&resp).show(|ui| {
for (l, v) in [(tr("Inside"), "inside"), (tr("Center"), "center"), (tr("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_mixed(
app,
ui,
&ids,
"strokeWeight",
Id::unique("stroke_w"),
Lead::Icon(Icon::Menu, tr("Stroke weight")),
weight,
"",
weight_w,
);
app.field_of(&ids, "strokeWeight", e, |n| n.stroke.weight, |v| json!(v.max(0.0)));
if sided {
let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, tr("Individual strokes"));
menu_popup(&sides_btn).show(|ui| {
let w = weight.unwrap_or(1.0);
for (l, v) in [
(tr("All"), Value::Null),
(tr("Top"), json!([w, 0.0, 0.0, 0.0])),
(tr("Right"), json!([0.0, w, 0.0, 0.0])),
(tr("Bottom"), json!([0.0, 0.0, w, 0.0])),
(tr("Left"), json!([0.0, 0.0, 0.0, w])),
(tr("Custom"), json!([w, w, w, w])),
] {
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"strokeSides": v}));
ui.close();
}
}
});
}
let adv_id = Id::unique("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, tr("Advanced stroke")).clicked() {
ui.data_mut(|d| d.insert_temp(adv_id, !open));
}
});
// Per-side weights, two to a row (like the corner radii) so a bound variable's name fits:
// each side binds to a number variable on its own.
if let Some(sides) = common(doc, sel, |n| n.stroke.sides).flatten() {
let col = widgets::col_width(ui.available_width());
let fields = ["T", "R", "B", "L"].into_iter().zip(figcraft_engine::cmd::props::STROKE_SIDES);
for first in [0, 2] {
row(ui, 32.0, |ui| {
for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) {
let v = sides.get(k).copied();
let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::unique(("stroke_side", k)), Lead::Text(label), v, "", col);
app.field(&ids, field, e, |x| json!(x.max(0.0)));
}
});
}
}
if ui.data(|d| d.get_temp::<bool>(Id::unique("stroke_adv"))).unwrap_or(false) {
stroke_settings(app, ui, doc, sel);
}
}
}
/// 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" };
let r = 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::unique(("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::unique(("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::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("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::unique(("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_or_name(&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::unique(("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, tr("Toggle visibility")).clicked() {
app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));
}
if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()
&& app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some()
&& let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker
&& *f == fills
{
// An open colour picker keeps editing the paint it was on, or closes with it.
match removed_index(*index, i) {
Some(k) => *index = k,
None => app.ui.picker = None,
}
}
});
let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") };
let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") };
let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills);
row_context_menu(ui, r, Id::unique(("paint_menu", fills, i))).show(|ui| {
if menu_item(ui, copy, "") {
app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i}));
ui.close();
}
if can_paste && menu_item(ui, paste, "") {
app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids, "index": i}));
ui.close();
}
});
r
}
/// 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(),
// The gradient kind's name ("Linear") is a key of its own.
PaintKind::Gradient(g) => tr(&format!("{:?}", g.kind)).to_string(),
PaintKind::Image(_) => tr("Image").into(),
PaintKind::Pattern(_) => tr("Pattern").into(),
}
}
+313
View File
@@ -0,0 +1,313 @@
use super::menu::{menu_item, menu_item_with, menu_popup, row};
use super::*;
/// 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);
// The path runs left to right: its left side is drawn on top.
let (l, r) = profile.map_or((0.5, 0.5), |p| {
let (l, r) = p.sides_at(t);
(l / max, r / max)
});
let x = rect.min.x + rect.width() * t as f32;
let (hl, hr) = (((l as f32) * half).max(0.5), ((r as f32) * half).max(0.5));
top.push(pos2(x, cy - hl));
bottom.push(pos2(x, cy + hr));
mesh.colored_vertex(pos2(x, cy - hl), col);
mesh.colored_vertex(pos2(x, cy + hr), 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, tr("Width profile"), None);
row(ui, 32.0, |ui| {
let label = match &profile {
None => tr("Mixed"),
Some(None) => tr(WidthPreset::Uniform.label()),
Some(Some(p)) => p.preset().map_or(tr("Custom"), |w| tr(w.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);
}
menu_popup(&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, tr(preset.label()), "", 8.0 + 64.0, 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, tr("Flip width profile"));
if flip.clicked()
&& let Some(p) = custom
{
let pts: Vec<Value> =
p.points.iter().rev().map(|q| figcraft_engine::cmd::props::width_point_json(&WidthPoint { t: 1.0 - q.t, ..*q })).collect();
app.run("stroke.widthProfile", json!({"ids": ids, "points": pts}));
}
});
}
/// A brush drawn along a short line across `rect`, as outlines (egui fills only convex shapes).
pub(crate) fn brush_art(painter: &egui::Painter, rect: Rect, preset: &str, col: Color32) {
use figcraft_geom::kurbo::{self, PathEl};
let mut line = figcraft_geom::BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((f64::from(rect.width()), 0.0));
let brush = Brush::new(preset);
let props = StrokeProps { weight: f64::from(rect.height()), align: StrokeAlign::Center, brush: Some(brush.clone()), ..Default::default() };
let Some(out) = figcraft_pathops::brush::outline(&line, props.weight, &props, &brush) else { return };
for sub in figcraft_geom::split_subpaths(&out) {
let mut pts = Vec::new();
kurbo::flatten(sub.iter(), 0.25, |el| {
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
pts.push(pos2(rect.min.x + p.x as f32, rect.center().y + p.y as f32));
}
});
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.0, col)));
}
}
/// Stroke settings ▸ Brush: a plain stroke, a stretch brush or a scatter brush, each with its look.
fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option<Brush>>, width: f32) {
use figcraft_pathops::brush::{PRESETS, preset};
let t = Tokens::get(ui.ctx());
widgets::sublabels(ui, tr("Brush"), None);
row(ui, 32.0, |ui| {
let label = match &brush {
None => tr("Mixed"),
Some(b) => b.as_ref().and_then(|b| preset(&b.preset)).map_or(tr("None"), |p| tr(p.label)),
};
let resp = widgets::dropdown(ui, label, width, None);
a11y::combo(&resp, tr("Brush"), label, a11y::menu_open(&resp));
if let Some(Some(b)) = &brush {
let r = resp.rect;
brush_art(
ui.painter(),
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)),
&b.preset,
t.icon_secondary,
);
}
menu_popup(&resp).show(|ui| {
if menu_item(ui, tr("None"), "") {
app.run("stroke.brush", json!({"ids": ids, "preset": null}));
ui.close();
}
for p in PRESETS {
let art = |pt: &egui::Painter, r: Rect, col: Color32| {
brush_art(
pt,
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)),
p.id,
col,
);
};
if menu_item_with(ui, tr(p.label), "", 8.0 + 64.0, art) {
app.run("stroke.brush", json!({"ids": ids, "preset": p.id}));
ui.close();
}
}
});
});
// A scatter brush's spacing and jitter.
let Some(Some(b)) =
brush.filter(|b| b.as_ref().and_then(|b| preset(&b.preset)).is_some_and(|p| p.kind == figcraft_pathops::brush::BrushKind::Scatter))
else {
return;
};
let half = (width - 8.0) / 2.0;
let with = |f: &dyn Fn(&mut Brush)| {
let mut nb = b.clone();
f(&mut nb);
serde_json::to_value(nb.sanitized()).unwrap_or_default()
};
widgets::sublabels(ui, tr("Spacing"), Some(tr("Scatter")));
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("brush_spacing"), Lead::Icon(Icon::Gap, tr("Brush spacing")), Some(b.spacing), "×", half, 0.1);
app.field(ids, "brush", e, |v| with(&|nb| nb.spacing = v));
let e = widgets::number_field(ui, Id::unique("brush_scatter"), Lead::Icon(Icon::Dots, tr("Brush scatter")), Some(b.scatter), "×", half, 0.1);
app.field(ids, "brush", e, |v| with(&|nb| nb.scatter = v));
});
widgets::sublabels(ui, tr("Size jitter"), Some(tr("Rotation jitter")));
row(ui, 32.0, |ui| {
let e = widgets::number_field(
ui,
Id::unique("brush_size_jitter"),
Lead::Icon(Icon::Scale, tr("Brush size jitter")),
Some(b.size_jitter * 100.0),
"%",
half,
1.0,
);
app.field(ids, "brush", e, |v| with(&|nb| nb.size_jitter = v / 100.0));
let e = widgets::number_field(
ui,
Id::unique("brush_rotation_jitter"),
Lead::Icon(Icon::Rotate, tr("Brush rotation jitter")),
Some(b.rotation_jitter * 100.0),
"%",
half,
1.0,
);
app.field(ids, "brush", e, |v| with(&|nb| nb.rotation_jitter = v / 100.0));
});
}
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
pub(super) 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, tr("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 { tr("Dash") } else { tr("Solid") }, half, None);
menu_popup(&resp).show(|ui| {
if menu_item(ui, tr("Solid"), "") {
app.set_props(&ids, json!({"dashes": []}));
ui.close();
}
if menu_item(ui, tr("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, tr("Dash"), Some(tr("Gap")));
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("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::unique("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);
brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), half * 2.0 + 8.0);
let join = st.as_ref().map(|s| s.join);
widgets::sublabels(ui, tr("Join|stroke"), (join == Some(StrokeJoin::Miter)).then_some(tr("Miter angle")));
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, join.map(join_label).unwrap_or(tr("Mixed")), half, None);
menu_popup(&resp).show(|ui| {
for j in StrokeJoin::ALL {
if menu_item(ui, join_label(j), "") {
app.set_props(&ids, json!({"strokeJoin": j}));
ui.close();
}
}
});
if join == Some(StrokeJoin::Miter) {
let angle = st.as_ref().map(|s| (s.miter_angle() * 100.0).round() / 100.0);
let e = widgets::number_field(ui, Id::unique("stroke_miter"), Lead::None, angle, "°", half, 1.0);
app.field(&ids, "miterAngle", e, |v| json!(v));
}
});
// 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, tr("Start point"), Some(tr("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(|c| tr(c.label())).unwrap_or(tr("Mixed")), half, None);
menu_popup(&resp).show(|ui| {
for c in StrokeCap::ALL {
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |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 stroke join's name in the interface.
pub(crate) fn join_label(j: StrokeJoin) -> &'static str {
match j {
StrokeJoin::Miter => tr("Miter"),
StrokeJoin::Bevel => tr("Bevel"),
StrokeJoin::Round => tr("Round"),
}
}
/// The width [`cap_art`] takes at the right of a menu row (its arrows reach past the line's ends).
pub(crate) const CAP_ART_W: f32 = 64.0;
/// A short stroke ending in `cap` (on its left end for a start point), drawn at the right of a menu
/// row.
pub(crate) 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)]);
}
}
}