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, // R' = A * tint.r
86                0.0,
87                0.0,
88                0.0,
89                tint.g(),
90                0.0, // G' = A * tint.g
91                0.0,
92                0.0,
93                0.0,
94                tint.b(),
95                0.0, // B' = A * tint.b
96                0.0,
97                0.0,
98                0.0,
99                tint.a(),
100                0.0, // A' = A * tint.a
101            ],
102            Self::Modulate(modulate) => [
103                modulate.r(),
104                0.0,
105                0.0,
106                0.0,
107                0.0, // R' = R * modulate.r
108                0.0,
109                modulate.g(),
110                0.0,
111                0.0,
112                0.0, // G' = G * modulate.g
113                0.0,
114                0.0,
115                modulate.b(),
116                0.0,
117                0.0, // B' = B * modulate.b
118                0.0,
119                0.0,
120                0.0,
121                modulate.a(),
122                0.0, // A' = A * modulate.a
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    /// Every filter has to be expressible as one 4x5 matrix, because that is
290    /// the only form composing two of them can work in. A tint that came out
291    /// as something other than "take the alpha, paint it in this colour" would
292    /// tint composed content differently from tinting it directly.
293    #[test]
294    fn every_colour_filter_states_itself_as_a_matrix() {
295        let identity = ColorFilter::Matrix([
296            1.0, 0.0, 0.0, 0.0, 0.0, //
297            0.0, 1.0, 0.0, 0.0, 0.0, //
298            0.0, 0.0, 1.0, 0.0, 0.0, //
299            0.0, 0.0, 0.0, 1.0, 0.0,
300        ]);
301        assert_eq!(
302            ColorFilter::matrix(identity.as_matrix()).as_matrix(),
303            identity.as_matrix(),
304            "a matrix filter is its own matrix"
305        );
306
307        // A tint reads the source alpha only: every colour column is zero and
308        // the alpha column carries the tint.
309        let tint = Color(0.25, 0.5, 0.75, 1.0);
310        let matrix = ColorFilter::tint(tint).as_matrix();
311        for row in 0..4 {
312            for column in 0..3 {
313                assert_eq!(matrix[row * 5 + column], 0.0, "row {row} column {column}");
314            }
315            assert_eq!(matrix[row * 5 + 4], 0.0, "row {row} offset");
316        }
317        assert_eq!(matrix[3], tint.r());
318        assert_eq!(matrix[8], tint.g());
319        assert_eq!(matrix[13], tint.b());
320        assert_eq!(matrix[18], tint.a());
321
322        // A modulation scales each channel by its own factor instead, which is
323        // the diagonal.
324        let modulate = ColorFilter::modulate(Color(0.5, 0.25, 0.125, 1.0)).as_matrix();
325        assert_eq!(modulate[0], 0.5);
326        assert_eq!(modulate[6], 0.25);
327        assert_eq!(modulate[12], 0.125);
328        assert_eq!(modulate[18], 1.0);
329    }
330
331    #[test]
332    fn image_bitmap_ids_do_not_use_process_global_or_allocation_identity() {
333        let source = include_str!("image.rs");
334        let image_counter = ["static ", "NEXT_IMAGE_BITMAP_ID"].concat();
335        let pixel_pointer = ["Arc::", "as_ptr(&self.pixels)"].concat();
336
337        assert!(
338            !source.contains(&image_counter) && !source.contains(&pixel_pointer),
339            "image bitmap ids must be derived from bitmap content, not global counters or allocation addresses"
340        );
341    }
342
343    #[test]
344    fn from_rgba8_accepts_valid_data() {
345        let bitmap = ImageBitmap::from_rgba8(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 255])
346            .expect("valid bitmap");
347
348        assert_eq!(bitmap.width(), 2);
349        assert_eq!(bitmap.height(), 1);
350        assert_eq!(bitmap.pixels().len(), 8);
351        assert!(bitmap.is_opaque());
352    }
353
354    #[test]
355    fn from_rgba8_tracks_transparency() {
356        let bitmap = ImageBitmap::from_rgba8(2, 1, vec![255, 0, 0, 255, 0, 255, 0, 128])
357            .expect("valid bitmap");
358
359        assert!(!bitmap.is_opaque());
360    }
361
362    #[test]
363    fn from_rgba8_rejects_zero_dimensions() {
364        let err = ImageBitmap::from_rgba8(0, 2, vec![]).expect_err("must fail");
365        assert_eq!(err, ImageBitmapError::InvalidDimensions);
366    }
367
368    #[test]
369    fn from_rgba8_rejects_wrong_pixel_length() {
370        let err = ImageBitmap::from_rgba8(2, 2, vec![0; 15]).expect_err("must fail");
371        assert_eq!(
372            err,
373            ImageBitmapError::PixelDataLengthMismatch {
374                expected: 16,
375                actual: 15,
376            }
377        );
378    }
379
380    #[test]
381    fn from_rgba8_slice_accepts_valid_data() {
382        let pixels = [255u8, 0, 0, 255];
383        let bitmap = ImageBitmap::from_rgba8_slice(1, 1, &pixels).expect("valid bitmap");
384        assert_eq!(bitmap.pixels(), &pixels);
385    }
386
387    #[test]
388    fn ids_are_content_derived() {
389        let a = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255]).expect("bitmap a");
390        let a_clone = a.clone();
391        let b = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255]).expect("bitmap b");
392        let c = ImageBitmap::from_rgba8(1, 1, vec![0, 0, 1, 255]).expect("bitmap c");
393        let d = ImageBitmap::from_rgba8(2, 1, vec![0, 0, 0, 255, 0, 0, 0, 255]).expect("bitmap d");
394
395        assert_eq!(a.id(), a_clone.id());
396        assert_eq!(a.id(), b.id());
397        assert_ne!(a.id(), c.id());
398        assert_ne!(a.id(), d.id());
399    }
400
401    #[test]
402    fn intrinsic_size_matches_dimensions() {
403        let bitmap = ImageBitmap::from_rgba8(3, 4, vec![255; 3 * 4 * 4]).expect("bitmap");
404        assert_eq!(bitmap.intrinsic_size(), Size::new(3.0, 4.0));
405    }
406
407    #[test]
408    fn tint_filter_multiplies_channels() {
409        let filter = ColorFilter::modulate(Color::from_rgba_u8(128, 255, 64, 128));
410        let tinted = filter.apply_rgba([1.0, 0.5, 1.0, 1.0]);
411        assert!((tinted[0] - (128.0 / 255.0)).abs() < 1e-5);
412        assert!((tinted[1] - 0.5).abs() < 1e-5);
413        assert!((tinted[2] - (64.0 / 255.0)).abs() < 1e-5);
414        assert!((tinted[3] - (128.0 / 255.0)).abs() < 1e-5);
415    }
416
417    #[test]
418    fn tint_constructor_matches_variant() {
419        let color = Color::from_rgba_u8(10, 20, 30, 40);
420        assert_eq!(ColorFilter::tint(color), ColorFilter::Tint(color));
421    }
422
423    #[test]
424    fn tint_filter_uses_src_in_behavior() {
425        let filter = ColorFilter::tint(Color::from_rgba_u8(255, 128, 0, 128));
426        let tinted = filter.apply_rgba([0.2, 0.4, 0.8, 0.25]);
427        assert!((tinted[0] - 0.25).abs() < 1e-5);
428        assert!((tinted[1] - (0.25 * 128.0 / 255.0)).abs() < 1e-5);
429        assert!(tinted[2].abs() < 1e-5);
430        assert!((tinted[3] - (0.25 * 128.0 / 255.0)).abs() < 1e-5);
431    }
432
433    #[test]
434    fn matrix_filter_transforms_channels() {
435        let matrix = [
436            1.0, 0.0, 0.0, 0.0, 0.1, // R + 0.1
437            0.0, 0.5, 0.0, 0.0, 0.0, // G * 0.5
438            0.0, 0.0, 0.0, 1.0, 0.0, // A -> B
439            0.0, 0.0, 0.0, 1.0, 0.0, // A passthrough
440        ];
441        let filter = ColorFilter::matrix(matrix);
442        let transformed = filter.apply_rgba([0.2, 0.6, 0.9, 0.4]);
443        assert!((transformed[0] - 0.3).abs() < 1e-5);
444        assert!((transformed[1] - 0.3).abs() < 1e-5);
445        assert!((transformed[2] - 0.4).abs() < 1e-5);
446        assert!((transformed[3] - 0.4).abs() < 1e-5);
447    }
448
449    #[test]
450    fn filter_compose_applies_in_order() {
451        let first = ColorFilter::modulate(Color::from_rgba_u8(128, 255, 255, 255));
452        let second = ColorFilter::tint(Color::from_rgba_u8(255, 0, 0, 255));
453        let chained = first.compose(second);
454        let direct_second = second.apply_rgba(first.apply_rgba([0.8, 0.4, 0.2, 0.5]));
455        let composed = chained.apply_rgba([0.8, 0.4, 0.2, 0.5]);
456        assert!((direct_second[0] - composed[0]).abs() < 1e-5);
457        assert!((direct_second[1] - composed[1]).abs() < 1e-5);
458        assert!((direct_second[2] - composed[2]).abs() < 1e-5);
459        assert!((direct_second[3] - composed[3]).abs() < 1e-5);
460    }
461}