Skip to main content

cranpose_ui_graphics/
image.rs

1//! Image bitmap primitives used by render backends.
2
3use crate::{BlendMode, Color, Size};
4use std::collections::hash_map::DefaultHasher;
5use std::hash::{Hash, Hasher};
6use std::sync::Arc;
7use thiserror::Error;
8
9/// Errors returned while constructing an [`ImageBitmap`].
10#[derive(Debug, Clone, PartialEq, Eq, Error)]
11pub enum ImageBitmapError {
12    #[error("image dimensions must be greater than zero")]
13    InvalidDimensions,
14    #[error("image dimensions are too large")]
15    DimensionsTooLarge,
16    #[error("pixel data length mismatch: expected {expected} bytes, got {actual}")]
17    PixelDataLengthMismatch { expected: usize, actual: usize },
18}
19
20/// Immutable RGBA image data used by UI primitives and render backends.
21#[derive(Clone, Debug)]
22pub struct ImageBitmap {
23    width: u32,
24    height: u32,
25    id: u64,
26    opaque: bool,
27    pixels: Arc<[u8]>,
28}
29
30/// Texture sampling mode for image primitives.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
32pub enum ImageSampling {
33    /// Preserve source texels exactly. Use this for atlases, pixel art, and UI skins.
34    #[default]
35    Nearest,
36    /// Interpolate adjacent texels. Use this for photographic or continuously scaled images.
37    Linear,
38}
39
40/// Simple image color filter model.
41#[derive(Clone, Copy, Debug, PartialEq)]
42pub enum ColorFilter {
43    /// Compose-style tint using `BlendMode::SrcIn`.
44    Tint(Color),
45    /// Explicit per-channel modulation (multiply behavior).
46    Modulate(Color),
47    /// 4x5 color matrix in row-major order.
48    ///
49    /// Rows map output RGBA channels, columns map input RGBA plus constant term:
50    /// `out = M * [r, g, b, a, 1]`.
51    Matrix([f32; 20]),
52}
53
54impl ColorFilter {
55    /// Creates a Compose-style tint filter (`SrcIn`).
56    pub fn tint(color: Color) -> Self {
57        Self::Tint(color)
58    }
59
60    /// Creates an explicit modulation filter that multiplies channels by `color`.
61    pub fn modulate(color: Color) -> Self {
62        Self::Modulate(color)
63    }
64
65    /// Creates a filter from a 4x5 color matrix.
66    pub fn matrix(matrix: [f32; 20]) -> Self {
67        Self::Matrix(matrix)
68    }
69
70    pub fn compose(self, next: ColorFilter) -> ColorFilter {
71        ColorFilter::Matrix(compose_color_matrices(self.as_matrix(), next.as_matrix()))
72    }
73
74    pub fn as_matrix(self) -> [f32; 20] {
75        match self {
76            Self::Tint(tint) => [
77                0.0,
78                0.0,
79                0.0,
80                tint.r(),
81                0.0, // R' = A * tint.r
82                0.0,
83                0.0,
84                0.0,
85                tint.g(),
86                0.0, // G' = A * tint.g
87                0.0,
88                0.0,
89                0.0,
90                tint.b(),
91                0.0, // B' = A * tint.b
92                0.0,
93                0.0,
94                0.0,
95                tint.a(),
96                0.0, // A' = A * tint.a
97            ],
98            Self::Modulate(modulate) => [
99                modulate.r(),
100                0.0,
101                0.0,
102                0.0,
103                0.0, // R' = R * modulate.r
104                0.0,
105                modulate.g(),
106                0.0,
107                0.0,
108                0.0, // G' = G * modulate.g
109                0.0,
110                0.0,
111                modulate.b(),
112                0.0,
113                0.0, // B' = B * modulate.b
114                0.0,
115                0.0,
116                0.0,
117                modulate.a(),
118                0.0, // A' = A * modulate.a
119            ],
120            Self::Matrix(matrix) => matrix,
121        }
122    }
123
124    pub fn apply_rgba(self, rgba: [f32; 4]) -> [f32; 4] {
125        apply_color_matrix(self.as_matrix(), rgba)
126    }
127
128    pub fn supports_gpu_vertex_modulation(self) -> bool {
129        matches!(self, Self::Modulate(_))
130    }
131
132    pub fn gpu_vertex_tint(self) -> Option<[f32; 4]> {
133        match self {
134            Self::Modulate(tint) => Some([tint.r(), tint.g(), tint.b(), tint.a()]),
135            _ => None,
136        }
137    }
138
139    pub fn blend_mode(self) -> BlendMode {
140        match self {
141            Self::Tint(_) => BlendMode::SrcIn,
142            Self::Modulate(_) => BlendMode::Modulate,
143            Self::Matrix(_) => BlendMode::SrcOver,
144        }
145    }
146}
147
148fn apply_color_matrix(matrix: [f32; 20], rgba: [f32; 4]) -> [f32; 4] {
149    let r = rgba[0];
150    let g = rgba[1];
151    let b = rgba[2];
152    let a = rgba[3];
153    [
154        (matrix[0] * r + matrix[1] * g + matrix[2] * b + matrix[3] * a + matrix[4]).clamp(0.0, 1.0),
155        (matrix[5] * r + matrix[6] * g + matrix[7] * b + matrix[8] * a + matrix[9]).clamp(0.0, 1.0),
156        (matrix[10] * r + matrix[11] * g + matrix[12] * b + matrix[13] * a + matrix[14])
157            .clamp(0.0, 1.0),
158        (matrix[15] * r + matrix[16] * g + matrix[17] * b + matrix[18] * a + matrix[19])
159            .clamp(0.0, 1.0),
160    ]
161}
162
163fn compose_color_matrices(first: [f32; 20], second: [f32; 20]) -> [f32; 20] {
164    let mut composed = [0.0f32; 20];
165    for row in 0..4 {
166        let row_base = row * 5;
167        let s0 = second[row_base];
168        let s1 = second[row_base + 1];
169        let s2 = second[row_base + 2];
170        let s3 = second[row_base + 3];
171        let s4 = second[row_base + 4];
172
173        composed[row_base] = s0 * first[0] + s1 * first[5] + s2 * first[10] + s3 * first[15];
174        composed[row_base + 1] = s0 * first[1] + s1 * first[6] + s2 * first[11] + s3 * first[16];
175        composed[row_base + 2] = s0 * first[2] + s1 * first[7] + s2 * first[12] + s3 * first[17];
176        composed[row_base + 3] = s0 * first[3] + s1 * first[8] + s2 * first[13] + s3 * first[18];
177        composed[row_base + 4] =
178            s0 * first[4] + s1 * first[9] + s2 * first[14] + s3 * first[19] + s4;
179    }
180    composed
181}
182
183impl ImageBitmap {
184    /// Creates a bitmap from tightly packed RGBA8 pixels.
185    pub fn from_rgba8(width: u32, height: u32, pixels: Vec<u8>) -> Result<Self, ImageBitmapError> {
186        Self::from_rgba8_slice(width, height, &pixels)
187    }
188
189    /// Creates a bitmap from tightly packed RGBA8 pixels.
190    pub fn from_rgba8_slice(
191        width: u32,
192        height: u32,
193        pixels: &[u8],
194    ) -> Result<Self, ImageBitmapError> {
195        if width == 0 || height == 0 {
196            return Err(ImageBitmapError::InvalidDimensions);
197        }
198        let expected = (width as usize)
199            .checked_mul(height as usize)
200            .and_then(|value| value.checked_mul(4))
201            .ok_or(ImageBitmapError::DimensionsTooLarge)?;
202
203        if pixels.len() != expected {
204            return Err(ImageBitmapError::PixelDataLengthMismatch {
205                expected,
206                actual: pixels.len(),
207            });
208        }
209
210        let id = bitmap_content_id(width, height, pixels);
211        let opaque = pixels.chunks_exact(4).all(|pixel| pixel[3] == u8::MAX);
212        Ok(Self {
213            width,
214            height,
215            id,
216            opaque,
217            pixels: Arc::from(pixels),
218        })
219    }
220
221    /// Content-derived bitmap identity used by renderer caches.
222    pub fn id(&self) -> u64 {
223        self.id
224    }
225
226    /// Width in pixels.
227    pub fn width(&self) -> u32 {
228        self.width
229    }
230
231    /// Height in pixels.
232    pub fn height(&self) -> u32 {
233        self.height
234    }
235
236    /// Returns the raw RGBA8 pixel data.
237    pub fn pixels(&self) -> &[u8] {
238        &self.pixels
239    }
240
241    /// Returns true when every source pixel has full alpha.
242    pub fn is_opaque(&self) -> bool {
243        self.opaque
244    }
245
246    /// Returns intrinsic size in logical units.
247    pub fn intrinsic_size(&self) -> Size {
248        Size {
249            width: self.width as f32,
250            height: self.height as f32,
251        }
252    }
253}
254
255impl PartialEq for ImageBitmap {
256    fn eq(&self, other: &Self) -> bool {
257        self.id() == other.id()
258    }
259}
260
261impl Eq for ImageBitmap {}
262
263impl Hash for ImageBitmap {
264    fn hash<H: Hasher>(&self, state: &mut H) {
265        self.id().hash(state);
266    }
267}
268
269fn bitmap_content_id(width: u32, height: u32, pixels: &[u8]) -> u64 {
270    let mut hasher = DefaultHasher::new();
271    width.hash(&mut hasher);
272    height.hash(&mut hasher);
273    pixels.hash(&mut hasher);
274    hasher.finish()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn image_bitmap_ids_do_not_use_process_global_or_allocation_identity() {
283        let source = include_str!("image.rs");
284        let image_counter = ["static ", "NEXT_IMAGE_BITMAP_ID"].concat();
285        let pixel_pointer = ["Arc::", "as_ptr(&self.pixels)"].concat();
286
287        assert!(
288            !source.contains(&image_counter) && !source.contains(&pixel_pointer),
289            "image bitmap ids must be derived from bitmap content, not global counters or allocation addresses"
290        );
291    }
292
293    #[test]
294    fn from_rgba8_accepts_valid_data() {
295        let bitmap = ImageBitmap::from_rgba8(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 255])
296            .expect("valid bitmap");
297
298        assert_eq!(bitmap.width(), 2);
299        assert_eq!(bitmap.height(), 1);
300        assert_eq!(bitmap.pixels().len(), 8);
301        assert!(bitmap.is_opaque());
302    }
303
304    #[test]
305    fn from_rgba8_tracks_transparency() {
306        let bitmap = ImageBitmap::from_rgba8(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 128])
307            .expect("valid bitmap");
308
309        assert!(!bitmap.is_opaque());
310    }
311
312    #[test]
313    fn from_rgba8_rejects_zero_dimensions() {
314        let err = ImageBitmap::from_rgba8(0, 2, vec![]).expect_err("must fail");
315        assert_eq!(err, ImageBitmapError::InvalidDimensions);
316    }
317
318    #[test]
319    fn from_rgba8_rejects_wrong_pixel_length() {
320        let err = ImageBitmap::from_rgba8(2, 2, vec![0; 15]).expect_err("must fail");
321        assert_eq!(
322            err,
323            ImageBitmapError::PixelDataLengthMismatch {
324                expected: 16,
325                actual: 15,
326            }
327        );
328    }
329
330    #[test]
331    fn from_rgba8_slice_accepts_valid_data() {
332        let pixels = [255u8, 0, 0, 255];
333        let bitmap = ImageBitmap::from_rgba8_slice(1, 1, &pixels).expect("valid bitmap");
334        assert_eq!(bitmap.pixels(), &pixels);
335    }
336
337    #[test]
338    fn ids_are_content_derived() {
339        let a = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255]).expect("bitmap a");
340        let a_clone = a.clone();
341        let b = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255]).expect("bitmap b");
342        let c = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 1, 255]).expect("bitmap c");
343        let d = ImageBitmap::from_rgba8(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 255]).expect("bitmap d");
344
345        assert_eq!(a.id(), a_clone.id());
346        assert_eq!(a.id(), b.id());
347        assert_ne!(a.id(), c.id());
348        assert_ne!(a.id(), d.id());
349    }
350
351    #[test]
352    fn intrinsic_size_matches_dimensions() {
353        let bitmap = ImageBitmap::from_rgba8(3, 4, vec![255; 3 * 4 * 4]).expect("bitmap");
354        assert_eq!(bitmap.intrinsic_size(), Size::new(3.0, 4.0));
355    }
356
357    #[test]
358    fn tint_filter_multiplies_channels() {
359        let filter = ColorFilter::modulate(Color::from_rgba_u8(128, 255, 64, 128));
360        let tinted = filter.apply_rgba([1.0, 0.5, 1.0, 1.0]);
361        assert!((tinted[0] - (128.0 / 255.0)).abs() < 1e-5);
362        assert!((tinted[1] - 0.5).abs() < 1e-5);
363        assert!((tinted[2] - (64.0 / 255.0)).abs() < 1e-5);
364        assert!((tinted[3] - (128.0 / 255.0)).abs() < 1e-5);
365    }
366
367    #[test]
368    fn tint_constructor_matches_variant() {
369        let color = Color::from_rgba_u8(10, 20, 30, 40);
370        assert_eq!(ColorFilter::tint(color), ColorFilter::Tint(color));
371    }
372
373    #[test]
374    fn tint_filter_uses_src_in_behavior() {
375        let filter = ColorFilter::tint(Color::from_rgba_u8(255, 128, 0, 128));
376        let tinted = filter.apply_rgba([0.2, 0.4, 0.8, 0.25]);
377        assert!((tinted[0] - 0.25).abs() < 1e-5);
378        assert!((tinted[1] - (0.25 * 128.0 / 255.0)).abs() < 1e-5);
379        assert!(tinted[2].abs() < 1e-5);
380        assert!((tinted[3] - (0.25 * 128.0 / 255.0)).abs() < 1e-5);
381    }
382
383    #[test]
384    fn matrix_filter_transforms_channels() {
385        let matrix = [
386            1.0, 0.0, 0.0, 0.0, 0.1, // R + 0.1
387            0.0, 0.5, 0.0, 0.0, 0.0, // G * 0.5
388            0.0, 0.0, 0.0, 1.0, 0.0, // A -> B
389            0.0, 0.0, 0.0, 1.0, 0.0, // A passthrough
390        ];
391        let filter = ColorFilter::matrix(matrix);
392        let transformed = filter.apply_rgba([0.2, 0.6, 0.9, 0.4]);
393        assert!((transformed[0] - 0.3).abs() < 1e-5);
394        assert!((transformed[1] - 0.3).abs() < 1e-5);
395        assert!((transformed[2] - 0.4).abs() < 1e-5);
396        assert!((transformed[3] - 0.4).abs() < 1e-5);
397    }
398
399    #[test]
400    fn filter_compose_applies_in_order() {
401        let first = ColorFilter::modulate(Color::from_rgba_u8(128, 255, 255, 255));
402        let second = ColorFilter::tint(Color::from_rgba_u8(255, 0, 0, 255));
403        let chained = first.compose(second);
404        let direct_second = second.apply_rgba(first.apply_rgba([0.8, 0.4, 0.2, 0.5]));
405        let composed = chained.apply_rgba([0.8, 0.4, 0.2, 0.5]);
406        assert!((direct_second[0] - composed[0]).abs() < 1e-5);
407        assert!((direct_second[1] - composed[1]).abs() < 1e-5);
408        assert!((direct_second[2] - composed[2]).abs() < 1e-5);
409        assert!((direct_second[3] - composed[3]).abs() < 1e-5);
410    }
411}