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