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:
parent
3f2c1b4ae6
commit
afbe195f72
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
Loading…
Reference in New Issue