diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index 875c6416..ce349c03 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -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 { 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 { Ok(json!({"ok": true})) } +/// The binding key (`fills//color` or `strokes//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 { + 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 { + 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 { + 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 { + 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 { let ts = checked_targets(s, p, "selection.selectColor")?; let ids: Vec<_> = ts.iter().map(|t| t.id).collect::>().into_iter().collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 14a6a3bc..09500e07 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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": []}), diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index 4e559d27..d676b42f 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -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"); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index b05427b2..8fe5fe3b 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -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" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index da2a177a..26470631 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -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" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 29596514..b0ceeefd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index fe979678..a3b6dcc0 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -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" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bb771e9f..1fb7d57a 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -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" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a516db9f..bb46c68a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -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" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 641ec1f6..5d5b9fe1 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -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" = "矢印" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b556e8d7..af06d1bf 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -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" = "ისარი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 053348f2..29383ce9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -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" = "화살표" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bc4e0cfa..46fc575a 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -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" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 25f287f9..d90e7a1a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -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" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index b0b8dea0..11e87878 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -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" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed1cc0eb..71efff90 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -2145,7 +2145,9 @@ name = "Русский" "select.sameText" = "Выделить с одинаковыми свойствами текста" "select.set" = "Выделить" "selection.applyColorStyle" = "Применить цветовой стиль к выделению" +"selection.bindColorVariable" = "Применить цветовую переменную к выделению" "selection.colors" = "Цвета выделения" +"selection.detachColor" = "Отвязать цвет выделения" "selection.selectColor" = "Выделить слои с этим цветом" "selection.setPaint" = "Изменить цвет выделения" "shape.arrow" = "Стрелка" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index bcb819e5..25823103 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -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" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 4275f06a..0fe26b89 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -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" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index da070c0e..5fddecc6 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -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" = "箭头" diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index f96fc609..5c90ec77 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -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, 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()); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 8ffe773c..8659b50c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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//color` or + `strokes//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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..f462de45 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |