Skip to main content

cranpose_ui_graphics/
color.rs

1//! Color representation and color space utilities
2
3#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct Color(pub f32, pub f32, pub f32, pub f32);
5
6impl Color {
7    pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
8        Self(r, g, b, 1.0)
9    }
10
11    pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
12        Self(r, g, b, a)
13    }
14
15    pub const fn from_rgba_u8(r: u8, g: u8, b: u8, a: u8) -> Self {
16        Self(
17            r as f32 / 255.0,
18            g as f32 / 255.0,
19            b as f32 / 255.0,
20            a as f32 / 255.0,
21        )
22    }
23
24    pub const fn from_rgb_u8(r: u8, g: u8, b: u8) -> Self {
25        Self::from_rgba_u8(r, g, b, 255)
26    }
27
28    pub fn r(&self) -> f32 {
29        self.0
30    }
31
32    pub fn g(&self) -> f32 {
33        self.1
34    }
35
36    pub fn b(&self) -> f32 {
37        self.2
38    }
39
40    pub fn a(&self) -> f32 {
41        self.3
42    }
43
44    pub fn with_alpha(&self, alpha: f32) -> Self {
45        Self(self.0, self.1, self.2, alpha)
46    }
47
48    /// This colour as the platform's colour type actually holds it: **eight
49    /// bits per channel**.
50    ///
51    /// `androidx.compose.ui.graphics.Color` is a float-shaped API over an
52    /// 8-bit value. Building one in the sRGB space snaps every channel on the
53    /// spot — the bytecode of `ColorKt.Color(float, float, float, float,
54    /// ColorSpace)` is `coerceIn(0f, 1f)`, `* 255.0f`, `+ 0.5f`, `f2i`, packed
55    /// into an ARGB int — so a colour an app computes in float is already a
56    /// whole channel value before anything paints with it, and the rasterizer
57    /// never sees the fraction.
58    ///
59    /// A renderer that carries the fraction to the framebuffer instead leaves
60    /// the rounding to whatever converts float to unorm there. That agrees
61    /// nearly everywhere and disagrees on an exact half. It is not a rare
62    /// shape: a theme that lerps between two byte colours lands on one
63    /// routinely — `mix(rail, background, 0.55)` puts a Wear settings capsule
64    /// at exactly 22.5/255 on green, where this rule gives 23 and a converter
65    /// that breaks ties to even gives 22. One level, and then the row's layer
66    /// alpha multiplies it and keeps it.
67    ///
68    /// Ties go **up**, which is what `(int)(x + 0.5f)`, Skia's
69    /// `SkScalarRoundToInt` and Rust's `f32::round` all give for a channel
70    /// clamped into 0..=1.
71    pub fn srgb_8bit(self) -> Self {
72        Self(
73            srgb_channel_8bit(self.0),
74            srgb_channel_8bit(self.1),
75            srgb_channel_8bit(self.2),
76            srgb_channel_8bit(self.3),
77        )
78    }
79
80    pub const BLACK: Color = Color(0.0, 0.0, 0.0, 1.0);
81    pub const WHITE: Color = Color(1.0, 1.0, 1.0, 1.0);
82    pub const RED: Color = Color(1.0, 0.0, 0.0, 1.0);
83    pub const GREEN: Color = Color(0.0, 1.0, 0.0, 1.0);
84    pub const BLUE: Color = Color(0.0, 0.0, 1.0, 1.0);
85    pub const TRANSPARENT: Color = Color(0.0, 0.0, 0.0, 0.0);
86}
87
88/// One channel snapped to the 8-bit value an sRGB colour holds.
89fn srgb_channel_8bit(channel: f32) -> f32 {
90    (channel.clamp(0.0, 1.0) * 255.0).round() / 255.0
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn an_srgb_colour_is_eight_bits_and_a_half_rounds_up() {
99        let mixed = Color(9.9 / 255.0, 22.5 / 255.0, 34.2 / 255.0, 1.0);
100        let snapped = mixed.srgb_8bit();
101        assert_eq!(
102            [
103                (snapped.0 * 255.0).round() as u8,
104                (snapped.1 * 255.0).round() as u8,
105                (snapped.2 * 255.0).round() as u8,
106            ],
107            [10, 23, 34]
108        );
109        for channel in [snapped.0, snapped.1, snapped.2, snapped.3] {
110            let scaled = channel * 255.0;
111            assert!(
112                (scaled - scaled.round()).abs() < 1e-3,
113                "{scaled} is not a whole channel value"
114            );
115        }
116    }
117
118    #[test]
119    fn snapping_matches_the_platforms_own_expression_over_the_whole_range() {
120        for step in 0..=100_000u32 {
121            let channel = step as f32 / 100_000.0;
122            let platform = (channel * 255.0 + 0.5) as u32;
123            let ours = (srgb_channel_8bit(channel) * 255.0).round() as u32;
124            assert_eq!(platform, ours, "channel {channel}");
125        }
126    }
127
128    #[test]
129    fn snapping_is_idempotent_and_leaves_exact_bytes_alone() {
130        for byte in 0..=255u8 {
131            let colour = Color::from_rgba_u8(byte, byte, byte, byte);
132            assert_eq!(colour.srgb_8bit(), colour);
133        }
134        let odd = Color(0.123_456, 0.789_012, 0.5, 0.25);
135        assert_eq!(odd.srgb_8bit().srgb_8bit(), odd.srgb_8bit());
136    }
137
138    #[test]
139    fn snapping_clamps_out_of_range_channels() {
140        let wild = Color(-2.0, 1.5, 0.0, 1.0).srgb_8bit();
141        assert_eq!(wild, Color(0.0, 1.0, 0.0, 1.0));
142    }
143}