mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag: binding a colour variable takes only that part out, unbinding puts the variable's alpha back in, and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
165 lines
8.3 KiB
Rust
165 lines
8.3 KiB
Rust
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
|
|
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
|
|
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
|
|
|
|
use figcraft_doc::NodeId;
|
|
use serde_json::json;
|
|
|
|
use crate::Session;
|
|
|
|
/// 0x80 / 255 and 0x40 / 255.
|
|
const HALF: f32 = 128.0 / 255.0;
|
|
const QUARTER: f32 = 64.0 / 255.0;
|
|
|
|
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
|
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
|
}
|
|
|
|
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
|
|
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
|
|
let mut s = Session::new();
|
|
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
|
|
let r = NodeId(r["id"].as_u64().unwrap());
|
|
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
|
|
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
|
|
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
|
|
(s, r)
|
|
}
|
|
|
|
/// The fill's effective alpha (colour alpha times paint opacity).
|
|
fn alpha(s: &Session, r: NodeId) -> f32 {
|
|
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
|
|
}
|
|
|
|
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
|
|
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
|
|
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
|
|
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
|
|
pix.data()[i]
|
|
}
|
|
|
|
fn near(a: f32, b: f32) -> bool {
|
|
(a - b).abs() < 2e-3
|
|
}
|
|
|
|
#[test]
|
|
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
|
|
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
|
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
|
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
|
assert!(near(p.opacity, HALF));
|
|
assert_eq!(pixel_alpha(&s, r), 128);
|
|
}
|
|
|
|
#[test]
|
|
fn a_bound_variable_alpha_applies_once() {
|
|
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
|
|
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
|
assert_eq!(pixel_alpha(&s, r), 128);
|
|
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
|
|
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
|
|
// Binding the same variable again changes nothing.
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
assert!(near(alpha(&s, r), HALF));
|
|
}
|
|
|
|
#[test]
|
|
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
|
|
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
assert_eq!(pixel_alpha(&s, r), 64);
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
|
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
|
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
|
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
|
|
assert_eq!(pixel_alpha(&s, r), 64);
|
|
// Undo restores the binding and its paint.
|
|
ex(&mut s, "edit.undo", json!({}));
|
|
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
|
assert_eq!(pixel_alpha(&s, r), 64);
|
|
}
|
|
|
|
#[test]
|
|
fn modes_switch_the_alpha_without_accumulating() {
|
|
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
|
|
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
|
|
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
|
|
}
|
|
// An opacity set while bound multiplies on top and survives mode switches.
|
|
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
|
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
|
|
assert!(near(alpha(&s, r), HALF * 0.5));
|
|
}
|
|
|
|
#[test]
|
|
fn node_set_and_style_bindings_apply_the_alpha_once() {
|
|
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
|
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
|
|
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
|
|
assert!(near(alpha(&s, r), HALF));
|
|
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
|
|
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
|
|
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
|
|
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
|
|
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
|
|
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
|
|
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
|
}
|
|
|
|
#[test]
|
|
fn a_fig_round_trip_keeps_the_alpha_once() {
|
|
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
|
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
|
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
|
|
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
|
|
let a = n.fills[0].solid_color().unwrap().a;
|
|
assert!(near(a, HALF * 0.5), "{a}");
|
|
}
|
|
|
|
#[test]
|
|
fn code_gives_a_translucent_stroke_its_alpha_once() {
|
|
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
|
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
|
|
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
|
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
|
}
|
|
|
|
#[test]
|
|
fn binding_keeps_an_opacity_set_on_the_paint() {
|
|
// A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only.
|
|
let mut s = Session::new();
|
|
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]}));
|
|
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"}));
|
|
let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]});
|
|
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap();
|
|
let r = NodeId(r["id"].as_u64().unwrap());
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"}));
|
|
let p = s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
|
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) });
|
|
assert!(near(p.opacity, 0.15), "{}", p.opacity);
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
|
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15));
|
|
// An opacity set on a `#RRGGBBAA` fill is the user's too, and stays.
|
|
let (mut s, r) = setup("#FF0000", "#FF0000");
|
|
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25}));
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
|
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
|
|
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
|
|
let (mut s, r) = setup("#FF000080", "#FF000080");
|
|
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
|
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
|
|
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
|
|
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
|
|
}
|
|
}
|