Skip to main content

cranpose_ui/widgets/
image.rs

1//! Image composable and painter primitives.
2
3#![allow(non_snake_case)]
4#![allow(clippy::too_many_arguments)] // API matches Jetpack Compose Image signature.
5
6use crate::composable;
7use crate::layout::core::{Alignment, Measurable};
8use crate::modifier::{Modifier, Rect, Size};
9use crate::widgets::Layout;
10use cranpose_core::NodeId;
11use cranpose_ui_graphics::{ColorFilter, DrawScope, ImageBitmap};
12use cranpose_ui_layout::{Constraints, MeasurePolicy, MeasureResult};
13
14pub const DEFAULT_ALPHA: f32 = 1.0;
15
16#[derive(Clone, Copy, Debug, PartialEq)]
17pub enum ContentScale {
18    Fit,
19    Crop,
20    FillBounds,
21    FillWidth,
22    FillHeight,
23    Inside,
24    None,
25}
26
27impl ContentScale {
28    pub fn scaled_size(self, src_size: Size, dst_size: Size) -> Size {
29        if src_size.width <= 0.0
30            || src_size.height <= 0.0
31            || dst_size.width <= 0.0
32            || dst_size.height <= 0.0
33        {
34            return Size::ZERO;
35        }
36
37        let scale_x = dst_size.width / src_size.width;
38        let scale_y = dst_size.height / src_size.height;
39
40        let (factor_x, factor_y) = match self {
41            Self::Fit => {
42                let factor = scale_x.min(scale_y);
43                (factor, factor)
44            }
45            Self::Crop => {
46                let factor = scale_x.max(scale_y);
47                (factor, factor)
48            }
49            Self::FillBounds => (scale_x, scale_y),
50            Self::FillWidth => (scale_x, scale_x),
51            Self::FillHeight => (scale_y, scale_y),
52            Self::Inside => {
53                if src_size.width <= dst_size.width && src_size.height <= dst_size.height {
54                    (1.0, 1.0)
55                } else {
56                    let factor = scale_x.min(scale_y);
57                    (factor, factor)
58                }
59            }
60            Self::None => (1.0, 1.0),
61        };
62
63        Size {
64            width: src_size.width * factor_x,
65            height: src_size.height * factor_y,
66        }
67    }
68}
69
70#[derive(Clone, Debug, PartialEq, Eq, Hash)]
71pub struct Painter {
72    bitmap: ImageBitmap,
73}
74
75impl Painter {
76    pub fn from_bitmap(bitmap: ImageBitmap) -> Self {
77        Self { bitmap }
78    }
79
80    pub fn intrinsic_size(&self) -> Size {
81        self.bitmap.intrinsic_size()
82    }
83
84    pub fn bitmap(&self) -> &ImageBitmap {
85        &self.bitmap
86    }
87}
88
89impl From<ImageBitmap> for Painter {
90    fn from(value: ImageBitmap) -> Self {
91        Self::from_bitmap(value)
92    }
93}
94
95pub fn BitmapPainter(bitmap: ImageBitmap) -> Painter {
96    Painter::from_bitmap(bitmap)
97}
98
99/// Measure policy for Image that preserves aspect ratio when constraints
100/// force the image smaller than its intrinsic size.
101///
102/// Unlike [`LeafMeasurePolicy`] which clamps width and height independently,
103/// this scales both dimensions by the same factor so the image is never
104/// distorted by layout constraints.
105#[derive(Clone, Debug, PartialEq)]
106struct ImageMeasurePolicy {
107    intrinsic_size: Size,
108}
109
110impl MeasurePolicy for ImageMeasurePolicy {
111    fn measure(
112        &self,
113        _measurables: &[Box<dyn Measurable>],
114        constraints: Constraints,
115    ) -> MeasureResult {
116        let iw = self.intrinsic_size.width;
117        let ih = self.intrinsic_size.height;
118
119        if iw <= 0.0 || ih <= 0.0 {
120            let (w, h) = constraints.constrain(0.0, 0.0);
121            return MeasureResult::new(
122                Size {
123                    width: w,
124                    height: h,
125                },
126                vec![],
127            );
128        }
129
130        // Clamp each axis to its constraint range.
131        let cw = iw.clamp(constraints.min_width, constraints.max_width);
132        let ch = ih.clamp(constraints.min_height, constraints.max_height);
133
134        // If either axis had to shrink, scale both by the smaller factor
135        // so the aspect ratio is preserved.
136        let scale_x = cw / iw;
137        let scale_y = ch / ih;
138
139        let (width, height) = if scale_x < 1.0 || scale_y < 1.0 {
140            let factor = scale_x.min(scale_y);
141            let w = (iw * factor).clamp(constraints.min_width, constraints.max_width);
142            let h = (ih * factor).clamp(constraints.min_height, constraints.max_height);
143            (w, h)
144        } else {
145            (cw, ch)
146        };
147
148        MeasureResult::new(Size { width, height }, vec![])
149    }
150
151    fn min_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
152        self.intrinsic_size.width
153    }
154
155    fn max_intrinsic_width(&self, _measurables: &[Box<dyn Measurable>], _height: f32) -> f32 {
156        self.intrinsic_size.width
157    }
158
159    fn min_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
160        self.intrinsic_size.height
161    }
162
163    fn max_intrinsic_height(&self, _measurables: &[Box<dyn Measurable>], _width: f32) -> f32 {
164        self.intrinsic_size.height
165    }
166}
167
168fn destination_rect(
169    src_size: Size,
170    dst_size: Size,
171    alignment: Alignment,
172    content_scale: ContentScale,
173) -> Rect {
174    let draw_size = content_scale.scaled_size(src_size, dst_size);
175    let offset_x = alignment.horizontal.align(dst_size.width, draw_size.width);
176    let offset_y = alignment.vertical.align(dst_size.height, draw_size.height);
177    Rect {
178        x: offset_x,
179        y: offset_y,
180        width: draw_size.width,
181        height: draw_size.height,
182    }
183}
184
185fn crop_source_rect(src_size: Size, dst_size: Size, alignment: Alignment) -> Rect {
186    if src_size.width <= 0.0
187        || src_size.height <= 0.0
188        || dst_size.width <= 0.0
189        || dst_size.height <= 0.0
190    {
191        return Rect::from_size(Size::ZERO);
192    }
193
194    let src_aspect = src_size.width / src_size.height;
195    let dst_aspect = dst_size.width / dst_size.height;
196
197    if (src_aspect - dst_aspect).abs() <= f32::EPSILON {
198        return Rect::from_origin_size(crate::modifier::Point::ZERO, src_size);
199    }
200
201    if src_aspect > dst_aspect {
202        // Source is wider than destination: crop width.
203        let crop_width = src_size.height * dst_aspect;
204        let x = alignment
205            .horizontal
206            .align(src_size.width, crop_width)
207            .clamp(0.0, (src_size.width - crop_width).max(0.0));
208        Rect {
209            x,
210            y: 0.0,
211            width: crop_width,
212            height: src_size.height,
213        }
214    } else {
215        // Source is taller than destination: crop height.
216        let crop_height = src_size.width / dst_aspect;
217        let y = alignment
218            .vertical
219            .align(src_size.height, crop_height)
220            .clamp(0.0, (src_size.height - crop_height).max(0.0));
221        Rect {
222            x: 0.0,
223            y,
224            width: src_size.width,
225            height: crop_height,
226        }
227    }
228}
229
230fn map_destination_clip_to_source(
231    src_rect: Rect,
232    dst_rect: Rect,
233    clipped_dst_rect: Rect,
234) -> Option<Rect> {
235    if src_rect.width <= 0.0
236        || src_rect.height <= 0.0
237        || dst_rect.width <= 0.0
238        || dst_rect.height <= 0.0
239        || clipped_dst_rect.width <= 0.0
240        || clipped_dst_rect.height <= 0.0
241    {
242        return None;
243    }
244
245    let scale_x = src_rect.width / dst_rect.width;
246    let scale_y = src_rect.height / dst_rect.height;
247
248    let src_min_x = src_rect.x;
249    let src_min_y = src_rect.y;
250    let src_max_x = src_rect.x + src_rect.width;
251    let src_max_y = src_rect.y + src_rect.height;
252
253    let raw_left = src_rect.x + (clipped_dst_rect.x - dst_rect.x) * scale_x;
254    let raw_top = src_rect.y + (clipped_dst_rect.y - dst_rect.y) * scale_y;
255    let raw_right =
256        src_rect.x + ((clipped_dst_rect.x + clipped_dst_rect.width) - dst_rect.x) * scale_x;
257    let raw_bottom =
258        src_rect.y + ((clipped_dst_rect.y + clipped_dst_rect.height) - dst_rect.y) * scale_y;
259
260    let left = raw_left.clamp(src_min_x, src_max_x);
261    let top = raw_top.clamp(src_min_y, src_max_y);
262    let right = raw_right.clamp(src_min_x, src_max_x);
263    let bottom = raw_bottom.clamp(src_min_y, src_max_y);
264    let width = right - left;
265    let height = bottom - top;
266
267    if width <= 0.0 || height <= 0.0 {
268        None
269    } else {
270        Some(Rect {
271            x: left,
272            y: top,
273            width,
274            height,
275        })
276    }
277}
278
279#[composable]
280pub fn Image<P>(
281    painter: P,
282    content_description: Option<String>,
283    modifier: Modifier,
284    alignment: Alignment,
285    content_scale: ContentScale,
286    alpha: f32,
287    color_filter: Option<ColorFilter>,
288) -> NodeId
289where
290    P: Into<Painter> + Clone + PartialEq + 'static,
291{
292    let painter = painter.into();
293    // Treat bitmap pixels as dp (1 image-pixel = 1 dp).  This keeps images
294    // at the same visual size on every screen density.  On hi-dpi screens the
295    // bitmap is upscaled by the renderer, which is the correct behaviour for
296    // web-loaded and most application images.
297    let intrinsic_dp = painter.intrinsic_size();
298    let draw_alpha = alpha.clamp(0.0, 1.0);
299    let draw_painter = painter.clone();
300
301    let semantics_modifier = if let Some(description) = content_description {
302        Modifier::empty().semantics(move |config| {
303            config.content_description = Some(description.clone());
304        })
305    } else {
306        Modifier::empty()
307    };
308
309    let image_modifier =
310        modifier
311            .then(semantics_modifier)
312            .draw_behind(move |scope: &mut dyn DrawScope| {
313                if draw_alpha <= 0.0 {
314                    return;
315                }
316                let container_size = scope.size();
317                if container_size.width <= 0.0 || container_size.height <= 0.0 {
318                    return;
319                }
320                if content_scale == ContentScale::Crop {
321                    // For Crop, sample the centered/biased source sub-rect directly.
322                    let src_rect = crop_source_rect(intrinsic_dp, container_size, alignment);
323                    if src_rect.width <= 0.0 || src_rect.height <= 0.0 {
324                        return;
325                    }
326                    scope.draw_image_src(
327                        draw_painter.bitmap().clone(),
328                        src_rect,
329                        Rect::from_size(container_size),
330                        draw_alpha,
331                        color_filter,
332                    );
333                } else {
334                    let dst_rect =
335                        destination_rect(intrinsic_dp, container_size, alignment, content_scale);
336                    if dst_rect.width <= 0.0 || dst_rect.height <= 0.0 {
337                        return;
338                    }
339                    let container_rect = Rect::from_size(container_size);
340                    let Some(clipped_dst_rect) = dst_rect.intersect(container_rect) else {
341                        return;
342                    };
343                    let full_src_rect = Rect::from_size(intrinsic_dp);
344                    let Some(clipped_src_rect) =
345                        map_destination_clip_to_source(full_src_rect, dst_rect, clipped_dst_rect)
346                    else {
347                        return;
348                    };
349                    scope.draw_image_src(
350                        draw_painter.bitmap().clone(),
351                        clipped_src_rect,
352                        clipped_dst_rect,
353                        draw_alpha,
354                        color_filter,
355                    );
356                }
357            });
358
359    Layout(
360        image_modifier,
361        ImageMeasurePolicy {
362            intrinsic_size: intrinsic_dp,
363        },
364        || {},
365    )
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::layout::core::Alignment;
372
373    fn sample_bitmap() -> ImageBitmap {
374        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
375    }
376
377    #[test]
378    fn painter_reports_intrinsic_size_and_bitmap() {
379        let bitmap = sample_bitmap();
380        let painter = BitmapPainter(bitmap.clone());
381        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
382        assert_eq!(painter.bitmap(), &bitmap);
383    }
384
385    #[test]
386    fn fit_keeps_aspect_ratio() {
387        let src = Size::new(200.0, 100.0);
388        let dst = Size::new(300.0, 300.0);
389        let result = ContentScale::Fit.scaled_size(src, dst);
390        assert_eq!(result, Size::new(300.0, 150.0));
391    }
392
393    #[test]
394    fn crop_fills_bounds() {
395        let src = Size::new(200.0, 100.0);
396        let dst = Size::new(300.0, 300.0);
397        let result = ContentScale::Crop.scaled_size(src, dst);
398        assert_eq!(result, Size::new(600.0, 300.0));
399    }
400
401    #[test]
402    fn destination_rect_aligns_center() {
403        let src = Size::new(200.0, 100.0);
404        let dst = Size::new(300.0, 300.0);
405        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
406        assert_eq!(
407            rect,
408            Rect {
409                x: 0.0,
410                y: 75.0,
411                width: 300.0,
412                height: 150.0,
413            }
414        );
415    }
416
417    #[test]
418    fn crop_source_rect_is_centered_for_wide_source() {
419        let src = Size::new(200.0, 100.0);
420        let dst = Size::new(100.0, 100.0);
421        let rect = crop_source_rect(src, dst, Alignment::CENTER);
422        assert_eq!(
423            rect,
424            Rect {
425                x: 50.0,
426                y: 0.0,
427                width: 100.0,
428                height: 100.0,
429            }
430        );
431    }
432
433    #[test]
434    fn crop_source_rect_honors_start_alignment() {
435        let src = Size::new(200.0, 100.0);
436        let dst = Size::new(100.0, 100.0);
437        let rect = crop_source_rect(src, dst, Alignment::TOP_START);
438        assert_eq!(
439            rect,
440            Rect {
441                x: 0.0,
442                y: 0.0,
443                width: 100.0,
444                height: 100.0,
445            }
446        );
447    }
448
449    fn approx_eq(left: f32, right: f32) {
450        assert!((left - right).abs() < 1e-4, "left={left}, right={right}");
451    }
452
453    #[test]
454    fn map_destination_clip_to_source_scales_proportionally() {
455        let src = Rect {
456            x: 0.0,
457            y: 0.0,
458            width: 100.0,
459            height: 100.0,
460        };
461        let dst = Rect {
462            x: -50.0,
463            y: 0.0,
464            width: 200.0,
465            height: 100.0,
466        };
467        let clipped_dst = Rect {
468            x: 0.0,
469            y: 0.0,
470            width: 100.0,
471            height: 100.0,
472        };
473        let mapped = map_destination_clip_to_source(src, dst, clipped_dst).expect("mapped");
474        approx_eq(mapped.x, 25.0);
475        approx_eq(mapped.y, 0.0);
476        approx_eq(mapped.width, 50.0);
477        approx_eq(mapped.height, 100.0);
478    }
479
480    #[test]
481    fn map_destination_clip_to_source_returns_full_source_without_clipping() {
482        let src = Rect {
483            x: 0.0,
484            y: 0.0,
485            width: 120.0,
486            height: 80.0,
487        };
488        let dst = Rect {
489            x: 10.0,
490            y: 5.0,
491            width: 60.0,
492            height: 40.0,
493        };
494        let mapped = map_destination_clip_to_source(src, dst, dst).expect("mapped");
495        approx_eq(mapped.x, src.x);
496        approx_eq(mapped.y, src.y);
497        approx_eq(mapped.width, src.width);
498        approx_eq(mapped.height, src.height);
499    }
500
501    // --- ImageMeasurePolicy tests ---
502
503    fn measure_image(intrinsic: Size, constraints: Constraints) -> Size {
504        let policy = ImageMeasurePolicy {
505            intrinsic_size: intrinsic,
506        };
507        policy.measure(&[], constraints).size
508    }
509
510    #[test]
511    fn image_measure_unconstrained() {
512        let size = measure_image(
513            Size::new(800.0, 600.0),
514            Constraints::loose(f32::INFINITY, f32::INFINITY),
515        );
516        assert_eq!(size, Size::new(800.0, 600.0));
517    }
518
519    #[test]
520    fn image_measure_width_constrained_preserves_aspect_ratio() {
521        // 800×600 constrained to max_width=400 → should scale to 400×300
522        let size = measure_image(
523            Size::new(800.0, 600.0),
524            Constraints::loose(400.0, f32::INFINITY),
525        );
526        assert_eq!(size, Size::new(400.0, 300.0));
527    }
528
529    #[test]
530    fn image_measure_height_constrained_preserves_aspect_ratio() {
531        // 800×600 constrained to max_height=300 → should scale to 400×300
532        let size = measure_image(
533            Size::new(800.0, 600.0),
534            Constraints::loose(f32::INFINITY, 300.0),
535        );
536        assert_eq!(size, Size::new(400.0, 300.0));
537    }
538
539    #[test]
540    fn image_measure_both_constrained_uses_smaller_factor() {
541        // 800×600 constrained to 200×400 → width is the bottleneck (0.25)
542        // scaled: 200×150
543        let size = measure_image(Size::new(800.0, 600.0), Constraints::loose(200.0, 400.0));
544        assert_eq!(size, Size::new(200.0, 150.0));
545    }
546
547    #[test]
548    fn image_measure_fits_within_constraints() {
549        // 200×100 in a 400×400 container → stays at intrinsic size
550        let size = measure_image(Size::new(200.0, 100.0), Constraints::loose(400.0, 400.0));
551        assert_eq!(size, Size::new(200.0, 100.0));
552    }
553
554    #[test]
555    fn image_measure_zero_intrinsic() {
556        let size = measure_image(Size::ZERO, Constraints::loose(400.0, 400.0));
557        assert_eq!(size, Size::new(0.0, 0.0));
558    }
559}