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    // Common color constants
81    pub const BLACK: Color = Color(0.0, 0.0, 0.0, 1.0);
82    pub const WHITE: Color = Color(1.0, 1.0, 1.0, 1.0);
83    pub const RED: Color = Color(1.0, 0.0, 0.0, 1.0);
84    pub const GREEN: Color = Color(0.0, 1.0, 0.0, 1.0);
85    pub const BLUE: Color = Color(0.0, 0.0, 1.0, 1.0);
86    pub const TRANSPARENT: Color = Color(0.0, 0.0, 0.0, 0.0);
87}
88
89/// One channel snapped to the 8-bit value an sRGB colour holds.
90fn srgb_channel_8bit(channel: f32) -> f32 {
91    (channel.clamp(0.0, 1.0) * 255.0).round() / 255.0
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn an_srgb_colour_is_eight_bits_and_a_half_rounds_up() {
100        // The Wear settings capsule: mix(rail, background, 0.55) is exactly
101        // 22.5/255 on green, and the platform keeps 23.
102        let mixed = Color(9.9 / 255.0, 22.5 / 255.0, 34.2 / 255.0, 1.0);
103        let snapped = mixed.srgb_8bit();
104        assert_eq!(
105            [
106                (snapped.0 * 255.0).round() as u8,
107                (snapped.1 * 255.0).round() as u8,
108                (snapped.2 * 255.0).round() as u8,
109            ],
110            [10, 23, 34]
111        );
112        // And the snapped value really is a whole channel value, not merely
113        // one that rounds back to it: nothing downstream has a tie left to
114        // break.
115        for channel in [snapped.0, snapped.1, snapped.2, snapped.3] {
116            let scaled = channel * 255.0;
117            assert!(
118                (scaled - scaled.round()).abs() < 1e-3,
119                "{scaled} is not a whole channel value"
120            );
121        }
122    }
123
124    #[test]
125    fn snapping_matches_the_platforms_own_expression_over_the_whole_range() {
126        // Compose computes `(int)(c * 255f + 0.5f)`; this uses `round`. They
127        // agree for every channel a colour can hold, ties included, and the
128        // sweep is what says so rather than an argument about float formats.
129        for step in 0..=100_000u32 {
130            let channel = step as f32 / 100_000.0;
131            let platform = (channel * 255.0 + 0.5) as u32;
132            let ours = (srgb_channel_8bit(channel) * 255.0).round() as u32;
133            assert_eq!(platform, ours, "channel {channel}");
134        }
135    }
136
137    #[test]
138    fn snapping_is_idempotent_and_leaves_exact_bytes_alone() {
139        for byte in 0..=255u8 {
140            let colour = Color::from_rgba_u8(byte, byte, byte, byte);
141            assert_eq!(colour.srgb_8bit(), colour);
142        }
143        let odd = Color(0.123_456, 0.789_012, 0.5, 0.25);
144        assert_eq!(odd.srgb_8bit().srgb_8bit(), odd.srgb_8bit());
145    }
146
147    #[test]
148    fn snapping_clamps_out_of_range_channels() {
149        let wild = Color(-2.0, 1.5, 0.0, 1.0).srgb_8bit();
150        assert_eq!(wild, Color(0.0, 1.0, 0.0, 1.0));
151    }
152}