diff --git a/ROADMAP.md b/ROADMAP.md index 0519375..ca3dd58 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 921a532..9d58c73 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/style_tests.rs b/crates/engine/src/style_tests.rs new file mode 100644 index 0000000..b48ecf2 --- /dev/null +++ b/crates/engine/src/style_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 12d0874..d55df7c 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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 { + 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::(key)).unwrap_or(false) { return; } let t = Tokens::get(ui.ctx()); - let styles: Vec<(String, String, Vec)> = doc + let styles: Vec<(String, String, Option)> = 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::(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| {