Skip to main content

cranpose_ui_graphics/
image.rs

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