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;
8use crate::layout::policies::LeafMeasurePolicy;
9use crate::modifier::{Modifier, Rect, Size};
10use crate::render_state::current_density;
11use crate::widgets::Layout;
12use cranpose_core::NodeId;
13use cranpose_ui_graphics::{ColorFilter, DrawScope, ImageBitmap};
14
15pub const DEFAULT_ALPHA: f32 = 1.0;
16
17#[derive(Clone, Copy, Debug, PartialEq)]
18pub enum ContentScale {
19    Fit,
20    Crop,
21    FillBounds,
22    FillWidth,
23    FillHeight,
24    Inside,
25    None,
26}
27
28impl ContentScale {
29    pub fn scaled_size(self, src_size: Size, dst_size: Size) -> Size {
30        if src_size.width <= 0.0
31            || src_size.height <= 0.0
32            || dst_size.width <= 0.0
33            || dst_size.height <= 0.0
34        {
35            return Size::ZERO;
36        }
37
38        let scale_x = dst_size.width / src_size.width;
39        let scale_y = dst_size.height / src_size.height;
40
41        let (factor_x, factor_y) = match self {
42            Self::Fit => {
43                let factor = scale_x.min(scale_y);
44                (factor, factor)
45            }
46            Self::Crop => {
47                let factor = scale_x.max(scale_y);
48                (factor, factor)
49            }
50            Self::FillBounds => (scale_x, scale_y),
51            Self::FillWidth => (scale_x, scale_x),
52            Self::FillHeight => (scale_y, scale_y),
53            Self::Inside => {
54                if src_size.width <= dst_size.width && src_size.height <= dst_size.height {
55                    (1.0, 1.0)
56                } else {
57                    let factor = scale_x.min(scale_y);
58                    (factor, factor)
59                }
60            }
61            Self::None => (1.0, 1.0),
62        };
63
64        Size {
65            width: src_size.width * factor_x,
66            height: src_size.height * factor_y,
67        }
68    }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq, Hash)]
72pub struct Painter {
73    bitmap: ImageBitmap,
74}
75
76impl Painter {
77    pub fn from_bitmap(bitmap: ImageBitmap) -> Self {
78        Self { bitmap }
79    }
80
81    pub fn intrinsic_size(&self) -> Size {
82        self.bitmap.intrinsic_size()
83    }
84
85    pub fn bitmap(&self) -> &ImageBitmap {
86        &self.bitmap
87    }
88}
89
90impl From<ImageBitmap> for Painter {
91    fn from(value: ImageBitmap) -> Self {
92        Self::from_bitmap(value)
93    }
94}
95
96pub fn BitmapPainter(bitmap: ImageBitmap) -> Painter {
97    Painter::from_bitmap(bitmap)
98}
99
100fn destination_rect(
101    src_size: Size,
102    dst_size: Size,
103    alignment: Alignment,
104    content_scale: ContentScale,
105) -> Rect {
106    let draw_size = content_scale.scaled_size(src_size, dst_size);
107    let offset_x = alignment.horizontal.align(dst_size.width, draw_size.width);
108    let offset_y = alignment.vertical.align(dst_size.height, draw_size.height);
109    Rect {
110        x: offset_x,
111        y: offset_y,
112        width: draw_size.width,
113        height: draw_size.height,
114    }
115}
116
117#[composable]
118pub fn Image<P>(
119    painter: P,
120    content_description: Option<String>,
121    modifier: Modifier,
122    alignment: Alignment,
123    content_scale: ContentScale,
124    alpha: f32,
125    color_filter: Option<ColorFilter>,
126) -> NodeId
127where
128    P: Into<Painter> + Clone + PartialEq + 'static,
129{
130    let painter = painter.into();
131    let density = current_density().max(1.0);
132    let pixel_size = painter.intrinsic_size();
133    // Convert pixel dimensions to dp so the layout requests a size that maps
134    // back to the original pixel count after the renderer applies the scale
135    // factor, achieving 1:1 pixel-perfect rendering.
136    let intrinsic_dp = Size {
137        width: pixel_size.width / density,
138        height: pixel_size.height / density,
139    };
140    let draw_alpha = alpha.clamp(0.0, 1.0);
141    let draw_painter = painter.clone();
142
143    let semantics_modifier = if let Some(description) = content_description {
144        Modifier::empty().semantics(move |config| {
145            config.content_description = Some(description.clone());
146        })
147    } else {
148        Modifier::empty()
149    };
150
151    let image_modifier = modifier
152        .then(semantics_modifier)
153        .clip_to_bounds()
154        .draw_behind(move |scope: &mut dyn DrawScope| {
155            if draw_alpha <= 0.0 {
156                return;
157            }
158            let container_size = scope.size();
159            let rect = destination_rect(intrinsic_dp, container_size, alignment, content_scale);
160            if rect.width <= 0.0 || rect.height <= 0.0 {
161                return;
162            }
163            scope.draw_image_at(
164                rect,
165                draw_painter.bitmap().clone(),
166                draw_alpha,
167                color_filter,
168            );
169        });
170
171    Layout(image_modifier, LeafMeasurePolicy::new(intrinsic_dp), || {})
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::layout::core::Alignment;
178
179    fn sample_bitmap() -> ImageBitmap {
180        ImageBitmap::from_rgba8(4, 2, vec![255; 4 * 2 * 4]).expect("bitmap")
181    }
182
183    #[test]
184    fn painter_reports_intrinsic_size_and_bitmap() {
185        let bitmap = sample_bitmap();
186        let painter = BitmapPainter(bitmap.clone());
187        assert_eq!(painter.intrinsic_size(), Size::new(4.0, 2.0));
188        assert_eq!(painter.bitmap(), &bitmap);
189    }
190
191    #[test]
192    fn fit_keeps_aspect_ratio() {
193        let src = Size::new(200.0, 100.0);
194        let dst = Size::new(300.0, 300.0);
195        let result = ContentScale::Fit.scaled_size(src, dst);
196        assert_eq!(result, Size::new(300.0, 150.0));
197    }
198
199    #[test]
200    fn crop_fills_bounds() {
201        let src = Size::new(200.0, 100.0);
202        let dst = Size::new(300.0, 300.0);
203        let result = ContentScale::Crop.scaled_size(src, dst);
204        assert_eq!(result, Size::new(600.0, 300.0));
205    }
206
207    #[test]
208    fn destination_rect_aligns_center() {
209        let src = Size::new(200.0, 100.0);
210        let dst = Size::new(300.0, 300.0);
211        let rect = destination_rect(src, dst, Alignment::CENTER, ContentScale::Fit);
212        assert_eq!(
213            rect,
214            Rect {
215                x: 0.0,
216                y: 75.0,
217                width: 300.0,
218                height: 150.0,
219            }
220        );
221    }
222}