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.
This commit is contained in:
dotCooCoo
2026-10-31 04:28:23 -07:00
parent 0abbe44cd4
commit c7cd72b09e
5 changed files with 461 additions and 41 deletions
+1 -1
View File
@@ -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<PublishedLibrary>)> {
pub(crate) fn library(s: &mut Session, cmd: &str, p: &Value) -> Result<(LibraryLink, Arc<PublishedLibrary>)> {
let key = req_string(cmd, p, "library")?;
let link = find_link(s.doc()?, &key)
.cloned()
@@ -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<CommandSpec> {
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<Value> {
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<String> {
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<Value> {
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<Value> {
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/<i>/color` or `strokes/<i>/color`) of the slot a target stands for, when the
+177 -3
View File
@@ -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, &params).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, &copy));
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(&copy).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);
}
+211 -29
View File
@@ -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<PaintTarget>, 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<Document>, 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::<CreateForm>(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<Document>, 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<Document>, 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<Document>, 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<Document>, 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::<Vec<_>>()))
.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<PaintTarget>,
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::<CreateForm>(create_form_id())) else { return };
if form.document != document {
ctx.data_mut(|d| d.remove::<CreateForm>(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::<CreateForm>(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, &copy));
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::<CreateForm>(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::<CreateForm>(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::<CreateForm>(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());
}
}
+1 -1
View File
@@ -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