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
185#[composable]
186pub fn Image<P>(
187    painter: P,
188    content_description: Option<String>,
189    modifier: Modifier,
190    alignment: Alignment,
191    content_scale: ContentScale,
192    alpha: f32,
193    color_filter: Option<ColorFilter>,
194) -> NodeId
195where
196    P: Into<Painter> + Clone + PartialEq + 'static,
197{
198    let painter = painter.into();
199    // Treat bitmap pixels as dp (1 image-pixel = 1 dp).  This keeps images
200    // at the same visual size on every screen density.  On hi-dpi screens the
201    // bitmap is upscaled by the renderer, which is the correct behaviour for
202    // web-loaded and most application images.
203    let intrinsic_dp = painter.intrinsic_size();
204    let draw_alpha = alpha.clamp(0.0, 1.0);
205    let draw_painter = painter.clone();
206
207    let semantics_modifier = if let Some(description) = content_description {
208        Modifier::empty().semantics(move |config| {
209            config.content_description = Some(description.clone());
210        })
211    } else {
212        Modifier::empty()
213    };
214
215    let image_modifier = modifier
216        .then(semantics_modifier)
217        .clip_to_bounds()
218        .draw_behind(move |scope: &mut dyn DrawScope| {
219            if draw_alpha <= 0.0 {
220                return;
221            }
222            let container_size = scope.size();
223            let rect = destination_rect(intrinsic_dp, container_size, alignment, content_scale);
224            if rect.width <= 0.0 || rect.height <= 0.0 {
225                return;
226            }
227            scope.draw_image_at(
228                rect,
229                draw_painter.bitmap().clone(),
230                draw_alpha,
231                color_filter,
232            );
233        });
234
235    Layout(
236        image_modifier,
237        ImageMeasurePolicy {
238            intrinsic_size: intrinsic_dp,
239        },
240        || {},
241    )
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::layout::core::Alignment;
248
249    fn sample_bitmap() -> ImageBitmap {
250        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
251    }
252
253    #[test]
254    fn painter_reports_intrinsic_size_and_bitmap() {
255        let bitmap = sample_bitmap();
256        let painter = BitmapPainter(bitmap.clone());
257        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
258        assert_eq!(painter.bitmap(), &bitmap);
259    }
260
261    #[test]
262    fn fit_keeps_aspect_ratio() {
263        let src = Size::new(200.0, 100.0);
264        let dst = Size::new(300.0, 300.0);
265        let result = ContentScale::Fit.scaled_size(src, dst);
266        assert_eq!(result, Size::new(300.0, 150.0));
267    }
268
269    #[test]
270    fn crop_fills_bounds() {
271        let src = Size::new(200.0, 100.0);
272        let dst = Size::new(300.0, 300.0);
273        let result = ContentScale::Crop.scaled_size(src, dst);
274        assert_eq!(result, Size::new(600.0, 300.0));
275    }
276
277    #[test]
278    fn destination_rect_aligns_center() {
279        let src = Size::new(200.0, 100.0);
280        let dst = Size::new(300.0, 300.0);
281        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
282        assert_eq!(
283            rect,
284            Rect {
285                x: 0.0,
286                y: 75.0,
287                width: 300.0,
288                height: 150.0,
289            }
290        );
291    }
292
293    // --- ImageMeasurePolicy tests ---
294
295    fn measure_image(intrinsic: Size, constraints: Constraints) -> Size {
296        let policy = ImageMeasurePolicy {
297            intrinsic_size: intrinsic,
298        };
299        policy.measure(&[], constraints).size
300    }
301
302    #[test]
303    fn image_measure_unconstrained() {
304        let size = measure_image(
305            Size::new(800.0, 600.0),
306            Constraints::loose(f32::INFINITY, f32::INFINITY),
307        );
308        assert_eq!(size, Size::new(800.0, 600.0));
309    }
310
311    #[test]
312    fn image_measure_width_constrained_preserves_aspect_ratio() {
313        // 800×600 constrained to max_width=400 → should scale to 400×300
314        let size = measure_image(
315            Size::new(800.0, 600.0),
316            Constraints::loose(400.0, f32::INFINITY),
317        );
318        assert_eq!(size, Size::new(400.0, 300.0));
319    }
320
321    #[test]
322    fn image_measure_height_constrained_preserves_aspect_ratio() {
323        // 800×600 constrained to max_height=300 → should scale to 400×300
324        let size = measure_image(
325            Size::new(800.0, 600.0),
326            Constraints::loose(f32::INFINITY, 300.0),
327        );
328        assert_eq!(size, Size::new(400.0, 300.0));
329    }
330
331    #[test]
332    fn image_measure_both_constrained_uses_smaller_factor() {
333        // 800×600 constrained to 200×400 → width is the bottleneck (0.25)
334        // scaled: 200×150
335        let size = measure_image(Size::new(800.0, 600.0), Constraints::loose(200.0, 400.0));
336        assert_eq!(size, Size::new(200.0, 150.0));
337    }
338
339    #[test]
340    fn image_measure_fits_within_constraints() {
341        // 200×100 in a 400×400 container → stays at intrinsic size
342        let size = measure_image(Size::new(200.0, 100.0), Constraints::loose(400.0, 400.0));
343        assert_eq!(size, Size::new(200.0, 100.0));
344    }
345
346    #[test]
347    fn image_measure_zero_intrinsic() {
348        let size = measure_image(Size::ZERO, Constraints::loose(400.0, 400.0));
349        assert_eq!(size, Size::new(0.0, 0.0));
350    }
351}