Skip to main content

aetna_core/
image.rs

1//! App-supplied raster images.
2//!
3//! Apps construct an [`Image`] once (typically as a `LazyLock` over a
4//! decoded byte slice) and embed it in the tree via the [`crate::image`]
5//! builder. Identity is content-hashed: two `Image`s built from the same
6//! pixels share a backend texture-cache slot. Cloning is a cheap `Arc`
7//! bump.
8//!
9//! ```
10//! use std::sync::LazyLock;
11//! use aetna_core::prelude::*;
12//!
13//! static AVATAR: LazyLock<Image> = LazyLock::new(|| {
14//!     // 2x2 RGBA8 placeholder. Real apps decode PNG/JPEG once in
15//!     // their LazyLock body — `aetna-core` deliberately does not pull
16//!     // in image-decoding crates.
17//!     Image::from_rgba8(
18//!         2, 2,
19//!         vec![
20//!             0xff, 0x00, 0x00, 0xff,  0x00, 0xff, 0x00, 0xff,
21//!             0x00, 0x00, 0xff, 0xff,  0xff, 0xff, 0xff, 0xff,
22//!         ],
23//!     )
24//! });
25//!
26//! fn cell() -> El {
27//!     image(AVATAR.clone()).image_fit(ImageFit::Cover).radius(8.0)
28//! }
29//! ```
30//!
31//! Decoding (`png`, `jpeg`, etc.) is intentionally the app's
32//! responsibility — keeps `aetna-core` free of heavy media deps and
33//! lets each app pick its own decoder + colour-space pipeline.
34
35use std::collections::hash_map::DefaultHasher;
36use std::hash::{Hash, Hasher};
37use std::sync::Arc;
38
39use crate::tree::Rect;
40
41/// A raster image. RGBA8 pixels, top-left origin, row-major. Cheap
42/// `Arc`-backed clone; backends key their texture cache off
43/// [`Self::content_hash`] so two equal `Image`s share a GPU slot.
44#[derive(Clone)]
45pub struct Image {
46    inner: Arc<ImageInner>,
47}
48
49struct ImageInner {
50    pixels: Vec<u8>,
51    width: u32,
52    height: u32,
53    content_hash: u64,
54}
55
56impl Image {
57    /// Build from RGBA8 pixels. Panics if `pixels.len() != width *
58    /// height * 4`. Hashes the pixel buffer + dimensions to derive a
59    /// stable content identity used for backend caching.
60    pub fn from_rgba8(width: u32, height: u32, pixels: Vec<u8>) -> Self {
61        let expected = (width as usize) * (height as usize) * 4;
62        assert_eq!(
63            pixels.len(),
64            expected,
65            "Image::from_rgba8: expected {expected} bytes ({width}x{height} RGBA8), got {}",
66            pixels.len(),
67        );
68        let mut h = DefaultHasher::new();
69        width.hash(&mut h);
70        height.hash(&mut h);
71        pixels.hash(&mut h);
72        let content_hash = h.finish();
73        Self {
74            inner: Arc::new(ImageInner {
75                pixels,
76                width,
77                height,
78                content_hash,
79            }),
80        }
81    }
82
83    pub fn width(&self) -> u32 {
84        self.inner.width
85    }
86
87    pub fn height(&self) -> u32 {
88        self.inner.height
89    }
90
91    /// RGBA8 pixel buffer, length `width * height * 4`. Top-left origin.
92    pub fn pixels(&self) -> &[u8] {
93        &self.inner.pixels
94    }
95
96    /// Stable hash of `(width, height, pixels)`. Backends use this as
97    /// the key into their per-image texture cache.
98    pub fn content_hash(&self) -> u64 {
99        self.inner.content_hash
100    }
101
102    /// Short hex label for inspection / dump output, e.g.
103    /// `"image:1a2b3c4d"`.
104    pub fn label(&self) -> String {
105        format!("image:{:08x}", self.inner.content_hash as u32)
106    }
107}
108
109impl PartialEq for Image {
110    fn eq(&self, other: &Self) -> bool {
111        // Arc identity → fast path. Fallback to content hash so two
112        // independently constructed `Image`s with equal pixels still
113        // compare equal (matches `SvgIcon`'s hash-driven identity).
114        Arc::ptr_eq(&self.inner, &other.inner)
115            || self.inner.content_hash == other.inner.content_hash
116    }
117}
118
119impl Eq for Image {}
120
121impl std::fmt::Debug for Image {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("Image")
124            .field("width", &self.inner.width)
125            .field("height", &self.inner.height)
126            .field(
127                "content_hash",
128                &format_args!("{:016x}", self.inner.content_hash),
129            )
130            .finish()
131    }
132}
133
134/// How a raster image projects into the rect resolved for its El.
135/// Mirrors CSS `object-fit`. The El rect (after `padding`) is the
136/// "viewport"; the image is the "content".
137#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
138pub enum ImageFit {
139    /// Scale uniformly so the image fits inside the rect, preserving
140    /// aspect ratio. Letterbox bands appear on the side that runs
141    /// short. Default — matches the CSS default for `<img>` in most
142    /// frameworks.
143    #[default]
144    Contain,
145    /// Scale uniformly so the image covers the rect, preserving aspect
146    /// ratio. Excess on the longer axis is clipped via the El's
147    /// scissor (the destination rect can extend past the El's content
148    /// area; `draw_ops` clips it back).
149    Cover,
150    /// Stretch the image to the rect, ignoring aspect ratio.
151    Fill,
152    /// No scaling — paint at the image's natural pixel size, anchored
153    /// top-left within the rect. Excess clips via the scissor.
154    None,
155}
156
157impl ImageFit {
158    /// Project an image of natural size `(nw, nh)` into `rect` according
159    /// to this fit. The returned rect is where the image should paint;
160    /// for `Cover` / `None` it may extend past `rect` and the caller
161    /// is expected to scissor-clip to `rect`.
162    pub fn project(self, nw: u32, nh: u32, rect: Rect) -> Rect {
163        let nw = (nw as f32).max(1.0);
164        let nh = (nh as f32).max(1.0);
165        match self {
166            ImageFit::Fill => rect,
167            ImageFit::None => Rect::new(rect.x, rect.y, nw, nh),
168            ImageFit::Contain => {
169                let scale = (rect.w / nw).min(rect.h / nh).max(0.0);
170                let w = nw * scale;
171                let h = nh * scale;
172                Rect::new(
173                    rect.x + (rect.w - w) * 0.5,
174                    rect.y + (rect.h - h) * 0.5,
175                    w,
176                    h,
177                )
178            }
179            ImageFit::Cover => {
180                let scale = (rect.w / nw).max(rect.h / nh).max(0.0);
181                let w = nw * scale;
182                let h = nh * scale;
183                Rect::new(
184                    rect.x + (rect.w - w) * 0.5,
185                    rect.y + (rect.h - h) * 0.5,
186                    w,
187                    h,
188                )
189            }
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn rgba(w: u32, h: u32, byte: u8) -> Vec<u8> {
199        vec![byte; (w as usize) * (h as usize) * 4]
200    }
201
202    #[test]
203    fn from_rgba8_validates_buffer_length() {
204        let _ = Image::from_rgba8(2, 2, rgba(2, 2, 0));
205    }
206
207    #[test]
208    #[should_panic(expected = "expected 16 bytes")]
209    fn from_rgba8_panics_on_size_mismatch() {
210        let _ = Image::from_rgba8(2, 2, vec![0; 12]);
211    }
212
213    #[test]
214    fn equal_pixels_share_content_hash() {
215        let a = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
216        let b = Image::from_rgba8(4, 4, rgba(4, 4, 0xab));
217        assert_eq!(a.content_hash(), b.content_hash());
218        assert_eq!(a, b);
219    }
220
221    #[test]
222    fn different_pixels_get_distinct_hash() {
223        let a = Image::from_rgba8(2, 2, rgba(2, 2, 0x00));
224        let b = Image::from_rgba8(2, 2, rgba(2, 2, 0xff));
225        assert_ne!(a.content_hash(), b.content_hash());
226    }
227
228    #[test]
229    fn fit_contain_letterboxes_horizontally() {
230        // 200x100 image into 400x400 rect: contain → 400x200 centred.
231        let r = ImageFit::Contain.project(200, 100, Rect::new(0.0, 0.0, 400.0, 400.0));
232        assert!((r.w - 400.0).abs() < 0.01);
233        assert!((r.h - 200.0).abs() < 0.01);
234        assert!((r.x - 0.0).abs() < 0.01);
235        assert!((r.y - 100.0).abs() < 0.01);
236    }
237
238    #[test]
239    fn fit_cover_overflows_horizontally() {
240        // 100x200 image into 400x400 rect: cover → 400x800 centred —
241        // overflow above and below the rect, scissor crops.
242        let r = ImageFit::Cover.project(100, 200, Rect::new(0.0, 0.0, 400.0, 400.0));
243        assert!((r.w - 400.0).abs() < 0.01);
244        assert!((r.h - 800.0).abs() < 0.01);
245        assert!((r.y + 200.0).abs() < 0.01);
246    }
247
248    #[test]
249    fn fit_fill_stretches() {
250        let r = ImageFit::Fill.project(100, 200, Rect::new(10.0, 20.0, 300.0, 50.0));
251        assert_eq!(r, Rect::new(10.0, 20.0, 300.0, 50.0));
252    }
253
254    #[test]
255    fn fit_none_uses_natural_size() {
256        let r = ImageFit::None.project(64, 32, Rect::new(10.0, 20.0, 400.0, 400.0));
257        assert_eq!(r, Rect::new(10.0, 20.0, 64.0, 32.0));
258    }
259}