From c7cd72b09e5f5c4bdbd65d208832dc51532fc4cf Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 04:28:23 -0700 Subject: [PATCH] M3: Selection colors rows apply library color styles and create a style A row's Styles menu lists this file's color styles (library copies are no longer listed as local), then each enabled library's published color styles under the library's name. Choosing a library style copies it in, linked and read-only, and applies it to every fill and stroke the row stands for in one undo step (selection.applyColorStyle with `library`). A row linked to a style or variable now has the Styles button too, so it can switch to another style. Create style on an ordinary row opens a window with the next free "Color n" name. Create makes a style of the row's whole paint (gradient stops, opacity and blend mode included) and links it to every target in one undo step (selection.createColorStyle {targets, name, description?}). A name another style has, an empty name, or a row whose slots no longer share one paint is refused, and the window stays open. Text ranges with fills of their own take the style's value without a link. --- crates/engine/src/cmd/library.rs | 2 +- .../engine/src/selection/selection_colors.rs | 78 +++++- crates/engine/tests/it/selection_colors.rs | 180 ++++++++++++- crates/ui-egui/src/panels/selection_colors.rs | 240 +++++++++++++++--- docs/ui-parity.md | 2 +- 5 files changed, 461 insertions(+), 41 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 5f3263765..8aeb12d49 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -194,7 +194,7 @@ fn with(p: &Value, keys: &[&str], mut base: Value) -> Value { } /// An enabled library of the active document (`library` param), read from its file. -fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc)> { +pub(crate) fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc)> { let key = req_string(cmd, p, "library")?; let link = find_link(s.doc()?, &key) .cloned() diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs index 6ab99e819..b653f333b 100644 --- a/crates/engine/src/selection/selection_colors.rs +++ b/crates/engine/src/selection/selection_colors.rs @@ -5,14 +5,14 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue}; +use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind, VariableType, VariableValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::cmd::styles::{rebind_paint_alpha, unbind_slot}; -use crate::params::{get, paint_of, parse, req_string, targets}; -use crate::{CommandSpec, Result, Session, bad, spec}; +use crate::params::{get, paint_of, parse, req_string, string, targets}; +use crate::{CommandSpec, EngineError, Result, Session, bad, spec}; #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -127,10 +127,17 @@ pub fn specs() -> Vec { spec!( "selection.applyColorStyle", "Apply selection color style", - "{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)", + "{targets, style (id or name), library? (an enabled library: id, name or path; the style is then one it publishes, copied in linked and read-only)}; applies the color style to matching fill/stroke lists (text ranges keep typography)", true, apply_style ), + spec!( + "selection.createColorStyle", + "Create selection color style", + "{targets, name, description?} → {id}; makes a color style of the paint the targets share and applies it to them as selection.applyColorStyle does; a name another style has is refused", + true, + create_style + ), spec!( "selection.bindColorVariable", "Apply selection color variable", @@ -205,15 +212,72 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.applyColorStyle"; let targets = checked_targets(s, p, CMD)?; let name = req_string(CMD, p, "style")?; + let name = if string(p, "library").is_some() { import_library_style(s, CMD, p, &name)? } else { name }; let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?; if !matches!(style.kind, StyleKind::Paint { .. }) { return Err(bad(CMD, "a color style is required")); } - for target in &targets { + link_style(s, &targets, &style)?; + Ok(json!({"ok": true})) +} + +/// Copy the color style `key` (id, else name) that the enabled library `library` publishes into the +/// document, linked and read-only, and return the copy's id. +fn import_library_style(s: &mut Session, cmd: &str, p: &Value, key: &str) -> Result { + let (link, lib) = crate::cmd::library::library(s, cmd, p)?; + let published = |st: &&Style| lib.manifest.style(&st.id).is_some_and(|x| x.published); + let styles = || lib.doc.styles.iter().filter(published); + let style = styles() + .find(|st| st.id == key) + .or_else(|| styles().find(|st| st.name == key)) + .ok_or_else(|| bad(cmd, format!("\"{}\" publishes no style `{key}`", link.name)))?; + if !matches!(style.kind, StyleKind::Paint { .. }) { + return Err(bad(cmd, "a color style is required")); + } + let keys = BTreeSet::from([style.id.clone()]); + crate::library::import_styles(s.doc_mut()?, &lib, &keys, false) + .into_values() + .next() + .ok_or_else(|| EngineError::Other("the style didn't import".into())) +} + +fn create_style(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "selection.createColorStyle"; + let targets = checked_targets(s, p, CMD)?; + let doc = s.doc()?; + let paint = targets.first().and_then(|t| t.paint(doc)).ok_or_else(|| bad(CMD, "a selection color target no longer exists"))?; + if !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) { + return Err(bad(CMD, "a solid or gradient paint is required")); + } + if targets.iter().any(|t| t.paint(doc).as_ref() != Some(&paint)) { + return Err(bad(CMD, "the targets no longer share one paint")); + } + let name = crate::cmd::variables::clean_name(&req_string(CMD, p, "name")?).ok_or_else(|| bad(CMD, "name can't be empty"))?; + if doc.styles.iter().any(|st| st.name == name) { + return Err(bad(CMD, format!("a style named `{name}` already exists"))); + } + let doc = s.doc_mut()?; + let id = doc.new_style_id(); + let style = Style { + id: id.clone(), + name, + description: string(p, "description").unwrap_or_default(), + kind: StyleKind::Paint { paints: vec![paint] }, + bound: BTreeMap::new(), + }; + doc.styles.push(style.clone()); + link_style(s, &targets, &style)?; + Ok(json!({"id": id})) +} + +/// Apply paint style `style` to every target: a fill or stroke list is linked to it; a text range +/// takes its resolved paints and keeps its typography. +fn link_style(s: &mut Session, targets: &[PaintTarget], style: &Style) -> Result<()> { + for target in targets { if let Some((start, end)) = target.range { // Text ranges have typography-style links, not paint-style links. Copy resolved paint // values just as an explicit range fill does; never replace its font or size. - let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue }; + let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, style, target.id).into_owned() else { continue }; let n = s.doc_mut()?.get_mut(target.id)?; if let NodeKind::Text(t) = &mut n.kind { t.restyle(start, end, |st| st.fills = Some(paints.clone())); @@ -222,7 +286,7 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?; } } - Ok(json!({"ok": true})) + Ok(()) } /// The binding key (`fills//color` or `strokes//color`) of the slot a target stands for, when the diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index c3fc4f97c..41204e5db 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -156,9 +156,14 @@ fn invalid_or_stale_targets_roll_back_atomically() { assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err()); assert_eq!(fill(&s, a), "FF0000"); assert_eq!(s.undo_depth(), before); - for command in - ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"] - { + for command in [ + "selection.setPaint", + "selection.selectColor", + "selection.applyColorStyle", + "selection.bindColorVariable", + "selection.detachColor", + "selection.createColorStyle", + ] { for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] { assert!(s.execute(command, ¶ms).is_err()); } @@ -421,3 +426,172 @@ fn scopes_filter_the_menu_but_the_command_binds_anyway() { run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"})); assert_eq!(fill(&s, a), "00FF00"); } + +#[test] +fn a_style_created_from_a_row_links_its_fills_and_strokes_in_one_undo_step() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + let id = run(&mut s, "selection.createColorStyle", json!({"targets":red.targets,"name":"Brand/Red"}))["id"].clone(); + let doc = s.doc().unwrap(); + let style = doc.style(id.as_str().unwrap()).unwrap(); + assert_eq!(style.name, "Brand/Red"); + assert!(matches!(&style.kind, figcraft_doc::StyleKind::Paint { paints } if paints.len() == 1 && paints[0] == red.paint)); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), id.as_str()); + assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), id.as_str()); + assert_eq!(doc.node(b).unwrap().styles.fill, None); + assert_eq!(fill(&s, b), "0000FF"); + assert_eq!(s.undo_depth().0, before.0 + 1); + run(&mut s, "style.update", json!({"style":id,"value":"#00ff00"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + s.undo().unwrap(); + s.undo().unwrap(); + assert!(s.doc().unwrap().style("Brand/Red").is_none()); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); + s.redo().unwrap(); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), id.as_str()); +} + +#[test] +fn a_created_style_keeps_the_rows_gradient_opacity_and_blend_mode() { + use figcraft_color::{BlendMode, Color, Gradient, GradientKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + let mut paint = Paint::gradient(Gradient::default_for(GradientKind::Radial, Color::BLACK)); + paint.opacity = 0.5; + paint.blend = BlendMode::Multiply; + for id in [a, b] { + run(&mut s, "fill.set", json!({"id":id,"paint":paint})); + } + let row = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.paint == paint).unwrap(); + assert_eq!(row.targets.len(), 2); + let id = run(&mut s, "selection.createColorStyle", json!({"targets":row.targets,"name":"Shade"}))["id"].clone(); + let doc = s.doc().unwrap(); + assert!(matches!(&doc.style("Shade").unwrap().kind, figcraft_doc::StyleKind::Paint { paints } if paints == &vec![paint.clone()])); + for n in [a, b] { + assert_eq!(doc.node(n).unwrap().fills, vec![paint.clone()]); + assert_eq!(doc.node(n).unwrap().styles.fill.as_deref(), id.as_str()); + } +} + +#[test] +fn creating_a_style_refuses_taken_or_empty_names_and_stale_rows_and_changes_nothing() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + let styles = s.doc().unwrap().styles.len(); + for name in ["Brand", " Brand ", "", " "] { + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":name})).is_err(), "{name:?}"); + } + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets})).is_err()); + // One slot of the row changed since it was listed: the row no longer stands for one paint. + run(&mut s, "fill.set", json!({"id":b,"color":"#0000ff"})); + let before_stale = s.undo_depth(); + assert!(s.execute("selection.createColorStyle", &json!({"targets":red.targets,"name":"Red"})).is_err()); + assert_eq!(s.undo_depth(), before_stale); + assert_eq!(s.undo_depth().0, before.0 + 1); + let doc = s.doc().unwrap(); + assert_eq!(doc.styles.len(), styles); + assert_eq!(doc.node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); +} + +#[test] +fn a_linked_row_switches_to_another_style() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + let accent = run(&mut s, "style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let linked = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.style.is_some()).unwrap(); + run(&mut s, "selection.applyColorStyle", json!({"targets":linked.targets,"style":"Accent"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), accent.as_str()); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "0000FF"); +} + +struct Fixture(std::path::PathBuf); + +impl Drop for Fixture { + #[allow(clippy::disallowed_methods)] + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A saved library file publishing the color style "Brand" (#0000ff) and the text style "Heading", +/// with the color style "_Draft" left unpublished; the fixture, the file and the library's id. +#[allow(clippy::disallowed_methods)] +fn color_library(tag: &str) -> (Fixture, std::path::PathBuf, Value) { + let fixture = Fixture(std::env::temp_dir().join(format!("figcraft-selection-colors-{tag}-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&fixture.0); + std::fs::create_dir(&fixture.0).unwrap(); + let path = fixture.0.join("library.figcraft"); + let mut publisher = Session::new(); + run(&mut publisher, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"})); + run(&mut publisher, "style.create", json!({"type":"paint","name":"_Draft","value":"#00ff00"})); + let text = run(&mut publisher, "text.create", json!({"text":"Title"}))["id"].clone(); + run(&mut publisher, "style.create", json!({"type":"text","name":"Heading","from":text})); + let published = run(&mut publisher, "library.publish", json!({})); + assert_eq!(published["styles"], 2); + run(&mut publisher, "file.saveAs", json!({"path":path})); + (fixture, path, published["id"].clone()) +} + +#[test] +fn a_row_applies_a_published_library_style_as_a_linked_copy_in_one_undo_step() { + let (_fixture, path, library) = color_library("apply"); + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#00ff00"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + run(&mut s, "library.add", json!({"path":path})); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":"Brand","library":library})); + let doc = s.doc().unwrap(); + let copy = doc.node(a).unwrap().styles.fill.clone().unwrap(); + assert!(figcraft_engine::library::is_remote_style(doc, ©)); + assert_eq!(doc.node(b).unwrap().styles.stroke.as_deref(), Some(copy.as_str())); + assert_eq!(fill(&s, a), "0000FF"); + assert_eq!(fill(&s, b), "00FF00"); + assert_eq!(s.undo_depth().0, before.0 + 1); + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.style(©).is_none()); + assert_eq!(doc.node(a).unwrap().styles.fill, None); + assert_eq!(fill(&s, a), "FF0000"); + // Choosing it again reuses the one copy. + s.redo().unwrap(); + let blue = group(&s, &[a, b], "0000FF"); + run(&mut s, "selection.applyColorStyle", json!({"targets":blue.targets,"style":"Brand","library":library})); + assert_eq!(s.doc().unwrap().styles.len(), 1); +} + +#[test] +fn library_styles_that_are_unpublished_not_colors_or_not_enabled_are_refused() { + let (_fixture, path, library) = color_library("refuse"); + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + rectangle(&mut s, "#00ff00"); + let red = group(&s, &[a], "FF0000"); + assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":"Brand","library":library})).is_err()); + run(&mut s, "library.add", json!({"path":path})); + let before = s.undo_depth(); + for (style, lib) in [("_Draft", library.clone()), ("Heading", library.clone()), ("Missing", library.clone()), ("Brand", json!("Elsewhere"))] { + assert!(s.execute("selection.applyColorStyle", &json!({"targets":red.targets,"style":style,"library":lib})).is_err(), "{style}"); + } + assert_eq!(s.undo_depth(), before); + assert!(s.doc().unwrap().styles.is_empty()); + assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill, None); +} diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 97333367f..7a8189ddb 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -4,8 +4,9 @@ use std::sync::Arc; use egui::{Id, Ui}; -use figcraft_color::Color; -use figcraft_doc::{Document, NodeId, StyleKind, VariableType}; +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, NodeId, PublishedLibrary, Style, StyleKind, VariableType}; +use figcraft_engine::library; use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope}; use serde_json::json; @@ -58,13 +59,15 @@ pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Opti } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity -/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled -/// during an interaction. +/// edits, style/variable detach/apply/bind, a style from this file or an enabled library, Create +/// style, and “select layers with this color”; disabled during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); - let colors = colors(ui, ds.uid, doc, ids, interacting); + let uid = ds.uid; + let colors = colors(ui, uid, doc, ids, interacting); if colors.len() < 2 { + ui.data_mut(|d| d.remove::(create_form_id())); return; } let t = Tokens::get(ui.ctx()); @@ -77,12 +80,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); toggle(app, group.targets.clone(), Some(&swatch)); - let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style .as_ref() .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str()))); + // A linked row has a Detach button beside its Styles button. + let buttons = if linked_name.is_some() { 148.0 } else { 116.0 }; + let width = (ui.available_width() - buttons).max(40.0); if let Some(name) = linked_name { let response = widgets::dropdown(ui, name, width, None); toggle(app, group.targets.clone(), Some(&response)); @@ -112,28 +117,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId paint.opacity = (value / 100.0).clamp(0.0, 1.0); app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); } - if linked_name.is_some() { - let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; - if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { - app.run("selection.detachColor", json!({"targets": group.targets})); - } - } else { - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); - } - } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + style_items(app, ui, doc, group, &t); + if linked_name.is_none() { + ui.separator(); + if crate::right::menu_item(ui, tr("Create style"), "") { + open_create_form(app, ui, doc, group); + ui.close(); } let variables: Vec<_> = doc .variables @@ -156,7 +147,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId ui.close(); } } - }); + } + }); + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); + } } if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() { app.run("selection.selectColor", json!({"targets": group.targets})); @@ -168,6 +165,106 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId }); ui.add_space(8.0); widgets::separator(ui); + create_form(app, ui.ctx(), uid); +} + +/// The paint styles a row's Styles menu offers: this file's (not library copies), then each enabled +/// library's published ones under its name (a library this session hasn't read offers none). A choice +/// applies the style to the row's targets. +fn style_items(app: &mut FigApp, ui: &mut Ui, doc: &Document, group: &SelectionColor, t: &Tokens) { + let paint = |s: &&Style| matches!(s.kind, StyleKind::Paint { .. }); + let local: Vec<&Style> = library::local_styles(doc).filter(paint).collect(); + let libs = crate::variable_ui::libraries(app, doc); + let offered: Vec<(&PublishedLibrary, Vec<&Style>)> = libs + .iter() + .map(|l| (l.as_ref(), l.doc.styles.iter().filter(|s| l.manifest.style(&s.id).is_some_and(|x| x.published)).filter(paint).collect::>())) + .filter(|(_, list)| !list.is_empty()) + .collect(); + if local.is_empty() && offered.is_empty() { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + for style in local { + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + for (lib, list) in offered { + ui.separator(); + widgets::label(ui, &lib.manifest.name, t.text_secondary); + for style in list { + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id, "library": lib.manifest.id})); + app.ui.picker = None; + ui.close(); + } + } + } +} + +/// The Create style window's state: the document and row it was opened for, and the name typed. +#[derive(Clone)] +struct CreateForm { + document: u64, + targets: Vec, + paint: Paint, + name: String, +} + +fn create_form_id() -> Id { + Id::unique("selection_color_create_style") +} + +/// Open the Create style window for `group`, with the next free "Color n" name. +fn open_create_form(app: &mut FigApp, ui: &Ui, doc: &Document, group: &SelectionColor) { + let Ok(ds) = app.session.ds() else { return }; + let form = CreateForm { + document: ds.uid, + targets: group.targets.clone(), + paint: group.paint.clone(), + name: crate::style_picker::next_name(doc, "Color"), + }; + app.ui.picker = None; + ui.data_mut(|d| d.insert_temp(create_form_id(), form)); +} + +/// The Create style window: the row's paint, a name, Cancel and Create (Enter creates). Create runs +/// `selection.createColorStyle`; when it fails (a name in use, a row that changed) the window stays +/// open with the name for correction. +fn create_form(app: &mut FigApp, ctx: &egui::Context, document: u64) { + let Some(mut form) = ctx.data(|d| d.get_temp::(create_form_id())) else { return }; + if form.document != document { + ctx.data_mut(|d| d.remove::(create_form_id())); + return; + } + let (mut open, mut cancel, mut create) = (true, false, false); + crate::dialogs::window(tr("Create style"), ctx).id(create_form_id().with("window")).open(&mut open).show(ctx, |ui| { + ui.set_width(240.0); + ui.horizontal(|ui| { + widgets::swatch(ui, &form.paint, 24.0); + ui.label(widgets::paint_name(&form.paint)); + }); + ui.add_space(6.0); + let name = ui.add(egui::TextEdit::singleline(&mut form.name).hint_text(tr("Name")).desired_width(220.0)); + widgets::select_all_on_focus(ui, &name, &form.name); + if ui.memory(|m| m.focused().is_none()) { + name.request_focus(); + } + ui.add_space(10.0); + let ready = !form.name.trim().is_empty(); + ui.horizontal(|ui| { + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); + let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready; + }); + }); + let made = create && app.run("selection.createColorStyle", json!({"targets": form.targets, "name": form.name.trim()})).is_some(); + if open && !cancel && !made { + ctx.data_mut(|d| d.insert_temp(create_form_id(), form)); + } else { + ctx.data_mut(|d| d.remove::(create_form_id())); + } } #[cfg(test)] @@ -369,4 +466,89 @@ mod tests { assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF")); assert!(p.app.ui.last_error.is_none()); } + + fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts.iter().rev().find(|(t, _)| t == s).unwrap_or_else(|| panic!("{s:?} isn't drawn")).1 + } + + #[test] + fn styles_menu_offers_an_enabled_librarys_published_color_styles() { + let dir = std::env::temp_dir().join(format!("figcraft-selection-colors-panel-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir(&dir).unwrap(); + let path = dir.join("library.figcraft"); + let mut publisher = Session::new(); + publisher.execute("style.create", &json!({"type":"paint","name":"Ocean","value":"#00ffff"})).unwrap(); + publisher.execute("style.create", &json!({"type":"paint","name":"_Draft","value":"#ffff00"})).unwrap(); + publisher.execute("library.publish", &json!({})).unwrap(); + publisher.execute("file.saveAs", &json!({"path":path})).unwrap(); + let (mut p, a, b) = Panel::new(); + p.app.run("library.add", json!({"path":path})); + let red = text_at(&p.frame(vec![]), "FF0000"); + p.click(Pos2::new(228.0, red.y)); + let texts = p.frame(vec![]); + let library_name = p.app.session.doc().unwrap().libraries.enabled[0].name.clone(); + assert!(texts.iter().any(|(s, _)| *s == library_name), "library styles are listed under the library's name"); + assert!(!texts.iter().any(|(s, _)| s == "_Draft"), "unpublished styles aren't offered"); + p.click(text_at(&texts, "Ocean")); + let _ = std::fs::remove_dir_all(&dir); + let doc = p.app.session.doc().unwrap(); + let copy = doc.node(a).unwrap().styles.fill.clone().unwrap(); + assert!(library::is_remote_style(doc, ©)); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FFFF"); + assert!(doc.node(b).unwrap().styles.fill.is_none()); + assert!(p.app.ui.last_error.is_none()); + // The copy is listed under its library, not as one of this file's styles. + let blue = text_at(&p.frame(vec![]), "0000FF"); + p.click(Pos2::new(228.0, blue.y)); + let texts = p.frame(vec![]); + assert_eq!(texts.iter().filter(|(s, _)| s == "Ocean").count(), 2, "the linked row's name and the library's entry"); + } + + #[test] + fn create_style_names_the_row_links_it_and_stays_open_on_a_taken_name() { + let (mut p, a, b) = Panel::new(); + let red = text_at(&p.frame(vec![]), "FF0000"); + p.click(Pos2::new(228.0, red.y)); + let create = text_at(&p.frame(vec![]), "Create style"); + p.click(create); + for _ in 0..2 { + p.frame(vec![]); + } + let form = p.ctx.data(|d| d.get_temp::(create_form_id())).expect("Create style opens its window"); + assert_eq!(form.name, "Color 1"); + // Another style takes the name first: Create fails and the window stays for a new name. + p.app.run("style.create", json!({"type":"paint","name":"Color 1","value":"#00ff00"})); + let button = text_at(&p.frame(vec![]), "Create"); + p.click(button); + assert!(p.ctx.data(|d| d.get_temp::(create_form_id())).is_some()); + assert!(p.app.session.doc().unwrap().node(a).unwrap().styles.fill.is_none()); + p.ctx.data_mut(|d| d.get_temp_mut_or_insert_with(create_form_id(), || form.clone()).name = "Brand".into()); + let before = p.app.session.undo_depth(); + let button = text_at(&p.frame(vec![]), "Create"); + p.click(button); + assert!(p.ctx.data(|d| d.get_temp::(create_form_id())).is_none()); + let doc = p.app.session.doc().unwrap(); + let brand = doc.style("Brand").unwrap().id.clone(); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), Some(brand.as_str())); + assert!(doc.node(b).unwrap().styles.fill.is_none()); + assert_eq!(p.app.session.undo_depth().0, before.0 + 1); + assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Brand")); + } + + #[test] + fn a_linked_rows_styles_menu_switches_its_style() { + let (mut p, a, _) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.create", json!({"type":"paint","name":"Accent","value":"#00ff00"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let row = text_at(&p.frame(vec![]), "Brand"); + p.click(Pos2::new(196.0, row.y)); + let texts = p.frame(vec![]); + assert!(!texts.iter().any(|(s, _)| s == "Create style"), "a linked row has a style already"); + p.click(text_at(&texts, "Accent")); + let doc = p.app.session.doc().unwrap(); + assert_eq!(doc.node(a).unwrap().styles.fill.as_deref(), doc.style("Accent").map(|s| s.id.as_str())); + assert!(p.app.ui.last_error.is_none()); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0286fd37d..5e634bc2b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -74,7 +74,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). A row's Styles menu also lists each enabled library's published color styles under the library's name (a library that can't be read offers none), and a linked row's menu switches it to another style; a library style is copied in, linked and read-only, in the same undo step (`selection.applyColorStyle` with `library`). Create style on an ordinary row asks for a name (the next free "Color n" first) and makes a style of the row's whole paint, gradients, opacity and blend mode included, linked to every fill and stroke the row stands for in one undo step; a name in use keeps the window open (`selection.createColorStyle`). Text ranges with fills of their own take the style's value, without a link. No library variables from there, and no new variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools