Skip to main content

sequoia_core/
pixel.rs

1use std::rc::Rc;
2
3use crate::geometry::Rect;
4use crate::paint::{Color, ImageSource};
5
6const BPP: usize = 4;
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct Pixels {
10    width: u32,
11    height: u32,
12    data: Vec<u8>,
13}
14
15impl Default for Pixels {
16    fn default() -> Self {
17        Self::new(0, 0)
18    }
19}
20
21impl Pixels {
22    pub fn new(width: u32, height: u32) -> Self {
23        let Some(px) = checked_pixels(width, height) else {
24            return Self {
25                width: 0,
26                height: 0,
27                data: Vec::new(),
28            };
29        };
30        Self {
31            width,
32            height,
33            data: vec![0; px * BPP],
34        }
35    }
36
37    pub fn filled(width: u32, height: u32, color: Color) -> Self {
38        let mut px = Self::new(width, height);
39        px.fill(color);
40        px
41    }
42
43    pub fn from_rgba(width: u32, height: u32, data: impl Into<Vec<u8>>) -> Option<Self> {
44        let data = data.into();
45        if width > MAX_DIM || height > MAX_DIM {
46            return None;
47        }
48        let expected = (width as usize)
49            .checked_mul(height as usize)?
50            .checked_mul(BPP)?;
51        if data.len() != expected {
52            return None;
53        }
54        Some(Self {
55            width,
56            height,
57            data,
58        })
59    }
60
61    pub fn width(&self) -> u32 {
62        self.width
63    }
64
65    pub fn height(&self) -> u32 {
66        self.height
67    }
68
69    pub fn is_empty(&self) -> bool {
70        self.width == 0 || self.height == 0
71    }
72
73    pub fn pixel_count(&self) -> usize {
74        self.data.len() / BPP
75    }
76
77    pub fn data(&self) -> &[u8] {
78        &self.data
79    }
80
81    pub fn data_mut(&mut self) -> &mut [u8] {
82        &mut self.data
83    }
84
85    pub fn into_data(self) -> Vec<u8> {
86        self.data
87    }
88
89    pub fn index(&self, x: i32, y: i32) -> Option<usize> {
90        if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
91            return None;
92        }
93        Some((y as usize * self.width as usize + x as usize) * BPP)
94    }
95
96    pub fn contains(&self, x: i32, y: i32) -> bool {
97        self.index(x, y).is_some()
98    }
99
100    pub fn get(&self, x: i32, y: i32) -> Option<Color> {
101        let i = self.index(x, y)?;
102        Some(unpack(&self.data[i..i + BPP]))
103    }
104
105    pub fn set(&mut self, x: i32, y: i32, color: Color) -> bool {
106        let Some(i) = self.index(x, y) else {
107            return false;
108        };
109        self.data[i..i + BPP].copy_from_slice(&pack(color));
110        true
111    }
112
113    pub fn blend(&mut self, x: i32, y: i32, color: Color) -> bool {
114        let Some(i) = self.index(x, y) else {
115            return false;
116        };
117        let out = blend_over(unpack(&self.data[i..i + BPP]), color);
118        self.data[i..i + BPP].copy_from_slice(&out);
119        true
120    }
121
122    pub fn fill(&mut self, color: Color) {
123        let px = pack(color);
124        for chunk in self.data.chunks_exact_mut(BPP) {
125            chunk.copy_from_slice(&px);
126        }
127    }
128
129    pub fn fill_rect(&mut self, rect: Rect, color: Color) -> Rect {
130        let Some((x0, y0, x1, y1)) = self.rect_bounds(rect) else {
131            return Rect::ZERO;
132        };
133        let px = pack(color);
134        for y in y0..y1 {
135            let row = y as usize * self.width as usize;
136            for x in x0..x1 {
137                let i = (row + x as usize) * BPP;
138                self.data[i..i + BPP].copy_from_slice(&px);
139            }
140        }
141        bounds_rect(x0, y0, x1, y1)
142    }
143
144    pub fn blend_rect(&mut self, rect: Rect, color: Color) -> Rect {
145        let Some((x0, y0, x1, y1)) = self.rect_bounds(rect) else {
146            return Rect::ZERO;
147        };
148        for y in y0..y1 {
149            let row = y as usize * self.width as usize;
150            for x in x0..x1 {
151                let i = (row + x as usize) * BPP;
152                let out = blend_over(unpack(&self.data[i..i + BPP]), color);
153                self.data[i..i + BPP].copy_from_slice(&out);
154            }
155        }
156        bounds_rect(x0, y0, x1, y1)
157    }
158
159    pub fn blit(&mut self, src: &Pixels, dx: i32, dy: i32) -> Rect {
160        self.blit_impl(src, dx, dy, false)
161    }
162
163    pub fn blend_from(&mut self, src: &Pixels, dx: i32, dy: i32) -> Rect {
164        self.blit_impl(src, dx, dy, true)
165    }
166
167    fn blit_impl(&mut self, src: &Pixels, dx: i32, dy: i32, blend: bool) -> Rect {
168        if src.is_empty() || self.is_empty() {
169            return Rect::ZERO;
170        }
171        let sx0 = (-dx).max(0);
172        let sy0 = (-dy).max(0);
173        let sx1 = (self.width as i32 - dx).min(src.width as i32);
174        let sy1 = (self.height as i32 - dy).min(src.height as i32);
175        if sx0 >= sx1 || sy0 >= sy1 {
176            return Rect::ZERO;
177        }
178        for sy in sy0..sy1 {
179            let dy_row = (dy + sy) as usize * self.width as usize;
180            let sx_row = sy as usize * src.width as usize;
181            for sx in sx0..sx1 {
182                let si = (sx_row + sx as usize) * BPP;
183                let di = (dy_row + (dx + sx) as usize) * BPP;
184                if blend {
185                    let out = blend_over(
186                        unpack(&self.data[di..di + BPP]),
187                        unpack(&src.data[si..si + BPP]),
188                    );
189                    self.data[di..di + BPP].copy_from_slice(&out);
190                } else {
191                    self.data[di..di + BPP].copy_from_slice(&src.data[si..si + BPP]);
192                }
193            }
194        }
195        bounds_rect(dx + sx0, dy + sy0, dx + sx1, dy + sy1)
196    }
197
198    pub fn clear(&mut self) {
199        self.data.fill(0);
200    }
201
202    pub fn crop(&self, rect: Rect) -> Pixels {
203        let Some((x0, y0, x1, y1)) = self.rect_bounds(rect) else {
204            return Pixels::new(0, 0);
205        };
206        let w = (x1 - x0) as usize;
207        let h = (y1 - y0) as usize;
208        let mut out = Vec::with_capacity(w * h * BPP);
209        for y in y0..y1 {
210            let start = (y as usize * self.width as usize + x0 as usize) * BPP;
211            out.extend_from_slice(&self.data[start..start + w * BPP]);
212        }
213        Pixels {
214            width: (x1 - x0) as u32,
215            height: (y1 - y0) as u32,
216            data: out,
217        }
218    }
219
220    pub fn flip_horizontal(&mut self) {
221        if self.is_empty() {
222            return;
223        }
224        let w = self.width as usize;
225        let row_bytes = w * BPP;
226        for y in 0..self.height as usize {
227            let row = &mut self.data[y * row_bytes..(y + 1) * row_bytes];
228            for x in 0..w / 2 {
229                let a = x * BPP;
230                let b = (w - 1 - x) * BPP;
231                for k in 0..BPP {
232                    row.swap(a + k, b + k);
233                }
234            }
235        }
236    }
237
238    pub fn flip_vertical(&mut self) {
239        if self.is_empty() {
240            return;
241        }
242        let row_bytes = self.width as usize * BPP;
243        let h = self.height as usize;
244        for y in 0..h / 2 {
245            let (a, b) = (y * row_bytes, (h - 1 - y) * row_bytes);
246            for k in 0..row_bytes {
247                self.data.swap(a + k, b + k);
248            }
249        }
250    }
251
252    pub fn rotate180(&mut self) {
253        self.flip_horizontal();
254        self.flip_vertical();
255    }
256
257    pub fn resize_nearest(&self, width: u32, height: u32) -> Pixels {
258        if checked_pixels(width, height).is_none() {
259            return Pixels::new(0, 0);
260        }
261        let (w, h) = (width, height);
262        if w == 0 || h == 0 || self.is_empty() {
263            return Pixels::new(w, h);
264        }
265        let mut out = Pixels::new(w, h);
266        let xr = self.width as f32 / w as f32;
267        let yr = self.height as f32 / h as f32;
268        for y in 0..h {
269            let sy = (y as f32 * yr).floor() as i32;
270            for x in 0..w {
271                let sx = (x as f32 * xr).floor() as i32;
272                if let (Some(si), Some(di)) = (self.index(sx, sy), out.index(x as i32, y as i32)) {
273                    out.data[di..di + BPP].copy_from_slice(&self.data[si..si + BPP]);
274                }
275            }
276        }
277        out
278    }
279
280    pub fn resize_bilinear(&self, width: u32, height: u32) -> Pixels {
281        if checked_pixels(width, height).is_none() {
282            return Pixels::new(0, 0);
283        }
284        let (w, h) = (width, height);
285        if w == 0 || h == 0 || self.is_empty() {
286            return Pixels::new(w, h);
287        }
288        let mut out = Pixels::new(w, h);
289        let sx = self.width as f32 / w as f32;
290        let sy = self.height as f32 / h as f32;
291        for y in 0..h {
292            let fy = (y as f32 + 0.5) * sy - 0.5;
293            for x in 0..w {
294                let fx = (x as f32 + 0.5) * sx - 0.5;
295                let c = self.sample_bilinear(fx, fy);
296                if let Some(di) = out.index(x as i32, y as i32) {
297                    out.data[di..di + BPP].copy_from_slice(&pack(c));
298                }
299            }
300        }
301        out
302    }
303
304    pub fn thumbnail(&self, max_dim: u32) -> Pixels {
305        self.scaled_to_fit(max_dim).unwrap_or_else(|| self.clone())
306    }
307
308    pub fn scaled_to_fit(&self, max_dim: u32) -> Option<Pixels> {
309        if self.is_empty() || max_dim == 0 {
310            return None;
311        }
312        let longest = self.width.max(self.height);
313        if longest <= max_dim {
314            return None;
315        }
316        let scale = max_dim as f32 / longest as f32;
317        let w = ((self.width as f32 * scale).round() as u32).max(1);
318        let h = ((self.height as f32 * scale).round() as u32).max(1);
319        Some(self.resize_bilinear(w, h))
320    }
321
322    pub fn sample_nearest(&self, fx: f32, fy: f32) -> Color {
323        if self.is_empty() {
324            return Color::TRANSPARENT;
325        }
326        let x = fx.floor().clamp(0.0, self.width as f32 - 1.0) as i32;
327        let y = fy.floor().clamp(0.0, self.height as f32 - 1.0) as i32;
328        self.get(x, y).unwrap_or(Color::TRANSPARENT)
329    }
330
331    pub fn sample_bilinear(&self, fx: f32, fy: f32) -> Color {
332        if self.is_empty() {
333            return Color::TRANSPARENT;
334        }
335        if !fx.is_finite() || !fy.is_finite() {
336            return Color::TRANSPARENT;
337        }
338        let x0 = fx.floor();
339        let y0 = fy.floor();
340        let tx = (fx - x0).clamp(0.0, 1.0);
341        let ty = (fy - y0).clamp(0.0, 1.0);
342        let x0 = (x0 as i32).clamp(0, self.width as i32 - 1);
343        let y0 = (y0 as i32).clamp(0, self.height as i32 - 1);
344        let x1 = (x0 + 1).min(self.width as i32 - 1);
345        let y1 = (y0 + 1).min(self.height as i32 - 1);
346        let c00 = self.get(x0, y0).unwrap_or(Color::TRANSPARENT);
347        let c10 = self.get(x1, y0).unwrap_or(Color::TRANSPARENT);
348        let c01 = self.get(x0, y1).unwrap_or(Color::TRANSPARENT);
349        let c11 = self.get(x1, y1).unwrap_or(Color::TRANSPARENT);
350        let mix = |a: f32, b: f32, t: f32| a + (b - a) * t;
351        let lerp = |a: Color, b: Color, t: f32| Color {
352            r: mix(a.r, b.r, t),
353            g: mix(a.g, b.g, t),
354            b: mix(a.b, b.b, t),
355            a: mix(a.a, b.a, t),
356        };
357        lerp(lerp(c00, c10, tx), lerp(c01, c11, tx), ty)
358    }
359
360    pub fn for_each_pixel(&mut self, mut f: impl FnMut(i32, i32, Color) -> Color) {
361        let w = self.width as usize;
362        for y in 0..self.height as usize {
363            for x in 0..w {
364                let i = (y * w + x) * BPP;
365                let c = unpack(&self.data[i..i + BPP]);
366                self.data[i..i + BPP].copy_from_slice(&pack(f(x as i32, y as i32, c)));
367            }
368        }
369    }
370
371    pub fn map(&self, mut f: impl FnMut(i32, i32, Color) -> Color) -> Pixels {
372        let mut out = self.clone();
373        out.for_each_pixel(&mut f);
374        out
375    }
376
377    pub fn grayscale(&self) -> Pixels {
378        self.map(|_, _, c| {
379            let l = 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b;
380            Color::rgba(l, l, l, c.a)
381        })
382    }
383
384    pub fn inverted(&self) -> Pixels {
385        self.map(|_, _, c| Color::rgba(1.0 - c.r, 1.0 - c.g, 1.0 - c.b, c.a))
386    }
387
388    pub fn tinted(&self, color: Color) -> Pixels {
389        self.map(|_, _, c| Color::rgba(c.r * color.r, c.g * color.g, c.b * color.b, c.a * color.a))
390    }
391
392    pub fn multiply_alpha(&self, factor: f32) -> Pixels {
393        let f = if factor.is_finite() {
394            factor.clamp(0.0, 1.0)
395        } else {
396            1.0
397        };
398        self.map(move |_, _, c| Color { a: c.a * f, ..c })
399    }
400
401    pub fn has_alpha(&self) -> bool {
402        self.data.chunks_exact(BPP).any(|p| p[3] != 255)
403    }
404
405    pub fn opaque_bounds(&self) -> Option<Rect> {
406        if self.is_empty() {
407            return None;
408        }
409        let mut min_x = self.width;
410        let mut min_y = self.height;
411        let mut max_x = 0u32;
412        let mut max_y = 0u32;
413        for y in 0..self.height {
414            for x in 0..self.width {
415                let i = ((y as usize * self.width as usize) + x as usize) * BPP;
416                if self.data[i + 3] != 0 {
417                    min_x = min_x.min(x);
418                    min_y = min_y.min(y);
419                    max_x = max_x.max(x);
420                    max_y = max_y.max(y);
421                }
422            }
423        }
424        if min_x > max_x || min_y > max_y {
425            return None;
426        }
427        Some(Rect::from_xywh(
428            min_x as f32,
429            min_y as f32,
430            (max_x - min_x + 1) as f32,
431            (max_y - min_y + 1) as f32,
432        ))
433    }
434
435    pub fn to_source(&self, id: u64, version: u64) -> ImageSource {
436        ImageSource::Dynamic {
437            id,
438            width: self.width,
439            height: self.height,
440            data: Rc::from(self.data.clone().into_boxed_slice()),
441            version,
442        }
443    }
444
445    pub fn into_source(self, id: u64, version: u64) -> ImageSource {
446        ImageSource::Dynamic {
447            id,
448            width: self.width,
449            height: self.height,
450            data: Rc::from(self.data.into_boxed_slice()),
451            version,
452        }
453    }
454
455    fn rect_bounds(&self, rect: Rect) -> Option<(i32, i32, i32, i32)> {
456        if self.is_empty() || rect.is_empty() {
457            return None;
458        }
459        let x0 = rect.left().round() as i32;
460        let y0 = rect.top().round() as i32;
461        let x1 = rect.right().round() as i32;
462        let y1 = rect.bottom().round() as i32;
463        let x0 = x0.max(0);
464        let y0 = y0.max(0);
465        let x1 = x1.min(self.width as i32);
466        let y1 = y1.min(self.height as i32);
467        if x0 >= x1 || y0 >= y1 {
468            return None;
469        }
470        Some((x0, y0, x1, y1))
471    }
472}
473
474pub const MAX_DIM: u32 = 16384;
475
476fn checked_pixels(width: u32, height: u32) -> Option<usize> {
477    if width > MAX_DIM || height > MAX_DIM {
478        return None;
479    }
480    (width as usize).checked_mul(height as usize)
481}
482
483fn bounds_rect(x0: i32, y0: i32, x1: i32, y1: i32) -> Rect {
484    if x0 >= x1 || y0 >= y1 {
485        return Rect::ZERO;
486    }
487    Rect::from_xywh(x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32)
488}
489
490fn pack(color: Color) -> [u8; BPP] {
491    let q = |c: f32| (c.clamp(0.0, 1.0) * 255.0).round() as u8;
492    [q(color.r), q(color.g), q(color.b), q(color.a)]
493}
494
495fn unpack(bytes: &[u8]) -> Color {
496    Color::rgba(
497        bytes[0] as f32 / 255.0,
498        bytes[1] as f32 / 255.0,
499        bytes[2] as f32 / 255.0,
500        bytes[3] as f32 / 255.0,
501    )
502}
503
504fn blend_over(dst: Color, src: Color) -> [u8; BPP] {
505    if src.a <= 0.0 {
506        return pack(dst);
507    }
508    if src.a >= 1.0 {
509        return pack(src);
510    }
511    let inv = 1.0 - src.a;
512    let out_a = src.a + dst.a * inv;
513    if out_a <= 0.0 {
514        return pack(Color::TRANSPARENT);
515    }
516    let mix = |s: f32, d: f32| (s * src.a + d * dst.a * inv) / out_a;
517    pack(Color::rgba(
518        mix(src.r, dst.r),
519        mix(src.g, dst.g),
520        mix(src.b, dst.b),
521        out_a,
522    ))
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    fn red() -> Color {
530        Color::rgb8(255, 0, 0)
531    }
532
533    #[test]
534    fn new_allocates_zeroed_rgba() {
535        let px = Pixels::new(3, 2);
536        assert_eq!((px.width(), px.height()), (3, 2));
537        assert_eq!(px.data().len(), 3 * 2 * 4);
538        assert!(px.data().iter().all(|&b| b == 0));
539        assert!(px.has_alpha());
540        assert_eq!(px.pixel_count(), 6);
541    }
542
543    #[test]
544    fn oversized_request_is_empty_not_panic() {
545        let px = Pixels::new(u32::MAX, 2);
546        assert!(px.is_empty());
547        assert_eq!(px.data().len(), 0);
548        assert_eq!(Pixels::new(MAX_DIM + 1, 1).width(), 0);
549    }
550
551    #[test]
552    fn from_rgba_validates_length() {
553        assert!(Pixels::from_rgba(2, 2, vec![0u8; 16]).is_some());
554        assert!(Pixels::from_rgba(2, 2, vec![0u8; 15]).is_none());
555        assert!(Pixels::from_rgba(0, 5, Vec::new()).is_some());
556        assert!(Pixels::from_rgba(MAX_DIM + 1, 1, vec![0u8; 4]).is_none());
557    }
558
559    #[test]
560    fn set_get_and_bounds() {
561        let mut px = Pixels::new(2, 2);
562        assert!(px.set(1, 1, red()));
563        assert_eq!(px.get(1, 1), Some(red()));
564        assert!(!px.set(2, 0, red()), "out of bounds");
565        assert_eq!(px.get(-1, 0), None);
566        assert!(px.contains(0, 1));
567        assert!(!px.contains(2, 0));
568    }
569
570    #[test]
571    fn blend_is_source_over() {
572        let mut px = Pixels::new(1, 1);
573        px.set(0, 0, Color::rgb8(0, 0, 255));
574        px.blend(0, 0, Color::rgba(1.0, 0.0, 0.0, 0.5));
575        let c = px.get(0, 0).unwrap();
576        assert!((c.r - 0.5).abs() < 0.02, "{c:?}");
577        assert!((c.b - 0.5).abs() < 0.02, "{c:?}");
578        assert!((c.a - 1.0).abs() < 1e-6);
579    }
580
581    #[test]
582    fn fill_and_fill_rect_clip_to_bounds() {
583        let mut px = Pixels::new(4, 4);
584        assert_eq!(
585            px.fill_rect(Rect::from_xywh(1.0, 1.0, 2.0, 2.0), red()),
586            Rect::from_xywh(1.0, 1.0, 2.0, 2.0)
587        );
588        assert_eq!(px.get(1, 1), Some(red()));
589        assert_eq!(px.get(2, 2), Some(red()));
590        assert_eq!(px.get(0, 0), Some(Color::TRANSPARENT));
591
592        let clipped = px.fill_rect(Rect::from_xywh(3.0, 3.0, 5.0, 5.0), red());
593        assert_eq!(clipped, Rect::from_xywh(3.0, 3.0, 1.0, 1.0));
594        px.fill(Color::GREEN);
595        assert_eq!(px.get(0, 0), Some(Color::GREEN));
596    }
597
598    #[test]
599    fn blit_and_blend_from_copy_region() {
600        let mut src = Pixels::filled(2, 2, red());
601        src.set(0, 0, Color::TRANSPARENT);
602        let mut dst = Pixels::filled(4, 4, Color::rgb8(0, 0, 255));
603        let r = dst.blit(&src, 1, 1);
604        assert_eq!(r, Rect::from_xywh(1.0, 1.0, 2.0, 2.0));
605        assert_eq!(dst.get(1, 1), Some(Color::TRANSPARENT), "blit overwrites");
606        assert_eq!(dst.get(2, 2), Some(red()));
607
608        let mut dst2 = Pixels::filled(4, 4, Color::rgb8(0, 0, 255));
609        dst2.blend_from(&src, 1, 1);
610        assert_eq!(
611            dst2.get(1, 1),
612            Some(Color::rgb8(0, 0, 255)),
613            "alpha 0 keeps dst"
614        );
615    }
616
617    #[test]
618    fn crop_clips_to_image() {
619        let mut px = Pixels::filled(4, 3, red());
620        px.set(0, 0, Color::GREEN);
621        let c = px.crop(Rect::from_xywh(0.0, 0.0, 2.0, 2.0));
622        assert_eq!((c.width(), c.height()), (2, 2));
623        assert_eq!(c.get(0, 0), Some(Color::GREEN));
624        assert_eq!(c.get(1, 1), Some(red()));
625        assert!(px.crop(Rect::from_xywh(10.0, 10.0, 5.0, 5.0)).is_empty());
626    }
627
628    #[test]
629    fn flips_and_rotate_match_mirror() {
630        let mut px = Pixels::new(3, 1);
631        px.set(0, 0, red());
632        px.flip_horizontal();
633        assert_eq!(px.get(2, 0), Some(red()));
634        px.flip_vertical();
635        assert_eq!(px.get(2, 0), Some(red()));
636        px.rotate180();
637        assert_eq!(px.get(0, 0), Some(red()));
638    }
639
640    #[test]
641    fn thumbnail_only_scales_down_and_keeps_aspect() {
642        let px = Pixels::filled(200, 100, red());
643        let t = px.thumbnail(50);
644        assert_eq!((t.width(), t.height()), (50, 25));
645        assert_eq!(px.thumbnail(500).width(), 200, "small image untouched");
646        assert!(px.scaled_to_fit(500).is_none());
647        let up = px.resize_nearest(400, 200);
648        assert_eq!((up.width(), up.height()), (400, 200));
649    }
650
651    #[test]
652    fn nearest_resize_preserves_hard_edges() {
653        let mut px = Pixels::new(2, 2);
654        px.set(0, 0, red());
655        let up = px.resize_nearest(4, 4);
656        assert_eq!(up.get(1, 1), Some(red()));
657        assert_eq!(up.get(0, 0), Some(red()));
658        assert_eq!(up.get(3, 3), Some(Color::TRANSPARENT));
659    }
660
661    #[test]
662    fn bilinear_sample_averages_neighbours() {
663        let mut px = Pixels::new(2, 1);
664        px.set(0, 0, Color::BLACK);
665        px.set(1, 0, Color::WHITE);
666        let mid = px.sample_bilinear(0.5, 0.0);
667        assert!((mid.r - 0.5).abs() < 1e-6, "{mid:?}");
668        assert_eq!(px.sample_nearest(0.0, 0.0), Color::BLACK);
669    }
670
671    #[test]
672    fn color_transforms_keep_alpha() {
673        let px = Pixels::filled(2, 2, Color::rgba(1.0, 0.0, 0.0, 0.5));
674        let g = px.grayscale();
675        let c = g.get(0, 0).unwrap();
676        assert!((c.r - c.g).abs() < 1e-6 && (c.g - c.b).abs() < 1e-6);
677        assert!((c.a - 0.5).abs() < 0.01);
678        let inv = px.inverted();
679        assert!((inv.get(1, 1).unwrap().r - 0.0).abs() < 1e-6);
680        let tint = Pixels::filled(1, 1, Color::WHITE).tinted(Color::rgba(1.0, 0.0, 0.0, 1.0));
681        assert_eq!(tint.get(0, 0), Some(red()));
682        let faded = Pixels::filled(1, 1, Color::WHITE).multiply_alpha(0.25);
683        assert!((faded.get(0, 0).unwrap().a - 0.25).abs() < 0.01);
684    }
685
686    #[test]
687    fn opaque_bounds_tightly_wraps_content() {
688        let mut px = Pixels::new(10, 10);
689        px.set(3, 4, red());
690        px.set(5, 6, red());
691        assert_eq!(
692            px.opaque_bounds(),
693            Some(Rect::from_xywh(3.0, 4.0, 3.0, 3.0))
694        );
695        assert_eq!(Pixels::new(2, 2).opaque_bounds(), None);
696    }
697
698    #[test]
699    fn source_conversions_carry_dimensions() {
700        let px = Pixels::filled(3, 2, red());
701        assert_eq!(px.to_source(7, 1).extent(), Some((3, 2)));
702        assert_eq!(px.clone().into_source(7, 1).dynamic_version(), Some(1));
703        assert_eq!(px.to_source(9, 1).cache_key(), 9);
704    }
705
706    #[test]
707    fn map_applies_pixel_fn() {
708        let px = Pixels::filled(2, 2, Color::rgb8(10, 20, 30));
709        let out = px.map(|_, _, c| Color::rgba(c.r, c.g, c.b, 0.5));
710        assert!((out.get(1, 1).unwrap().a - 0.5).abs() < 0.01);
711        let mut inplace = px.clone();
712        inplace.for_each_pixel(|_, _, _| Color::BLACK);
713        assert_eq!(inplace.get(0, 0), Some(Color::BLACK));
714    }
715}