mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
M3: apply and detach color variables from Selection colors
A Selection colors row could take a color style but not a color variable, and a row linked to a style or variable could not be unlinked from there. selection.bindColorVariable binds a color variable to every solid fill and stroke slot a row stands for. It replaces a color style on that list and any earlier binding of the slot. A text range with fills of its own takes the variable's value in the layer's modes. Gradients and slots outside the variable's scopes are refused. selection.detachColor unlinks the style or variable of a row's slots and keeps their colors. In the Design panel, the Styles menu of an unlinked row lists the document's color variables after its color styles, and a linked row shows a Detach button in the Styles button's place.
This commit is contained in:
@@ -5,11 +5,12 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, 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;
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
@@ -130,6 +131,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
apply_style
|
||||
),
|
||||
spec!(
|
||||
"selection.bindColorVariable",
|
||||
"Apply selection color variable",
|
||||
"{targets, variable (id or name)}; binds the color variable to matching solid fill/stroke slots, replacing a color style there (text ranges with fills of their own take its value in the layer's modes)",
|
||||
true,
|
||||
bind_variable
|
||||
),
|
||||
spec!(
|
||||
"selection.detachColor",
|
||||
"Detach selection color",
|
||||
"{targets}; unlinks the color style or variable of matching fill/stroke lists, keeping their values",
|
||||
true,
|
||||
detach
|
||||
),
|
||||
spec!(
|
||||
"selection.selectColor",
|
||||
"Select layers with color",
|
||||
@@ -210,6 +225,111 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
/// The binding key (`fills/<i>/color` or `strokes/<i>/color`) of the slot a target stands for, when the
|
||||
/// target is the node's own list or a text range that shows the node's fills. Returns None for a range
|
||||
/// with fills of its own, since those fills hold no link.
|
||||
fn list_field(doc: &Document, target: &PaintTarget) -> Option<String> {
|
||||
if let Some((start, _)) = target.range
|
||||
&& doc.node(target.id)?.kind.text()?.style_at(start).fills.is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
Some(format!("{}/{}/color", list.prefix(), target.index))
|
||||
}
|
||||
|
||||
/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when
|
||||
/// one was there.
|
||||
fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let before = n.bound.len();
|
||||
n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color")));
|
||||
n.bound.len() != before
|
||||
}
|
||||
|
||||
/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a
|
||||
/// variable that isn't a color, a gradient, or a slot outside the variable's scopes (a variable
|
||||
/// hidden from pickers has none).
|
||||
pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option<String> {
|
||||
let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) };
|
||||
if var.kind != VariableType::Color {
|
||||
return Some("a color variable is required".into());
|
||||
}
|
||||
for target in targets {
|
||||
if !target.paint(doc).is_some_and(|paint| matches!(paint.kind, PaintKind::Solid { .. })) {
|
||||
return Some("color variables bind to solid paints".into());
|
||||
}
|
||||
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let field = format!("{}/{}/color", list.prefix(), target.index);
|
||||
if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) {
|
||||
return Some(format!("`{field}` doesn't take a color variable"));
|
||||
}
|
||||
if let Some(scope) = crate::cmd::variables::field_scope(doc, target.id, &field)
|
||||
&& !var.in_scope(scope)
|
||||
{
|
||||
return Some(format!("the variable isn't scoped to {scope}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn bind_variable(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.bindColorVariable";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let name = req_string(CMD, p, "variable")?;
|
||||
let doc = s.doc()?;
|
||||
if let Some(why) = bind_refusal(doc, &targets, &name) {
|
||||
return Err(bad(CMD, why));
|
||||
}
|
||||
let vid = doc.variables.get(&name).map(|v| v.id.clone()).ok_or_else(|| bad(CMD, "no such variable"))?;
|
||||
for target in &targets {
|
||||
if let Some(field) = list_field(s.doc()?, target) {
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
if target.fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
if !unbind_slot(n, target.fills, target.index) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
|
||||
}
|
||||
n.bound.insert(field, vid.clone());
|
||||
continue;
|
||||
}
|
||||
// A range's own fills can't hold a binding: they take the value, as a range edit does.
|
||||
let doc = s.doc()?;
|
||||
let Some(VariableValue::Color(color)) = doc.variables.resolve(&vid, &doc.modes_for(target.id)) else { continue };
|
||||
let mut paints = target.paints(doc).ok_or_else(|| bad(CMD, "invalid target"))?;
|
||||
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
|
||||
let own = if slot.color_alpha > 0.0 { (slot.opacity / slot.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
|
||||
slot.kind = PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
slot.opacity = own * color.a;
|
||||
slot.color_alpha = color.a;
|
||||
let Some((start, end)) = target.range else { continue };
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut()?.get_mut(target.id)?.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets = checked_targets(s, p, "selection.detachColor")?;
|
||||
for target in &targets {
|
||||
let Some(field) = list_field(s.doc()?, target) else { continue };
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
if target.fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
if unbind_slot(n, target.fills, target.index) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts = checked_targets(s, p, "selection.selectColor")?;
|
||||
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
|
||||
@@ -423,6 +423,7 @@ fn every_command_survives_junk_params() {
|
||||
// Selection color slots: malformed indexes, ranges and paints.
|
||||
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
|
||||
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
|
||||
json!({"targets": [{"id": 2, "fills": false, "index": 99999999999u64, "range": null}], "variable": ["x"]}),
|
||||
// Smart-selection reorder targets and modes as junk.
|
||||
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
|
||||
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
|
||||
|
||||
@@ -156,7 +156,9 @@ 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"] {
|
||||
for command in
|
||||
["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"]
|
||||
{
|
||||
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
}
|
||||
@@ -270,3 +272,149 @@ fn whole_text_fill_keeps_a_color_style_link() {
|
||||
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
|
||||
assert_eq!(fill(&s, id), "00FF00");
|
||||
}
|
||||
|
||||
fn theme_variable(s: &mut Session, name: &str, hex: &str) -> Value {
|
||||
if s.doc().unwrap().variables.collection("Theme").is_none() {
|
||||
run(s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
}
|
||||
run(s, "variable.create", json!({"collection":"Theme","name":name,"type":"color","value":hex}))["id"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_a_color_variable_binds_matching_fill_and_stroke_slots_only() {
|
||||
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 v = theme_variable(&mut s, "Brand", "#00ff00");
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.bindColorVariable", json!({"targets":red.targets,"variable":"Brand"}));
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().bound.get("fills/0/color").map(String::as_str), v.as_str());
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().bound.get("strokes/0/color").map(String::as_str), v.as_str());
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
run(&mut s, "variable.set", json!({"variable":v,"value":"#ffff00"}));
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
let bound = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.variable.is_some()).unwrap();
|
||||
assert_eq!(bound.targets.len(), 2);
|
||||
s.undo().unwrap();
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_variable_replaces_a_style_link_and_an_earlier_binding() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#ff0000");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
|
||||
let old = theme_variable(&mut s, "Old", "#ff0000");
|
||||
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":old}));
|
||||
let new = theme_variable(&mut s, "New", "#0000ff");
|
||||
let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect();
|
||||
run(&mut s, "selection.bindColorVariable", json!({"targets":targets,"variable":new}));
|
||||
for id in [a, b] {
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
assert!(n.styles.fill.is_none());
|
||||
assert_eq!(n.bound.len(), 1, "one binding for the slot: {:?}", n.bound);
|
||||
assert_eq!(n.bound.values().next().map(String::as_str), new.as_str());
|
||||
assert_eq!(fill(&s, id), "0000FF");
|
||||
}
|
||||
assert_eq!(collect(s.doc().unwrap(), &[a, b]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_variable_on_a_text_range_with_its_own_fills_takes_its_value() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
|
||||
theme_variable(&mut s, "Brand", "#00ff0080");
|
||||
let blue = group(&s, &[id], "0000FF");
|
||||
run(&mut s, "selection.bindColorVariable", json!({"targets":blue.targets,"variable":"Brand"}));
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
let t = n.kind.text().unwrap();
|
||||
let paint = &t.style_at(1).fills.as_ref().unwrap()[0];
|
||||
assert_eq!(paint.solid_color().unwrap().to_css_hex(), "#00FF0080");
|
||||
assert_eq!(t.style_at(1).font_size, 30.0);
|
||||
assert!(n.bound.is_empty());
|
||||
assert_eq!(fill(&s, id), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detaching_a_selection_color_keeps_its_value() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#ff0000");
|
||||
let c = rectangle(&mut s, "#0000ff");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
|
||||
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
|
||||
let v = theme_variable(&mut s, "Accent", "#ffff0080");
|
||||
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":v}));
|
||||
run(&mut s, "variable.bind", json!({"id":c,"field":"fill","variable":v}));
|
||||
let colors = collect(s.doc().unwrap(), &[a, b]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
let before = s.undo_depth();
|
||||
for g in &colors {
|
||||
run(&mut s, "selection.detachColor", json!({"targets":g.targets}));
|
||||
}
|
||||
assert_eq!(s.undo_depth().0, before.0 + 2);
|
||||
assert!(s.doc().unwrap().node(a).unwrap().styles.fill.is_none());
|
||||
assert!(s.doc().unwrap().node(b).unwrap().bound.is_empty());
|
||||
assert!(!s.doc().unwrap().node(c).unwrap().bound.is_empty());
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#FFFF0080");
|
||||
run(&mut s, "variable.set", json!({"variable":v,"value":"#000000"}));
|
||||
assert_eq!(fill(&s, b), "FFFF00");
|
||||
assert!(collect(s.doc().unwrap(), &[a, b]).iter().all(|g| g.style.is_none() && g.variable.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_variables_refuse_gradients_other_types_and_unknown_names() {
|
||||
use figcraft_color::{Color, Gradient, GradientKind};
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":gradient}));
|
||||
theme_variable(&mut s, "Brand", "#00ff00");
|
||||
run(&mut s, "variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8}));
|
||||
let colors = collect(s.doc().unwrap(), &[a]);
|
||||
let solid = colors.iter().find(|g| g.paint.solid_color().is_some()).unwrap();
|
||||
let ramp = colors.iter().find(|g| g.paint.solid_color().is_none()).unwrap();
|
||||
let before = s.undo_depth();
|
||||
for (targets, variable) in [(&ramp.targets, "Brand"), (&solid.targets, "Gap"), (&solid.targets, "Missing")] {
|
||||
assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err());
|
||||
}
|
||||
assert!(s.execute("selection.bindColorVariable", &json!({"targets":solid.targets})).is_err());
|
||||
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
run(&mut s, "selection.bindColorVariable", json!({"targets":solid.targets,"variable":"Brand"}));
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().bound.len(), 1);
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_variables_apply_only_within_their_scopes() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
|
||||
theme_variable(&mut s, "Line", "#00ff00");
|
||||
theme_variable(&mut s, "Hidden", "#0000ff");
|
||||
run(&mut s, "variable.update", json!({"variable":"Line","scopes":["STROKE_COLOR"]}));
|
||||
run(&mut s, "variable.update", json!({"variable":"Hidden","scopes":[]}));
|
||||
let red = group(&s, &[a], "FF0000");
|
||||
let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect();
|
||||
let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect();
|
||||
for (targets, variable) in [(&red.targets, "Line"), (&fill_slot, "Line"), (&stroke_slot, "Hidden")] {
|
||||
assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err());
|
||||
}
|
||||
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"}));
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
}
|
||||
|
||||
@@ -2086,7 +2086,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Vybrat stejné vlastnosti textu"
|
||||
"select.set" = "Vybrat"
|
||||
"selection.applyColorStyle" = "Použít barevný styl na výběr"
|
||||
"selection.bindColorVariable" = "Použít barevnou proměnnou na výběr"
|
||||
"selection.colors" = "Barvy výběru"
|
||||
"selection.detachColor" = "Odpojit barvu výběru"
|
||||
"selection.selectColor" = "Vybrat vrstvy s touto barvou"
|
||||
"selection.setPaint" = "Změnit barvu výběru"
|
||||
"shape.arrow" = "Šipka"
|
||||
|
||||
@@ -2039,7 +2039,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Gleiche Texteigenschaften auswählen"
|
||||
"select.set" = "Auswählen"
|
||||
"selection.applyColorStyle" = "Farbstil auf Auswahl anwenden"
|
||||
"selection.bindColorVariable" = "Farbvariable auf Auswahl anwenden"
|
||||
"selection.colors" = "Auswahlfarben"
|
||||
"selection.detachColor" = "Auswahlfarbe lösen"
|
||||
"selection.selectColor" = "Ebenen mit dieser Farbe auswählen"
|
||||
"selection.setPaint" = "Farbe der Auswahl ändern"
|
||||
"shape.arrow" = "Pfeil"
|
||||
|
||||
@@ -2154,7 +2154,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Seleccionar mismas propiedades de texto"
|
||||
"select.set" = "Seleccionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
|
||||
"selection.bindColorVariable" = "Aplicar variable de color a la selección"
|
||||
"selection.colors" = "Colores de la selección"
|
||||
"selection.detachColor" = "Separar color de la selección"
|
||||
"selection.selectColor" = "Seleccionar capas con este color"
|
||||
"selection.setPaint" = "Cambiar color de la selección"
|
||||
"shape.arrow" = "Flecha"
|
||||
|
||||
@@ -2132,7 +2132,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Valitse samat tekstiominaisuudet"
|
||||
"select.set" = "Valitse"
|
||||
"selection.applyColorStyle" = "Käytä valinnan värityyliä"
|
||||
"selection.bindColorVariable" = "Käytä valinnan värimuuttujaa"
|
||||
"selection.colors" = "Valinnan värit"
|
||||
"selection.detachColor" = "Irrota valinnan väri"
|
||||
"selection.selectColor" = "Valitse tämänväriset tasot"
|
||||
"selection.setPaint" = "Muuta valinnan väriä"
|
||||
"shape.arrow" = "Nuoli"
|
||||
|
||||
@@ -2134,7 +2134,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
|
||||
"select.set" = "Sélectionner"
|
||||
"selection.applyColorStyle" = "Appliquer un style de couleur à la sélection"
|
||||
"selection.bindColorVariable" = "Appliquer une variable de couleur à la sélection"
|
||||
"selection.colors" = "Couleurs de la sélection"
|
||||
"selection.detachColor" = "Détacher la couleur de la sélection"
|
||||
"selection.selectColor" = "Sélectionner les calques de cette couleur"
|
||||
"selection.setPaint" = "Changer la couleur de la sélection"
|
||||
"shape.arrow" = "Flèche"
|
||||
|
||||
@@ -2090,7 +2090,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Pilih properti teks yang sama"
|
||||
"select.set" = "Pilih"
|
||||
"selection.applyColorStyle" = "Terapkan gaya warna ke pilihan"
|
||||
"selection.bindColorVariable" = "Terapkan variabel warna ke pilihan"
|
||||
"selection.colors" = "Warna pilihan"
|
||||
"selection.detachColor" = "Lepas warna pilihan"
|
||||
"selection.selectColor" = "Pilih layer dengan warna ini"
|
||||
"selection.setPaint" = "Ubah warna pilihan"
|
||||
"shape.arrow" = "Panah"
|
||||
|
||||
@@ -2112,7 +2112,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "同じテキストプロパティを選択"
|
||||
"select.set" = "選択"
|
||||
"selection.applyColorStyle" = "選択範囲にカラースタイルを適用"
|
||||
"selection.bindColorVariable" = "選択範囲にカラー変数を適用"
|
||||
"selection.colors" = "選択範囲のカラー"
|
||||
"selection.detachColor" = "選択範囲のカラーを切り離す"
|
||||
"selection.selectColor" = "このカラーのレイヤーを選択"
|
||||
"selection.setPaint" = "選択範囲のカラーを変更"
|
||||
"shape.arrow" = "矢印"
|
||||
|
||||
@@ -2126,7 +2126,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა"
|
||||
"select.set" = "მონიშვნა"
|
||||
"selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება"
|
||||
"selection.bindColorVariable" = "მონიშნულზე ფერის ცვლადის გამოყენება"
|
||||
"selection.colors" = "მონიშნულის ფერები"
|
||||
"selection.detachColor" = "მონიშნულის ფერის გამოყოფა"
|
||||
"selection.selectColor" = "ამ ფერის შრეების მონიშვნა"
|
||||
"selection.setPaint" = "მონიშნულის ფერის შეცვლა"
|
||||
"shape.arrow" = "ისარი"
|
||||
|
||||
@@ -2030,7 +2030,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "같은 텍스트 속성 선택"
|
||||
"select.set" = "선택"
|
||||
"selection.applyColorStyle" = "선택 항목에 색상 스타일 적용"
|
||||
"selection.bindColorVariable" = "선택 항목에 색상 변수 적용"
|
||||
"selection.colors" = "선택 항목 색상"
|
||||
"selection.detachColor" = "선택 항목 색상 분리"
|
||||
"selection.selectColor" = "이 색상의 레이어 선택"
|
||||
"selection.setPaint" = "선택 항목 색상 변경"
|
||||
"shape.arrow" = "화살표"
|
||||
|
||||
@@ -2131,7 +2131,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Dezelfde teksteigenschappen selecteren"
|
||||
"select.set" = "Selecteren"
|
||||
"selection.applyColorStyle" = "Kleurstijl op selectie toepassen"
|
||||
"selection.bindColorVariable" = "Kleurvariabele op selectie toepassen"
|
||||
"selection.colors" = "Selectiekleuren"
|
||||
"selection.detachColor" = "Selectiekleur loskoppelen"
|
||||
"selection.selectColor" = "Lagen op kleur selecteren"
|
||||
"selection.setPaint" = "Selectiekleur wijzigen"
|
||||
"shape.arrow" = "Pijl"
|
||||
|
||||
@@ -2125,7 +2125,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu"
|
||||
"select.set" = "Zaznacz"
|
||||
"selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia"
|
||||
"selection.bindColorVariable" = "Zastosuj zmienną koloru do zaznaczenia"
|
||||
"selection.colors" = "Kolory zaznaczenia"
|
||||
"selection.detachColor" = "Odłącz kolor zaznaczenia"
|
||||
"selection.selectColor" = "Zaznacz warstwy z kolorem"
|
||||
"selection.setPaint" = "Zmień kolor zaznaczenia"
|
||||
"shape.arrow" = "Strzałka"
|
||||
|
||||
@@ -2143,7 +2143,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "Selecionar mesmas propriedades de texto"
|
||||
"select.set" = "Selecionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de cor à seleção"
|
||||
"selection.bindColorVariable" = "Aplicar variável de cor à seleção"
|
||||
"selection.colors" = "Cores da seleção"
|
||||
"selection.detachColor" = "Desvincular cor da seleção"
|
||||
"selection.selectColor" = "Selecionar camadas com esta cor"
|
||||
"selection.setPaint" = "Alterar cor da seleção"
|
||||
"shape.arrow" = "Seta"
|
||||
|
||||
@@ -2145,7 +2145,9 @@ name = "Русский"
|
||||
"select.sameText" = "Выделить с одинаковыми свойствами текста"
|
||||
"select.set" = "Выделить"
|
||||
"selection.applyColorStyle" = "Применить цветовой стиль к выделению"
|
||||
"selection.bindColorVariable" = "Применить цветовую переменную к выделению"
|
||||
"selection.colors" = "Цвета выделения"
|
||||
"selection.detachColor" = "Отвязать цвет выделения"
|
||||
"selection.selectColor" = "Выделить слои с этим цветом"
|
||||
"selection.setPaint" = "Изменить цвет выделения"
|
||||
"shape.arrow" = "Стрелка"
|
||||
|
||||
@@ -2112,7 +2112,9 @@ name = "Svenska"
|
||||
"select.sameText" = "Markera samma textegenskaper"
|
||||
"select.set" = "Markera"
|
||||
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
|
||||
"selection.bindColorVariable" = "Använd färgvariabel på markeringen"
|
||||
"selection.colors" = "Markeringsfärger"
|
||||
"selection.detachColor" = "Koppla bort markeringsfärg"
|
||||
"selection.selectColor" = "Markera lager med den här färgen"
|
||||
"selection.setPaint" = "Ändra markeringens färg"
|
||||
"shape.arrow" = "Pil"
|
||||
|
||||
@@ -2090,7 +2090,9 @@ name = "Türkçe"
|
||||
"select.sameText" = "Aynı metin özelliklerini seç"
|
||||
"select.set" = "Seç"
|
||||
"selection.applyColorStyle" = "Seçime renk stili uygula"
|
||||
"selection.bindColorVariable" = "Seçime renk değişkeni uygula"
|
||||
"selection.colors" = "Seçim renkleri"
|
||||
"selection.detachColor" = "Seçim rengini ayır"
|
||||
"selection.selectColor" = "Bu renkteki katmanları seç"
|
||||
"selection.setPaint" = "Seçimin rengini değiştir"
|
||||
"shape.arrow" = "Ok"
|
||||
|
||||
@@ -2031,7 +2031,9 @@ status = "draft — needs native review"
|
||||
"select.sameText" = "选择相同文本属性"
|
||||
"select.set" = "选择"
|
||||
"selection.applyColorStyle" = "将颜色样式应用于所选项"
|
||||
"selection.bindColorVariable" = "将颜色变量应用于所选项"
|
||||
"selection.colors" = "所选项颜色"
|
||||
"selection.detachColor" = "分离所选项颜色"
|
||||
"selection.selectColor" = "选择使用此颜色的图层"
|
||||
"selection.setPaint" = "更改所选项颜色"
|
||||
"shape.arrow" = "箭头"
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::sync::Arc;
|
||||
|
||||
use egui::{Id, Ui};
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::{Document, NodeId, StyleKind};
|
||||
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, collect};
|
||||
use figcraft_doc::{Document, NodeId, StyleKind, VariableType};
|
||||
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::a11y::Tip;
|
||||
@@ -106,24 +106,48 @@ 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}));
|
||||
}
|
||||
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 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 !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
});
|
||||
} 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 variables: Vec<_> = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| v.kind == VariableType::Color && bind_refusal(doc, &group.targets, &v.id).is_none())
|
||||
.collect();
|
||||
if !variables.is_empty() {
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Color variables"), t.text_secondary);
|
||||
}
|
||||
for v in variables {
|
||||
if crate::right::menu_item(ui, &v.name, "") {
|
||||
app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
@@ -273,4 +297,45 @@ mod tests {
|
||||
let colors = collect(p.app.session.doc().unwrap(), &[a, b]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_menu_applies_a_color_variable_to_the_row() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
p.app.run("variable.createCollection", json!({"name":"Theme"}));
|
||||
p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"}));
|
||||
p.app.run("variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8}));
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
p.click(Pos2::new(228.0, red.y));
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(s, _)| s == "Color variables"));
|
||||
assert!(!texts.iter().any(|(s, _)| s == "Gap"));
|
||||
let accent = texts.iter().find(|(s, _)| s == "Accent").unwrap().1;
|
||||
p.click(accent);
|
||||
let doc = p.app.session.doc().unwrap();
|
||||
assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert!(!doc.node(a).unwrap().bound.is_empty());
|
||||
assert!(doc.node(b).unwrap().bound.is_empty());
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Accent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_row_detaches_its_style_or_variable() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
|
||||
p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
|
||||
p.app.run("variable.createCollection", json!({"name":"Theme"}));
|
||||
p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#0000ff"}));
|
||||
p.app.run("variable.bind", json!({"id":b,"field":"fill","variable":"Accent"}));
|
||||
for name in ["Brand", "Accent"] {
|
||||
let row = p.frame(vec![]).into_iter().find(|(s, _)| s == name).unwrap().1;
|
||||
p.click(Pos2::new(228.0, row.y));
|
||||
}
|
||||
let doc = p.app.session.doc().unwrap();
|
||||
assert!(doc.node(a).unwrap().styles.fill.is_none());
|
||||
assert!(doc.node(b).unwrap().bound.is_empty());
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF"));
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1552,13 @@ Pass the returned target list to:
|
||||
- `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`,
|
||||
this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy
|
||||
resolved paints and keep their typography, without a paint-style link.
|
||||
- `selection.bindColorVariable {targets, variable}` to bind a document color variable (id or name)
|
||||
to those solid fill and stroke slots, as `variable.bind` does with `fills/<i>/color` or
|
||||
`strokes/<i>/color`. A color style on the list and an earlier binding of the slot are removed.
|
||||
Text ranges with fills of their own take the variable's value in the layer's modes, without a
|
||||
binding. Gradients, other variable types and slots outside the variable's scopes are refused.
|
||||
- `selection.detachColor {targets}` to unlink the color style and the color variable of those
|
||||
slots, keeping their values.
|
||||
|
||||
Keep the targets fixed throughout a live interaction. A color becoming equal to another color
|
||||
must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits
|
||||
|
||||
+2
-1
@@ -70,7 +70,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). No reorder rings for swapping layers; 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 colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#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`). No library styles or variables from there, and no new style or 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
|
||||
@@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row |
|
||||
| 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned |
|
||||
| 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged |
|
||||
| 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged |
|
||||
|
||||
Reference in New Issue
Block a user