M3: effect and grid styles in the Design panel (#14)

- Effects and Layout grid sections gain the Styles button: a list of the document's effect or
  grid styles (click to apply) and "Create style" from the selected layer; an applied style shows
  its name with a detach button, like colour styles.
- The colour style picker and style chip are now one shared implementation for fill, stroke,
  effect and grid slots.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 03:30:20 +07:00 committed by GitHub
parent 3f2c1b4ae6
commit afbe195f72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 33 deletions

View File

@ -130,7 +130,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Shapes & vector | 8 | 55% | eraser, variable width, shape builder, multi-point transform box, point snapping, live boolean editing, arc handles on canvas | 15–22 |
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 55% | arrowhead set, dash/gap UI, per-side UI, stroke styles | 5–8 |
| Effects | 4 | 60% | effect styles UI, glass/texture fidelity, shadow spread on non-rects exactness | 6–10 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 60% | section tooling, mask types (luminance/outline) | 5–8 |
| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
| Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 |

View File

@ -734,6 +734,8 @@ pub fn normalize(doc: &mut Document, before: &Document) {
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod variable_tests;

View File

@ -0,0 +1,34 @@
//! Effect and grid styles through the session: create from a layer, apply, edits propagate,
//! detach.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
#[test]
fn effect_and_grid_styles_apply_propagate_and_detach() {
let mut s = Session::new();
let a = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap();
let b = id_of(&s.execute("frame.create", &json!({"x": 200, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap();
s.execute("effect.add", &json!({"ids": [a], "type": "dropShadow"})).unwrap();
s.execute("grid.add", &json!({"ids": [a]})).unwrap();
let fx = s.execute("style.create", &json!({"type": "effect", "name": "Shadow/md", "from": a})).unwrap();
let fx = fx["id"].as_str().unwrap().to_string();
let gr = s.execute("style.create", &json!({"type": "grid", "name": "Grid 8", "from": a})).unwrap();
let gr = gr["id"].as_str().unwrap().to_string();
s.execute("style.apply", &json!({"ids": [b], "style": fx})).unwrap();
s.execute("style.apply", &json!({"ids": [b], "style": gr})).unwrap();
let node = |s: &Session, id| s.doc().unwrap().node(id).unwrap().clone();
assert_eq!(node(&s, b).effects.len(), 1);
assert_eq!(node(&s, b).grids.len(), 1);
assert_eq!(node(&s, b).styles.effect.as_deref(), Some(fx.as_str()));
// Redefining the style from a layer without effects clears them on linked layers.
let c = id_of(&s.execute("frame.create", &json!({"x": 400, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap();
s.execute("style.update", &json!({"style": fx, "from": c})).unwrap();
assert!(node(&s, b).effects.is_empty(), "linked layers follow the style");
// Detach keeps the values and drops the link.
s.execute("style.detach", &json!({"ids": [b], "which": "grid"})).unwrap();
assert!(node(&s, b).styles.grid.is_none());
assert_eq!(node(&s, b).grids.len(), 1);
}

View File

@ -581,29 +581,18 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:
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 style_name = common(doc, sel, |n| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() })
.flatten()
.and_then(|s| doc.style(&s).map(|st| st.name.clone()));
let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };
if let Some(i) =
widgets::section_header(ui, title, &[(Icon::Styles, "Styles"), (Icon::Plus, if fills { "Add fill" } else { "Add stroke" })], empty)
{
if i == 1 {
app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids}));
} else {
ui.data_mut(|d| d.insert_temp(Id::new(("style_menu", fills)), true));
ui.data_mut(|d| d.insert_temp(slot.menu_id(), true));
}
}
style_picker(app, ui, doc, &ids, fills);
if let Some(name) = style_name {
row(ui, 32.0, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(ui.painter(), r, Icon::Styles, t.icon_secondary);
widgets::label(ui, &name, t.text);
if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() {
app.run("style.detach", json!({"ids": ids, "which": if fills { "fill" } else { "stroke" }}));
}
});
}
style_picker(app, ui, doc, &ids, slot);
style_chip(app, ui, doc, &ids, slot);
match &list {
None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)),
Some(l) => {
@ -757,44 +746,118 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
});
}
fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], fills: bool) {
let key = Id::new(("style_menu", fills));
/// A style slot on a layer: what a style picker lists and what a style chip shows.
#[derive(Clone, Copy, PartialEq)]
enum StyleSlot {
Fill,
Stroke,
Effect,
Grid,
}
impl StyleSlot {
/// The slot's name in `style.apply {target}` / `style.detach {which}`.
fn key(self) -> &'static str {
match self {
StyleSlot::Fill => "fill",
StyleSlot::Stroke => "stroke",
StyleSlot::Effect => "effect",
StyleSlot::Grid => "grid",
}
}
fn menu_id(self) -> Id {
Id::new(("style_menu", self.key()))
}
fn linked(self, n: &Node) -> Option<String> {
match self {
StyleSlot::Fill => n.styles.fill.clone(),
StyleSlot::Stroke => n.styles.stroke.clone(),
StyleSlot::Effect => n.styles.effect.clone(),
StyleSlot::Grid => n.styles.grid.clone(),
}
}
fn paints(self) -> bool {
matches!(self, StyleSlot::Fill | StyleSlot::Stroke)
}
}
/// The applied style's name with a detach button.
fn style_chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
let t = Tokens::get(ui.ctx());
let Some(name) = common(doc, ids, |n| slot.linked(n)).flatten().and_then(|s| doc.style(&s).map(|st| st.name.clone())) else { return };
row(ui, 32.0, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
icons::paint(ui.painter(), r, Icon::Styles, t.icon_secondary);
widgets::label(ui, &name, t.text);
if icons::button(ui, Icon::Detach, 24.0, false, "Detach style").clicked() {
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
}
});
}
/// The style list under a section header: the document's styles for `slot` (click to apply) and
/// "Create style" from the first selected layer; paint slots also list colour variables.
fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
let key = slot.menu_id();
if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) {
return;
}
let t = Tokens::get(ui.ctx());
let styles: Vec<(String, String, Vec<Paint>)> = doc
let styles: Vec<(String, String, Option<Paint>)> = doc
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Paint { paints } => Some((s.id.clone(), s.name.clone(), paints.clone())),
.filter_map(|s| match (&s.kind, slot) {
(StyleKind::Paint { paints }, StyleSlot::Fill | StyleSlot::Stroke) => Some((s.id.clone(), s.name.clone(), paints.first().cloned())),
(StyleKind::Effect { .. }, StyleSlot::Effect) | (StyleKind::Grid { .. }, StyleSlot::Grid) => Some((s.id.clone(), s.name.clone(), None)),
_ => None,
})
.collect();
let (title, create_type, default_name) = match slot {
StyleSlot::Fill => ("Color styles", "paint", "Color"),
StyleSlot::Stroke => ("Color styles", "stroke", "Color"),
StyleSlot::Effect => ("Effect styles", "effect", "Effect"),
StyleSlot::Grid => ("Grid styles", "grid", "Grid"),
};
let mut close = false;
egui::Frame::NONE.fill(t.hover).inner_margin(egui::Margin::symmetric(16, 8)).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Color styles").font(semibold(11.0)));
ui.label(egui::RichText::new(title).font(semibold(11.0)));
if ui.small_button("Create style").clicked() {
let n = doc.styles.len() + 1;
app.run("style.create", json!({"type": if fills { "paint" } else { "stroke" }, "name": format!("Color {n}"), "from": ids.first()}));
let n = styles.len() + 1;
app.run("style.create", json!({"type": create_type, "name": format!("{default_name} {n}"), "from": ids.first()}));
close = true;
}
if ui.small_button("Close").clicked() {
close = true;
}
});
for (id, name, paints) in &styles {
if styles.is_empty() {
ui.label(egui::RichText::new("No styles yet").font(regular(11.0)).color(t.text_secondary));
}
for (id, name, paint) in &styles {
ui.horizontal(|ui| {
if let Some(p) = paints.first() {
widgets::swatch(ui, p, 14.0);
match paint {
Some(p) => {
widgets::swatch(ui, p, 14.0);
}
None => {
let (r, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), r, if slot == StyleSlot::Grid { Icon::Grid } else { Icon::Effects }, t.icon_secondary);
}
}
if ui.selectable_label(false, name).clicked() {
app.run("style.apply", json!({"ids": ids, "style": id, "target": if fills { "fill" } else { "stroke" }}));
let mut q = json!({"ids": ids, "style": id});
if slot.paints() {
q["target"] = json!(slot.key());
}
app.run("style.apply", q);
close = true;
}
});
}
if !slot.paints() {
return;
}
// Colour variables bind the first paint's colour.
let modes = std::collections::BTreeMap::new();
let colors: Vec<(String, String, figcraft_color::Color)> = doc
@ -814,7 +877,7 @@ fn style_picker(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], f
ui.horizontal(|ui| {
widgets::swatch(ui, &Paint::solid(*c), 14.0);
if ui.selectable_label(false, name).clicked() {
app.run("variable.bind", json!({"ids": ids, "field": if fills { "fill" } else { "stroke" }, "variable": vid}));
app.run("variable.bind", json!({"ids": ids, "field": slot.key(), "variable": vid}));
close = true;
}
});
@ -831,9 +894,15 @@ 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::Plus, "Add effect")], empty) {
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
if let Some(i) = widgets::section_header(ui, "Effects", &[(Icon::Styles, "Styles"), (Icon::Plus, "Add effect")], empty) {
if i == 1 {
app.run("effect.add", json!({"ids": ids, "type": "dropShadow"}));
} else {
ui.data_mut(|d| d.insert_temp(StyleSlot::Effect.menu_id(), true));
}
}
style_picker(app, ui, doc, &ids, StyleSlot::Effect);
style_chip(app, ui, doc, &ids, StyleSlot::Effect);
let Some(list) = list else { return };
for (i, e) in list.iter().enumerate().rev() {
let mut open = ui.data(|d| d.get_temp::<bool>(Id::new(("fx_open", i)))).unwrap_or(false);
@ -981,9 +1050,15 @@ 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 widgets::section_header(ui, "Layout grid", &[(Icon::Plus, "Add layout grid")], empty).is_some() {
app.run("grid.add", json!({"ids": ids}));
if let Some(i) = widgets::section_header(ui, "Layout grid", &[(Icon::Styles, "Styles"), (Icon::Plus, "Add layout grid")], empty) {
if i == 1 {
app.run("grid.add", json!({"ids": ids}));
} else {
ui.data_mut(|d| d.insert_temp(StyleSlot::Grid.menu_id(), true));
}
}
style_picker(app, ui, doc, &ids, StyleSlot::Grid);
style_chip(app, ui, doc, &ids, StyleSlot::Grid);
let Some(list) = list else { return };
for (i, g) in list.iter().enumerate() {
row(ui, 32.0, |ui| {