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}