Files
figcraft/crates/engine/src/cmd/styles.rs
T
BFlatandClaude Opus 5.5 d7fd41cd13 M3: library variables (#60)
Publishing a library now includes the file's variable collections (modes,
aliases, scopes), versioned per variable and collection; variables hidden
from publishing stay out unless a published variable aliases them or a
published component binds them. Files using the library see its
collections read-only in the Variables editor and the variable pickers;
binding, aliasing or setting a collection's mode copies the variables in
as read-only linked copies, library components keep their bindings, and
"Library updates available" covers changed variables (bound layers follow).

New commands: library.importVariable, library.bindVariable,
library.aliasVariable, library.setVariableMode; variable.update takes
hiddenFromPublishing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:06:18 +07:00

565 lines
25 KiB
Rust

//! Shared styles (paint, text, effect, grid) and variables (collections, modes, bindings).
use std::collections::BTreeMap;
use figcraft_color::Color;
use figcraft_doc::*;
use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"paint.documentColors",
"Document colours",
"{page?, limit? (default 21)} → solid colours used on the page, most used first",
false,
|s, p| {
let page = id(p, "page").unwrap_or(s.page()?);
let limit = num(p, "limit").map(|l| l.clamp(1.0, 500.0) as usize).unwrap_or(21);
let colors = document_colors(s.doc()?, page);
let out: Vec<Value> = colors.into_iter().take(limit).map(|(c, n)| json!({"color": c.to_hex(), "count": n})).collect();
Ok(json!({"colors": out}))
}
),
spec!(
"style.create",
"Create style",
"{type: paint|text|effect|grid, name, from? (node id; default first selected), description?, value? (paints/effects/grids/text style JSON)}",
true,
create
),
spec!("style.apply", "Apply style", "{ids?, style (id or name), target? (fill|stroke; paint styles)}", true, apply),
spec!("style.detach", "Detach style", "{ids?, which: fill|stroke|effect|grid|text}", true, |s, p| {
let which = req_string("style.detach", p, "which")?;
for i in req_targets("style.detach", s, p)? {
let n = s.doc_mut()?.get_mut(i)?;
match which.as_str() {
"fill" => n.styles.fill = None,
"stroke" => n.styles.stroke = None,
"effect" => n.styles.effect = None,
"grid" => n.styles.grid = None,
"text" => n.styles.text = None,
_ => return Err(bad("style.detach", "which: fill|stroke|effect|grid|text")),
}
}
ok()
}),
spec!("style.update", "Edit style", "{style, name?, description?, value?, from? (node id: redefine from it)}", true, update),
spec!("style.delete", "Delete style", "{style}", true, |s, p| {
let id = req_string("style.delete", p, "style")?;
let doc = s.doc_mut()?;
let sid = doc.style(&id).map(|st| st.id.clone()).ok_or_else(|| bad("style.delete", "no such style"))?;
if crate::library::is_remote_style(doc, &sid) {
return Err(EngineError::Disabled("style.delete".into(), crate::library::READ_ONLY.into()));
}
doc.styles.retain(|st| st.id != sid);
// Nodes keep their values but lose the link.
for nid in doc.ids().collect::<Vec<_>>() {
if let Some(n) = doc.node(nid)
&& [&n.styles.fill, &n.styles.stroke, &n.styles.effect, &n.styles.grid, &n.styles.text]
.iter()
.any(|r| r.as_deref() == Some(sid.as_str()))
{
let n = doc.get_mut(nid)?;
for r in [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text] {
if r.as_deref() == Some(sid.as_str()) {
*r = None;
}
}
}
}
ok()
}),
spec!("style.list", "List styles", "{type?}", false, |s, p| {
let t = string(p, "type");
Ok(Value::Array(
s.doc()?
.styles
.iter()
.filter(|st| t.as_deref().is_none_or(|t| st.kind.type_name() == t))
.map(|st| serde_json::to_value(st).unwrap_or(Value::Null))
.collect(),
))
}),
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
let id = unique_id("VariableCollectionId", doc.variables.collections.iter().map(|c| c.id.as_str()));
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
_ => vec!["Mode 1".into()],
};
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes });
Ok(json!({"id": id}))
}),
spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| {
let c = req_string("variable.addMode", p, "collection")?;
let name = req_string("variable.addMode", p, "name")?;
let doc = s.doc_mut()?;
let coll = doc
.variables
.collections
.iter_mut()
.find(|x| x.id == c || x.name == c)
.ok_or_else(|| bad("variable.addMode", "no such collection"))?;
// A fresh id: modes can be deleted, so the count alone may repeat one.
let mid =
(coll.modes.len() + 1..).map(|k| format!("{}/mode{k}", coll.id)).find(|m| !coll.modes.iter().any(|x| x.id == *m)).unwrap_or_default();
let first = coll.modes.first().map(|m| m.id.clone());
coll.modes.push(Mode { id: mid.clone(), name });
let cid = coll.id.clone();
// New mode starts with the default mode's values.
for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) {
if let Some(val) = first.as_ref().and_then(|f| v.values.get(f)).cloned() {
v.values.insert(mid.clone(), val);
}
}
Ok(json!({"id": mid}))
}),
spec!(
"variable.create",
"Create variable",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}}",
true,
var_create
),
spec!(
"variable.set",
"Set variable value",
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}",
true,
var_set
),
spec!("variable.delete", "Delete variable", "{variable}", true, |s, p| {
let v = req_string("variable.delete", p, "variable")?;
let doc = s.doc_mut()?;
let id = doc.variables.get(&v).map(|x| x.id.clone()).ok_or_else(|| bad("variable.delete", "no such variable"))?;
doc.variables.variables.retain(|x| x.id != id);
ok()
}),
spec!(
"variable.bind",
"Bind variable",
"{ids?, field (fill|stroke|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…), variable (null unbinds)}",
true,
bind
),
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, set_mode),
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
]
}
fn next_style_id(doc: &Document) -> String {
let mut k = doc.styles.len() + 1;
loop {
let id = format!("S:{k}");
if doc.style(&id).is_none() {
return id;
}
k += 1;
}
}
fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
Some(match t {
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
"text" => StyleKind::Text { style: n.kind.text()?.style.clone() },
"effect" => StyleKind::Effect { effects: n.effects.clone() },
"grid" => StyleKind::Grid { grids: n.grids.clone() },
_ => return None,
})
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.create";
let t = req_string(cmd, p, "type")?.to_ascii_lowercase();
let name = req_string(cmd, p, "name")?;
let from = id(p, "from").or_else(|| s.selection().first().copied());
let doc = s.doc_mut()?;
let kind = if let Some(v) = get(p, "value") {
match t.as_str() {
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
}
} else {
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
};
let sid = next_style_id(doc);
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind });
// The source layer becomes linked to the new style.
if let Some(f) = from
&& get(p, "value").is_none()
{
let field = match t.as_str() {
"stroke" => "strokeStyle",
"text" => "textStyle",
"effect" => "effectStyle",
"grid" => "gridStyle",
_ => "fillStyle",
};
apply_style_ref(doc, f, field, Some(&sid))?;
}
Ok(json!({"id": sid}))
}
/// Link node `id` to style `sid` for `field` (fillStyle, strokeStyle, …) and copy its value in.
pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<&str>) -> Result<()> {
let style = match sid {
Some(s) => Some(doc.style(s).cloned().ok_or_else(|| EngineError::Other(format!("no such style `{s}`")))?),
None => None,
};
let n = doc.get_mut(id)?;
match (field, &style) {
("fillStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
n.fills = paints.clone();
n.styles.fill = Some(id.clone());
}
("strokeStyle", Some(Style { kind: StyleKind::Paint { paints }, id, .. })) => {
n.strokes = paints.clone();
n.styles.stroke = Some(id.clone());
}
("effectStyle", Some(Style { kind: StyleKind::Effect { effects }, id, .. })) => {
n.effects = effects.clone();
n.styles.effect = Some(id.clone());
}
("gridStyle", Some(Style { kind: StyleKind::Grid { grids }, id, .. })) => {
n.grids = grids.clone();
n.styles.grid = Some(id.clone());
}
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
if let NodeKind::Text(t) = &mut n.kind {
let keep_fills = t.style.fills.clone();
t.style = TextStyle { fills: keep_fills, ..style.clone() };
t.runs.clear();
}
n.styles.text = Some(id.clone());
}
("fillStyle", None) => n.styles.fill = None,
("strokeStyle", None) => n.styles.stroke = None,
("effectStyle", None) => n.styles.effect = None,
("gridStyle", None) => n.styles.grid = None,
("textStyle", None) => n.styles.text = None,
_ => return Err(EngineError::Other(format!("style doesn't fit `{field}`"))),
}
Ok(())
}
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.apply";
let st = req_string(cmd, p, "style")?;
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let style = doc.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
let field = match (&style.kind, string(p, "target").as_deref()) {
(StyleKind::Paint { .. }, Some("stroke")) => "strokeStyle",
(StyleKind::Paint { .. }, _) => "fillStyle",
(StyleKind::Text { .. }, _) => "textStyle",
(StyleKind::Effect { .. }, _) => "effectStyle",
(StyleKind::Grid { .. }, _) => "gridStyle",
};
for i in ids {
apply_style_ref(doc, i, field, Some(&style.id))?;
}
ok()
}
fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.update";
let st = req_string(cmd, p, "style")?;
let doc = s.doc_mut()?;
let idx = doc.styles.iter().position(|x| x.id == st || x.name == st).ok_or_else(|| bad(cmd, "no such style"))?;
if doc.styles.get(idx).is_some_and(|x| crate::library::is_remote_style(doc, &x.id)) {
return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into()));
}
let from = id(p, "from");
let new_kind = if let Some(v) = get(p, "value") {
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
Some(match cur {
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
})
} else if let Some(f) = from {
let t = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
doc.node(f).and_then(|n| kind_from_node(t, n))
} else {
None
};
let Some(style) = doc.styles.get_mut(idx) else { return Err(bad(cmd, "no such style")) };
if let Some(n) = string(p, "name") {
style.name = n;
}
if let Some(d) = string(p, "description") {
style.description = d;
}
if let Some(k) = new_kind {
style.kind = k;
}
let sid = style.id.clone();
propagate_style(doc, &sid);
ok()
}
/// Re-apply style `sid` to every node linked to it.
pub fn propagate_style(doc: &mut Document, sid: &str) {
let ids: Vec<NodeId> = doc.ids().collect();
for i in ids {
let Some(n) = doc.node(i) else { continue };
let refs = n.styles.clone();
for (field, r) in [
("fillStyle", &refs.fill),
("strokeStyle", &refs.stroke),
("effectStyle", &refs.effect),
("gridStyle", &refs.grid),
("textStyle", &refs.text),
] {
if r.as_deref() == Some(sid) {
let _ = apply_style_ref(doc, i, field, Some(sid));
}
}
}
}
/// `variable.bind`: bind (or with no variable, unbind) `field` of the targets.
pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.bind";
let field = req_string(cmd, p, "field")?;
let var = string(p, "variable");
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let vid = match &var {
Some(v) => Some(doc.variables.get(v).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such variable"))?),
None => None,
};
for i in ids {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
}
}
}
ok()
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
let c = req_string(cmd, p, "collection")?;
let m = req_string(cmd, p, "mode")?;
let doc = s.doc()?;
let coll = doc.variables.collection(&c).ok_or_else(|| bad(cmd, "no such collection"))?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
let cid = coll.id.clone();
let mut ids = targets(s, p);
if ids.is_empty() {
ids = vec![s.page()?];
}
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
}
ok()
}
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Some(VariableValue::Alias(a.to_string()));
}
Some(match kind {
VariableType::Color => VariableValue::Color(color_of(v)?),
VariableType::Number => VariableValue::Number(f64_of(v)?),
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
})
}
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.create";
let c = req_string(cmd, p, "collection")?;
let name = req_string(cmd, p, "name")?;
let kind = match req_string(cmd, p, "type")?.to_ascii_lowercase().as_str() {
"color" => VariableType::Color,
"number" | "float" => VariableType::Number,
"string" => VariableType::String,
"boolean" | "bool" => VariableType::Boolean,
_ => return Err(bad(cmd, "type: color|number|string|boolean")),
};
let doc = s.doc_mut()?;
let coll = doc.variables.collection(&c).cloned().ok_or_else(|| bad(cmd, "no such collection"))?;
let mut values = BTreeMap::new();
if let Some(Value::Object(m)) = get(p, "values") {
for (mode, v) in m {
let mid = coll
.modes
.iter()
.find(|x| x.id == *mode || x.name == *mode)
.map(|x| x.id.clone())
.ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
}
}
if let Some(v) = get(p, "value") {
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| val.clone());
}
}
let default = match kind {
VariableType::Color => VariableValue::Color(Color::BLACK),
VariableType::Number => VariableValue::Number(0.0),
VariableType::String => VariableValue::String(String::new()),
VariableType::Boolean => VariableValue::Boolean(true),
};
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| default.clone());
}
let id = unique_id("VariableID", doc.variables.variables.iter().map(|v| v.id.as_str()));
doc.variables.variables.push(Variable {
id: id.clone(),
name,
collection: coll.id,
kind,
values,
description: string(p, "description").unwrap_or_default(),
scopes: None,
hidden_from_publishing: false,
});
Ok(json!({"id": id}))
}
pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.set";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let mode = match string(p, "mode") {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
};
let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? {
VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?),
v => v,
};
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
slot.values.insert(mode, val);
}
ok()
}
/// Write bound variable values into nodes (called during normalisation).
pub fn apply_bindings(doc: &mut Document) {
if doc.variables.variables.is_empty() {
return;
}
let bound: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in bound {
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
for (field, var) in binds {
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// `effects/<index>/<offsetX|offsetY|radius|spread|color>`: one effect's value.
if let Some((i, prop)) = field.strip_prefix("effects/").and_then(|r| r.split_once('/'))
&& let Ok(i) = i.parse::<usize>()
{
effect_sets.push((i, prop.to_string(), val));
continue;
}
let jv = match &val {
VariableValue::Color(c) => json!(c.to_css_hex()),
// Opacity variables hold percentages.
VariableValue::Number(x) if field == "opacity" => json!((x / 100.0).clamp(0.0, 1.0)),
VariableValue::Number(x) => json!(x),
VariableValue::String(x) => json!(x),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(_) => continue,
};
let key = match field.as_str() {
"fill" | "fills" | "fills/0" | "fills/0/color" => "fill",
"stroke" | "strokes/0/color" => "stroke",
other => other,
};
sets.insert(key.to_string(), jv);
}
let _ = super::props::apply("variables", doc, id, &sets);
if !effect_sets.is_empty() {
apply_effect_bindings(doc, id, &effect_sets);
}
}
}
/// Write bound values into a node's effects (only what changed, so unchanged layers stay shared).
fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, VariableValue)]) {
let Some(mut effects) = doc.node(id).map(|n| n.effects.clone()) else { return };
for (i, prop, val) in sets {
let Some(e) = effects.get_mut(*i) else { continue };
let num = match val {
VariableValue::Number(x) if x.is_finite() => Some(*x),
_ => None,
};
match (&mut e.kind, prop.as_str(), num, val) {
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetX", Some(x), _) => s.offset.0 = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "offsetY", Some(x), _) => s.offset.1 = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "radius", Some(x), _) => s.radius = x.max(0.0),
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "spread", Some(x), _) => s.spread = x,
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
(EffectKind::LayerBlur { radius } | EffectKind::BackgroundBlur { radius }, "radius", Some(x), _) => *radius = x.max(0.0),
_ => {}
}
}
if doc.node(id).is_some_and(|n| n.effects != effects)
&& let Ok(n) = doc.get_mut(id)
{
n.effects = effects;
}
}
/// `prefix:N` with N one more than the largest existing.
pub(crate) fn unique_id<'a>(prefix: &str, existing: impl Iterator<Item = &'a str>) -> String {
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{prefix}:{}", max + 1)
}
/// Solid fill and stroke colours used under `page` (alpha includes the paint's opacity), most used
/// first.
pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
let mut counts: BTreeMap<[u8; 4], (Color, usize)> = BTreeMap::new();
let mut stack = vec![page];
let mut seen = 0usize;
while let Some(id) = stack.pop() {
seen += 1;
if seen > 200_000 {
break;
}
let Some(n) = doc.node(id) else { continue };
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
}
let mut out: Vec<(Color, usize)> = counts.into_values().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_hex().cmp(&b.0.to_hex())));
out
}