mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 00:17:58 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, ©));
|
||||
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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user