Merge pull request #827 from mateusvillain/feat/color-picker-lch-oklch

M1: LCH and OKLCH models in the colour picker
This commit is contained in:
BFlat
2026-10-27 01:27:07 +07:00
committed by GitHub
12 changed files with 544 additions and 42 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -361,6 +361,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
| 2026-10-18 | minor | Colour picker models LCH and OKLCH beside Hex, RGB, HSL, HSB and CSS (desktop and web): lightness, chroma and hue fields (OKLCH lightness in percent, chroma to two places), an edit keeps lightness and hue and lowers chroma until the colour fits sRGB, as CSS gamut mapping does; the CSS field reads `lch()` and `oklch()` too. Values read the stored channels as sRGB, like the other models (a Display P3 file's colours aren't converted first). Conversions checked against the CSS Color 4 and OKLab reference values; checked in the desktop app over the control channel. Numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
+3
View File
@@ -86,3 +86,6 @@ sync_github_name = true
[people.leandrodslv]
sync_github_name = true
[people.mateusvillain]
sync_github_name = true
+1 -1
View File
@@ -3,7 +3,7 @@ name = "figcraft-color"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FigCraft colour and paint: RGBA colours, HSB/HSL, hex/CSS parsing, solid/gradient/image paints, blend modes"
description = "FigCraft colour and paint: RGBA colours, HSB/HSL/LCH/OKLCH, hex/CSS parsing, solid/gradient/image paints, blend modes"
[lints]
workspace = true
+2 -1
View File
@@ -1,4 +1,5 @@
//! HSB (HSV) and HSL, the models the colour picker offers besides RGB and hex.
//! HSB (HSV) and HSL, two of the models the colour picker offers besides RGB and hex (LCH and
//! OKLCH are in `lch.rs`).
use crate::Color;
use serde::{Deserialize, Serialize};
+164
View File
@@ -0,0 +1,164 @@
//! CIE LCH (CSS `lch()`, Lab with a D50 white) and OKLCH (CSS `oklch()`), the perceptual models
//! the colour picker offers. Colours are read as sRGB, like the hex, RGB, HSL and HSB fields.
//!
//! The matrices are the ones CSS Color 4 publishes (sRGB ↔ XYZ D65, Bradford D65 ↔ D50) and
//! Björn Ottosson's published OKLab matrices.
use crate::Color;
use crate::profile::{apply as mul, decode, encode};
use serde::{Deserialize, Serialize};
/// CIE LCH: lightness 0..100, chroma 0.. (about 0..150 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Lch {
pub l: f32,
pub c: f32,
pub h: f32,
}
/// OKLCH: lightness 0..1, chroma 0.. (about 0..0.4 for real colours), hue 0..360.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Oklch {
pub l: f32,
pub c: f32,
pub h: f32,
}
type V3 = [f64; 3];
fn linear_rgb(c: Color) -> V3 {
[decode(c.r), decode(c.g), decode(c.b)]
}
const SRGB_TO_XYZ: [V3; 3] = [
[0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3],
[0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71],
[0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7],
];
const XYZ_TO_SRGB: [V3; 3] = [
[3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4],
[-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59],
[0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6],
];
const D65_TO_D50: [V3; 3] = [
[1.047_929_820_840_548_8, 0.022_946_793_341_019_088, -0.050_192_229_543_135_57],
[0.029_627_815_688_159_344, 0.990_434_484_573_249, -0.017_073_825_029_385_14],
[-0.009_243_058_152_591_178, 0.015_055_144_896_577_895, 0.751_874_289_958_000_8],
];
const D50_TO_D65: [V3; 3] = [
[0.955_473_452_704_218_2, -0.023_098_536_874_261_423, 0.063_259_308_661_021_7],
[-0.028_369_706_963_208_136, 1.009_995_458_005_822_6, 0.021_041_398_966_943_008],
[0.012_314_001_688_319_899, -0.020_507_696_433_477_912, 1.330_365_936_608_075_3],
];
const D50_WHITE: V3 = [0.3457 / 0.3585, 1.0, (1.0 - 0.3457 - 0.3585) / 0.3585];
const KAPPA: f64 = 24389.0 / 27.0;
const EPSILON: f64 = 216.0 / 24389.0;
const SRGB_TO_LMS: [V3; 3] = [
[0.412_221_470_8, 0.536_332_536_3, 0.051_445_992_9],
[0.211_903_498_2, 0.680_699_545_1, 0.107_396_956_6],
[0.088_302_461_9, 0.281_718_837_6, 0.629_978_700_5],
];
const LMS_TO_OKLAB: [V3; 3] = [
[0.210_454_255_3, 0.793_617_785_0, -0.004_072_046_8],
[1.977_998_495_1, -2.428_592_205_0, 0.450_593_709_9],
[0.025_904_037_1, 0.782_771_766_2, -0.808_675_766_0],
];
const OKLAB_TO_LMS: [V3; 3] =
[[1.0, 0.396_337_777_4, 0.215_803_757_3], [1.0, -0.105_561_345_8, -0.063_854_172_8], [1.0, -0.089_484_177_5, -1.291_485_548_0]];
const LMS_TO_SRGB: [V3; 3] = [
[4.076_741_662_1, -3.307_711_591_3, 0.230_969_929_2],
[-1.268_438_004_6, 2.609_757_401_1, -0.341_319_396_5],
[-0.004_196_086_3, -0.703_418_614_7, 1.707_614_701_0],
];
fn lab_from_color(c: Color) -> V3 {
let xyz = mul(&D65_TO_D50, mul(&SRGB_TO_XYZ, linear_rgb(c)));
let f = |v: f64| if v > EPSILON { v.cbrt() } else { (KAPPA * v + 16.0) / 116.0 };
let [fx, fy, fz] = [f(xyz[0] / D50_WHITE[0]), f(xyz[1] / D50_WHITE[1]), f(xyz[2] / D50_WHITE[2])];
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
}
fn linear_from_lab([l, a, b]: V3) -> V3 {
let fy = (l + 16.0) / 116.0;
let fx = fy + a / 500.0;
let fz = fy - b / 200.0;
let inv = |f: f64| if f.powi(3) > EPSILON { f.powi(3) } else { (116.0 * f - 16.0) / KAPPA };
let y = if l > KAPPA * EPSILON { fy.powi(3) } else { l / KAPPA };
let xyz = [inv(fx) * D50_WHITE[0], y * D50_WHITE[1], inv(fz) * D50_WHITE[2]];
mul(&XYZ_TO_SRGB, mul(&D50_TO_D65, xyz))
}
fn oklab_from_color(c: Color) -> V3 {
let lms = mul(&SRGB_TO_LMS, linear_rgb(c));
mul(&LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()])
}
fn linear_from_oklab(lab: V3) -> V3 {
let lms = mul(&OKLAB_TO_LMS, lab);
mul(&LMS_TO_SRGB, [lms[0].powi(3), lms[1].powi(3), lms[2].powi(3)])
}
/// Lab → (lightness, chroma, hue); grey has hue 0.
fn polar([l, a, b]: V3, grey: f64) -> (f32, f32, f32) {
let c = a.hypot(b);
let h = if c < grey { 0.0 } else { b.atan2(a).to_degrees().rem_euclid(360.0) };
(l as f32, c as f32, h as f32)
}
fn finite(v: f32) -> f64 {
if v.is_finite() { f64::from(v) } else { 0.0 }
}
fn in_gamut(rgb: V3) -> bool {
rgb.iter().all(|v| (-1e-6..=1.0 + 1e-6).contains(v))
}
/// The sRGB colour for a lightness, chroma and hue: out of gamut, the chroma comes down (a binary
/// search) until it fits, so lightness and hue stay what was asked for, as CSS gamut mapping does.
fn map_to_srgb(l: f64, c: f64, h: f64, to_linear_rgb: impl Fn(V3) -> V3) -> Color {
let (sin, cos) = h.to_radians().sin_cos();
let lab = |c: f64| [l, c * cos, c * sin];
let mut rgb = to_linear_rgb(lab(c));
if !in_gamut(rgb) {
let (mut lo, mut hi) = (0.0, c);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if in_gamut(to_linear_rgb(lab(mid))) {
lo = mid;
} else {
hi = mid;
}
}
rgb = to_linear_rgb(lab(lo));
}
Color::new(encode(rgb[0]), encode(rgb[1]), encode(rgb[2]), 1.0)
}
impl Lch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(lab_from_color(c), 0.02);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 100.0);
let c = finite(self.c).clamp(0.0, 230.0);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_lab)
}
}
impl Oklch {
pub fn from_color(c: Color) -> Self {
let (l, c, h) = polar(oklab_from_color(c), 0.000_2);
Self { l, c, h }
}
/// Opaque sRGB; out-of-gamut chroma is reduced to the nearest colour sRGB can show.
pub fn to_color(self) -> Color {
let l = finite(self.l).clamp(0.0, 1.0);
let c = finite(self.c).clamp(0.0, 0.5);
map_to_srgb(l, c, finite(self.h).rem_euclid(360.0), linear_from_oklab)
}
}
+105
View File
@@ -0,0 +1,105 @@
use super::*;
fn close(a: f32, b: f32, tol: f32) -> bool {
(a - b).abs() <= tol
}
#[test]
fn lch_matches_css_reference_values() {
// CSS Color 4 values for sRGB primaries and white (D50 Lab).
let red = Lch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 54.29, 0.05) && close(red.c, 106.84, 0.1) && close(red.h, 40.85, 0.1), "{red:?}");
let white = Lch::from_color(Color::WHITE);
assert!(close(white.l, 100.0, 0.01) && white.c < 0.05, "{white:?}");
assert_eq!(Lch::from_color(Color::BLACK).l.round(), 0.0);
}
#[test]
fn oklch_matches_reference_values() {
let red = Oklch::from_color(Color::from_hex("FF0000").unwrap());
assert!(close(red.l, 0.628, 0.001) && close(red.c, 0.2577, 0.001) && close(red.h, 29.23, 0.1), "{red:?}");
let white = Oklch::from_color(Color::WHITE);
assert!(close(white.l, 1.0, 0.001) && white.c < 0.001 && white.h == 0.0, "{white:?}");
}
#[test]
fn lch_and_oklch_round_trip_through_srgb() {
for hex in ["0D99FF", "FF0000", "00FF00", "0000FF", "FFFFFF", "000000", "808080", "663399", "F24E1E", "1ABC9C"] {
let c = Color::from_hex(hex).unwrap();
assert_eq!(Lch::from_color(c).to_color().to_hex(), hex, "{hex} via LCH");
assert_eq!(Oklch::from_color(c).to_color().to_hex(), hex, "{hex} via OKLCH");
}
}
#[test]
fn out_of_gamut_keeps_lightness_and_hue() {
// Far more chroma than sRGB holds: the chroma comes down, lightness and hue stay.
let asked = Oklch { l: 0.7, c: 0.45, h: 150.0 };
let got = Oklch::from_color(asked.to_color());
assert!(close(got.l, 0.7, 0.01) && close(got.h, 150.0, 1.5) && got.c < 0.45, "{got:?}");
let asked = Lch { l: 50.0, c: 200.0, h: 300.0 };
let got = Lch::from_color(asked.to_color());
assert!(close(got.l, 50.0, 1.0) && close(got.h, 300.0, 2.0) && got.c < 200.0, "{got:?}");
}
#[test]
fn junk_values_never_panic() {
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, -5.0, 0.0] {
let a = Lch { l: v, c: v, h: v }.to_color();
let b = Oklch { l: v, c: v, h: v }.to_color();
for x in [a.r, a.g, a.b, b.r, b.g, b.b] {
assert!(x.is_finite() && (0.0..=1.0).contains(&x));
}
}
let nan = Color { r: f32::NAN, g: 2.0, b: -1.0, a: 1.0 };
assert!(Lch::from_color(nan.sanitized()).l.is_finite());
assert!(Oklch::from_color(nan.sanitized()).l.is_finite());
}
#[test]
fn css_reads_lch_and_oklch() {
assert_eq!(Color::parse_css("oklch(62.8% 0.2577 29.23)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("oklch(0.628 0.2577 29.23deg / 50%)").unwrap().to_rgba8(), [255, 0, 0, 128]);
assert_eq!(Color::parse_css("lch(54.29 106.84 40.85)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("LCH(100% 0 0)").unwrap().to_hex(), "FFFFFF");
assert!(Color::parse_css("oklch(nan 0 0)").is_none());
assert!(Color::parse_css("lch(50)").is_none());
}
#[test]
fn css_hue_units_and_none() {
let blue = Color::parse_css("oklch(0.6 0.2 260)").unwrap();
for h in ["260deg", "0.7222222turn", "288.8888grad", "4.537856rad"] {
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
}
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
}
#[test]
fn css_lch_and_oklch_junk_is_refused_or_in_range() {
let long = "9".repeat(100_000);
let junk = [
"oklch()".to_string(),
"oklch(,,)".into(),
"lch(1 2 3 4 5)".into(),
"oklch(0.5 0.1 1e40)".into(),
"lch(inf 0 0)".into(),
"oklch(0.5 0.1 deg)".into(),
"lch(50 50 50 / / /)".into(),
"oklch(🟥 0 0)".into(),
"lch(\u{0} 0 0)".into(),
format!("oklch({long} {long} {long})"),
format!("lch(50 50 {long}turn)"),
];
for j in &junk {
if let Some(c) = Color::parse_css(j) {
for v in [c.r, c.g, c.b, c.a] {
assert!(v.is_finite() && (0.0..=1.0).contains(&v), "{j:.40}: {c:?}");
}
}
}
for j in ["oklch()", "lch(1 2 3 4 5)", "lch(inf 0 0)", "oklch(0.5 0.1 deg)", "oklch(🟥 0 0)", "oklch(0.5 0.1 1e40)"] {
assert!(Color::parse_css(j).is_none(), "{j}");
}
}
+55 -1
View File
@@ -11,10 +11,12 @@ use serde::{Deserialize, Serialize};
mod blend;
mod hsx;
mod icc;
mod lch;
mod named;
mod profile;
pub use blend::BlendMode;
pub use hsx::*;
pub use lch::{Lch, Oklch};
pub use profile::{ColorProfile, ProfileConversion};
/// A non-premultiplied sRGB colour with alpha, components in `0..=1`. Deserializes from
@@ -147,7 +149,8 @@ impl Color {
Self::from_hex(s).or_else(|| named::lookup(s).map(|[r, g, b]| Self::from_rgba8(r, g, b, 255)))
}
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, a CSS colour name or `transparent`.
/// Parse a CSS colour: hex, `rgb()/rgba()`, `hsl()/hsla()`, `lch()`, `oklch()`, a CSS colour
/// name or `transparent`. LCH and OKLCH outside sRGB come back as the nearest sRGB colour.
pub fn parse_css(s: &str) -> Option<Self> {
let t = s.trim().to_ascii_lowercase();
if t.starts_with('#') {
@@ -186,6 +189,15 @@ impl Color {
}
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
}
// Percentages per CSS Color 4: OKLCH 100% is lightness 1, chroma 0.4; LCH 100% is 100 and 150.
if let Some(inner) = t.strip_prefix("oklch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 1.0, 0.4)?;
return Some(Oklch { l, c, h }.to_color().with_alpha(alpha));
}
if let Some(inner) = t.strip_prefix("lch(").and_then(|r| r.strip_suffix(')')) {
let ([l, c, h], alpha) = css_polar(inner, 100.0, 150.0)?;
return Some(Lch { l, c, h }.to_color().with_alpha(alpha));
}
if t == "transparent" {
return Some(Color::TRANSPARENT);
}
@@ -233,6 +245,46 @@ fn css_args(inner: &str) -> Vec<String> {
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
}
/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of
/// `l_full` / `c_full`; `none` is 0.
fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let parts = css_args(inner);
if !(3..=4).contains(&parts.len()) {
return None;
}
let l = css_number(parts.first()?, l_full)?;
let c = css_number(parts.get(1)?, c_full)?;
let h = css_hue(parts.get(2)?)?;
let alpha = match parts.get(3).map(String::as_str) {
None | Some("none") => 1.0,
Some(a) => parse_alpha(a)?,
};
Some(([l, c, h], alpha))
}
/// A CSS number, a percentage of `full` or `none` (0); finite only.
fn css_number(p: &str, full: f32) -> Option<f32> {
let v = match p.strip_suffix('%') {
_ if p == "none" => 0.0,
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
None => p.parse::<f32>().ok()?,
};
v.is_finite().then_some(v)
}
/// A CSS hue in degrees: a number, `deg`, `rad`, `grad`, `turn` or `none` (0); finite only.
fn css_hue(p: &str) -> Option<f32> {
if p == "none" {
return Some(0.0);
}
let (num, per_degree) = [("grad", 400.0 / 360.0), ("turn", 1.0 / 360.0), ("rad", std::f32::consts::PI / 180.0), ("deg", 1.0)]
.into_iter()
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
.unwrap_or((p, 1.0));
let v = num.parse::<f32>().ok()? / per_degree;
v.is_finite().then_some(v)
}
fn parse_alpha(a: &str) -> Option<f32> {
if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }
}
@@ -671,6 +723,8 @@ impl Paint {
#[cfg(test)]
mod gradient_tests;
#[cfg(test)]
mod lch_tests;
#[cfg(test)]
mod pattern_tests;
#[cfg(test)]
mod profile_tests;
+2 -2
View File
@@ -172,13 +172,13 @@ fn outside(l: f64) -> bool {
const ENCODE_STEPS: usize = 4096;
/// The sRGB transfer function, encoded value → linear light.
fn decode(v: f32) -> f64 {
pub(crate) fn decode(v: f32) -> f64 {
let v = f64::from(if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 });
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
/// The sRGB transfer function, linear light → encoded value (clipped to 0..=1).
fn encode(l: f64) -> f32 {
pub(crate) fn encode(l: f64) -> f32 {
let l = if l.is_finite() { l.clamp(0.0, 1.0) } else { 0.0 };
(if l <= 0.003_130_8 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 }) as f32
}
+130 -34
View File
@@ -236,7 +236,7 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
}
/// Colour models the value row can show.
const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
const MODELS: [&str; 7] = ["Hex", "RGB", "HSL", "HSB", "LCH", "OKLCH", "CSS"];
/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the
/// edit is done (false while a field is being scrubbed).
@@ -248,7 +248,9 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.spacing_mut().item_spacing.x = 4.0;
let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), 52.0, None);
// "LCH" and "OKLCH" need a wider dropdown; their fields and alpha give up the room.
let wide = matches!(MODELS.get(model).copied(), Some("LCH" | "OKLCH"));
let resp = widgets::dropdown(ui, MODELS.get(model).copied().unwrap_or("Hex"), if wide { 66.0 } else { 52.0 }, None);
crate::right::menu_popup(&resp).show(|ui| {
for (i, m) in MODELS.iter().enumerate() {
if crate::right::menu_item(ui, m, "") {
@@ -257,25 +259,22 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
}
}
});
let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field(
ui,
Id::unique(("cp_f", id)),
widgets::Lead::None,
Some(v.round()),
"",
w,
1.0,
) {
Some(widgets::FieldEdit::Set(x)) => Some((x, true)),
Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)),
None => None,
// `decimals` places shown; `step` is what a scrub or arrow moves by.
let num = |ui: &mut Ui, id: &str, v: f64, w: f32, decimals: i32, step: f64| {
let p = 10f64.powi(decimals);
match widgets::number_field(ui, Id::unique(("cp_f", id)), widgets::Lead::None, Some((v * p).round() / p), "", w, step) {
Some(widgets::FieldEdit::Set(x)) => Some((x, true)),
Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)),
None => None,
}
};
match MODELS.get(model).copied().unwrap_or("Hex") {
let name = MODELS.get(model).copied().unwrap_or("Hex");
match name {
"RGB" => {
let [r, g, b, _] = c.to_rgba8();
let vals = [f64::from(r), f64::from(g), f64::from(b)];
for (k, v) in vals.iter().enumerate() {
if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0, 0, 1.0) {
done = end;
let mut rgb = vals;
if let Some(slot) = rgb.get_mut(k) {
@@ -285,28 +284,38 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
}
}
}
"HSL" | "HSB" => {
let hsl = model == 2;
let (h, s2, l2) = if hsl {
let x = figcraft_color::Hsl::from_color(c);
(x.h, x.s, x.l)
} else {
let x = Hsb::from_color(c);
(x.h, x.s, x.b)
};
let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0];
for (k, v) in vals.iter().enumerate() {
if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
"HSL" | "HSB" | "LCH" | "OKLCH" => {
let Some(spec) = Polar::of(name) else { return };
// The values last shown or typed stay while the colour is theirs: a chroma lowered to
// fit sRGB, or a hue on a grey, isn't read back from the colour on the next frame.
let kept_id = Id::unique("cp_polar");
let rgba = c.to_rgba8();
let vals = ui
.data(|d| d.get_temp::<(&'static str, [f64; 3], [u8; 4])>(kept_id))
.filter(|(m, _, k)| *m == name && *k == rgba)
.map_or_else(|| spec.values(c), |(_, v, _)| v);
let mut kept = vals;
for (k, (v, f)) in vals.iter().zip(spec.fields()).enumerate() {
let w = if !wide {
31.0
} else if f.decimals > 0 {
30.0
} else {
27.0
};
if let Some((x, end)) = num(ui, f.id, *v, w, f.decimals, f.step) {
done = end;
let mut v3 = vals;
if let Some(slot) = v3.get_mut(k) {
*slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) };
*slot = if f.wraps { x.rem_euclid(360.0) } else { x.clamp(0.0, f.max) };
}
let (h, a, b) = (v3[0] as f32, v3[1] as f32 / 100.0, v3[2] as f32 / 100.0);
let col = if hsl { figcraft_color::Hsl { h, s: a, l: b }.to_color() } else { Hsb { h, s: a, b }.to_color() };
out = Some(col.with_alpha(c.a));
let col = spec.color(v3).with_alpha(c.a);
kept = v3;
out = Some(col);
}
}
let shown = out.unwrap_or(c).to_rgba8();
ui.data_mut(|d| d.insert_temp(kept_id, (name, kept, shown)));
}
"CSS" => {
if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") {
@@ -321,7 +330,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
}
if MODELS.get(model) != Some(&"CSS") {
let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round());
let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", if wide { 40.0 } else { 44.0 }, 1.0) {
Some(widgets::FieldEdit::Set(v)) => Some((v, true)),
Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)),
None => None,
@@ -335,6 +344,93 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
out.map(|c| (c, done))
}
/// One field of a [`Polar`] model: its id, decimals shown, scrub/arrow step and range (0..=max,
/// or a hue that wraps at 360).
struct PolarField {
id: &'static str,
decimals: i32,
step: f64,
max: f64,
wraps: bool,
}
const fn field(id: &'static str, decimals: i32, step: f64, max: f64) -> PolarField {
PolarField { id, decimals, step, max, wraps: false }
}
const fn hue(id: &'static str) -> PolarField {
PolarField { id, decimals: 0, step: 1.0, max: 360.0, wraps: true }
}
/// The three-field models: HSL, HSB, LCH and OKLCH, shown in their CSS units (percentages for
/// saturation and lightness, OKLCH lightness in percent).
#[derive(Clone, Copy)]
enum Polar {
Hsl,
Hsb,
Lch,
Oklch,
}
impl Polar {
fn of(name: &str) -> Option<Self> {
Some(match name {
"HSL" => Self::Hsl,
"HSB" => Self::Hsb,
"LCH" => Self::Lch,
"OKLCH" => Self::Oklch,
_ => return None,
})
}
const HSL_FIELDS: [PolarField; 3] = [hue("hsl_h"), field("hsl_s", 0, 1.0, 100.0), field("hsl_l", 0, 1.0, 100.0)];
const HSB_FIELDS: [PolarField; 3] = [hue("hsb_h"), field("hsb_s", 0, 1.0, 100.0), field("hsb_b", 0, 1.0, 100.0)];
const LCH_FIELDS: [PolarField; 3] = [field("lch_l", 0, 1.0, 100.0), field("lch_c", 0, 1.0, 230.0), hue("lch_h")];
const OKLCH_FIELDS: [PolarField; 3] = [field("oklch_l", 0, 1.0, 100.0), field("oklch_c", 2, 0.01, 0.5), hue("oklch_h")];
const fn fields(self) -> &'static [PolarField; 3] {
match self {
Self::Hsl => &Self::HSL_FIELDS,
Self::Hsb => &Self::HSB_FIELDS,
Self::Lch => &Self::LCH_FIELDS,
Self::Oklch => &Self::OKLCH_FIELDS,
}
}
fn values(self, c: Color) -> [f64; 3] {
let v = match self {
Self::Hsl => {
let x = figcraft_color::Hsl::from_color(c);
[x.h, x.s * 100.0, x.l * 100.0]
}
Self::Hsb => {
let x = Hsb::from_color(c);
[x.h, x.s * 100.0, x.b * 100.0]
}
Self::Lch => {
let x = figcraft_color::Lch::from_color(c);
[x.l, x.c, x.h]
}
Self::Oklch => {
let x = figcraft_color::Oklch::from_color(c);
[x.l * 100.0, x.c, x.h]
}
};
v.map(f64::from)
}
/// Opaque.
fn color(self, v: [f64; 3]) -> Color {
let [a, b, d] = v.map(|v| v as f32);
match self {
Self::Hsl => figcraft_color::Hsl { h: a, s: b / 100.0, l: d / 100.0 }.to_color(),
Self::Hsb => Hsb { h: a, s: b / 100.0, b: d / 100.0 }.to_color(),
Self::Lch => figcraft_color::Lch { l: a, c: b, h: d }.to_color(),
Self::Oklch => figcraft_color::Oklch { l: a / 100.0, c: b, h: d }.to_color(),
}
}
}
fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color())
}
@@ -819,7 +915,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
changed = true;
done |= d;
}
// Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.
// Colour model fields (Hex, RGB, HSL, HSB, LCH, OKLCH or CSS) + alpha.
if let Some((c, d)) = model_fields(ui, nc) {
nc = c;
hsb = Hsb::from_color(nc);
+76
View File
@@ -494,6 +494,82 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo
/// step.
#[test]
fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() {
for (model, name, l) in [(4usize, "LCH", "lch_l"), (5, "OKLCH", "oklch_l")] {
let (mut u, a, _) = setup();
let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap();
u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#0D99FF"}]}));
let before = color(&u);
assert_eq!(before.to_hex(), "0D99FF");
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
for _ in 0..3 {
u.frame(vec![]);
}
let steps = undo_steps(&u);
let from = lead(&u, Id::unique(("cp_f", l)));
drag(&mut u, from, -20.0, Modifiers::NONE);
let after = color(&u);
let hue = |c| figcraft_color::Oklch::from_color(c).h;
assert!(after.luminance() < before.luminance(), "{name}: darker");
assert!((hue(after) - hue(before)).abs() < 3.0, "{name}: hue kept {} → {}", hue(before), hue(after));
assert_eq!(undo_steps(&u), steps + 1, "{name}: one undo step");
}
}
/// Type `text` into the picker field `id` (`("cp_f", id)`) and press Enter.
fn type_into_picker(u: &mut Ui, id: &str, text: &str) {
let rect = u.ctx.read_response(Id::unique(("cp_f", id)).with("field")).unwrap_or_else(|| panic!("no picker field {id}")).rect;
click(u, rect.center());
u.frame(vec![Event::Text(text.into())]);
key(u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
}
/// A picker on layer `a` in model `model` (an index into the model menu), drawn and settled.
fn open_picker(u: &mut Ui, a: NodeId, model: usize) {
u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model));
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
for _ in 0..3 {
u.frame(vec![]);
}
}
fn kept_polar(u: &Ui) -> [f64; 3] {
u.ctx.data(|d| d.get_temp::<(&'static str, [f64; 3], [u8; 4])>(Id::unique("cp_polar"))).unwrap().1
}
#[test]
fn oklch_keeps_a_hue_typed_on_a_grey() {
let (mut u, a, _) = setup();
let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap();
u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#808080"}]}));
open_picker(&mut u, a, 5);
type_into_picker(&mut u, "oklch_h", "250");
u.frame(vec![]);
assert_eq!(kept_polar(&u)[2].round(), 250.0, "the hue stays on a grey");
type_into_picker(&mut u, "oklch_c", "0.1");
let ok = figcraft_color::Oklch::from_color(color(&u));
assert!(ok.c > 0.05 && (ok.h - 250.0).abs() < 3.0, "chroma raised along the typed hue: {ok:?}");
}
#[test]
fn lch_keeps_the_typed_chroma_while_the_colour_is_out_of_srgb() {
let (mut u, a, _) = setup();
let color = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap();
u.app.set_props(&[a], json!({"fills": [{"type": "solid", "color": "#0D99FF"}]}));
open_picker(&mut u, a, 4);
let hue = kept_polar(&u)[2];
// Lightness near white: sRGB holds almost no chroma there, so the colour comes out near grey.
type_into_picker(&mut u, "lch_l", "98");
assert_eq!(kept_polar(&u)[2], hue, "hue kept near white");
// Back down: the chroma and hue asked for come back instead of a grey or a red.
type_into_picker(&mut u, "lch_l", "60");
let lch = figcraft_color::Lch::from_color(color(&u));
assert!(lch.c > 50.0 && (lch.h - hue as f32).abs() < 2.0, "blue again: {lch:?} (hue {hue})");
}
#[test]
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
let mut s = Session::new();
+1 -1
View File
@@ -15,7 +15,7 @@ replaced.
| Layer | Crate | Lines | Role |
|---|---|---|---|
| L0 | `figcraft-geom` | ~0.5k | Affine transforms, shape path builders (rounded and smoothed corners, polygons, stars, arcs) on kurbo |
| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, hex/CSS, Display P3), paints (solid, gradients, image), blend modes |
| L0 | `figcraft-color` | ~1.3k | Colours (HSB/HSL, LCH/OKLCH, hex/CSS, Display P3), paints (solid, gradients, image), blend modes |
| L1 | `figcraft-doc` | ~8.5k | Document model: pages, node tree in a persistent map (`imbl`, stable ids), properties, components, styles, variables, prototype data, comments, versions. Serde everywhere |
| L2 | `figcraft-layout` | ~1.8k | Auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max), constraints, layout grid geometry |
| L2 | `figcraft-text` | ~3.1k | Font database (optional embedded craft-fonts + system scan), shaping (harfrust), bidi, line layout, glyph outlines (skrifa), variable fonts |
+3 -1
View File
File diff suppressed because one or more lines are too long