Skip to main content

telar_ui_core/
image.rs

1use std::sync::Arc;
2
3use geometry_core::{ObjectFit, Rect};
4use layout_core::{LayoutError, LayoutStyle};
5use platform_core::Event;
6use renderer_core::{BorderRadius, DrawCommand, ImageData, ImageFilter};
7use ui_tree::{Component, EventResult, NodeVec, RenderNode};
8
9use crate::impl_leaf_widget;
10use crate::layout_leaf::LayoutLeaf;
11
12pub struct Image {
13    data: Box<dyn Fn() -> Arc<ImageData>>,
14    leaf: LayoutLeaf,
15    filter: Box<dyn Fn() -> ImageFilter>,
16    fit: Box<dyn Fn() -> ObjectFit>,
17    radius: BorderRadius,
18}
19
20impl Image {
21    pub fn new(
22        layout_style: LayoutStyle,
23        data_fn: impl Fn() -> Arc<ImageData> + 'static,
24        filter_fn: impl Fn() -> ImageFilter + 'static,
25        fit_fn: impl Fn() -> ObjectFit + 'static,
26    ) -> Result<Self, LayoutError> {
27        // A side left at `auto` falls back to the bitmap's intrinsic size; a single px side derives the other from the intrinsic aspect ratio; a percent side is left untouched.
28        let layout_style = crate::layout_leaf::resolve_intrinsic_size(layout_style, || {
29            let d = data_fn();
30            (d.width as f32, d.height as f32)
31        });
32
33        let leaf = LayoutLeaf::register(layout_style)?;
34        Ok(Self {
35            data: Box::new(data_fn),
36            leaf,
37            filter: Box::new(filter_fn),
38            fit: Box::new(fit_fn),
39            radius: BorderRadius::zero(),
40        })
41    }
42
43    /// Rounds the picture's own corners.
44    ///
45    /// A `StyledContainer` around it cannot do this — its radius rounds the *fill* it paints, and a bitmap child
46    /// draws over that — so a thumbnail, an avatar or a cover in a rounded UI had no way to be anything but a
47    /// square. The clip primitive already carries a radius; this is the widget passing one through.
48    pub fn with_radius(mut self, radius: f32) -> Self {
49        self.radius = BorderRadius::all(radius.max(0.0));
50        self
51    }
52
53    /// Rounds each corner separately, for a picture that meets an edge on one side only.
54    pub fn with_border_radius(mut self, radius: BorderRadius) -> Self {
55        self.radius = radius;
56        self
57    }
58}
59
60impl Component for Image {
61    fn view(&self) -> RenderNode {
62        let r = self.leaf.rect.get();
63        let r_local = Rect {
64            x: 0.0,
65            y: 0.0,
66            width: r.width,
67            height: r.height,
68        };
69        let data = (self.data)();
70        let (content, clip) = geometry_core::fit_rect(
71            (data.width as f32, data.height as f32),
72            r_local,
73            (self.fit)(),
74        );
75        let image = RenderNode::Primitive(DrawCommand::Image {
76            data,
77            rect: content,
78            filter: (self.filter)(),
79        });
80        // Cover overflows the box; clip it to the local box. The renderer maps clip rects through the active matrix, so a local (0,0,w,h) clip composes with this widget's layout transform and any scroll. A radius is the other reason to clip, and it applies to a `Contain` fit that overflows nothing too.
81        let node = if clip || !self.radius.is_zero() {
82            RenderNode::Clip {
83                rect: r_local,
84                radius: self.radius,
85                children: NodeVec::collect([image]),
86            }
87        } else {
88            image
89        };
90        self.leaf.at_layout_position(node)
91    }
92
93    fn on_event(&mut self, _event: &Event) -> EventResult {
94        EventResult::Ignored
95    }
96
97    fn debug_name(&self) -> &'static str {
98        "Image"
99    }
100}
101
102impl_leaf_widget!(Image);
103
104#[cfg(test)]
105mod tests {
106    use crate::context::reset_layout_runtime;
107    use layout_core::AvailableSpace;
108
109    use super::*;
110    use crate::context::{compute_layout, new_container};
111    use crate::layout_item::LayoutItem;
112
113    #[test]
114    fn image_without_size_uses_intrinsic_size() {
115        reset_layout_runtime();
116        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
117        let image = Image::new(
118            LayoutStyle::new(),
119            move || Arc::clone(&data),
120            || ImageFilter::Linear,
121            || ObjectFit::Contain,
122        )
123        .unwrap();
124        let root = new_container(
125            LayoutStyle::new().flex_column().width(200.0).height(200.0),
126            &[image.layout_node()],
127        )
128        .unwrap();
129        compute_layout(
130            root,
131            AvailableSpace::Definite(200.0),
132            AvailableSpace::Definite(200.0),
133        )
134        .unwrap();
135
136        let rect = image.leaf.rect.get();
137        assert_eq!(rect.width, 40.0);
138        assert_eq!(rect.height, 20.0);
139    }
140
141    /// A radius has to reach the render tree even on a fit that overflows nothing, since `Contain` takes the
142    /// early return that skips the clip entirely.
143    #[test]
144    fn a_radius_clips_the_picture_whatever_the_fit() {
145        reset_layout_runtime();
146        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
147        let image = Image::new(
148            LayoutStyle::new().width(40.0).height(20.0),
149            move || Arc::clone(&data),
150            || ImageFilter::Linear,
151            || ObjectFit::Contain,
152        )
153        .unwrap()
154        .with_radius(6.0);
155
156        let radius_of = |node: &RenderNode| -> Option<BorderRadius> {
157            let mut current = node;
158            loop {
159                match current {
160                    RenderNode::Clip { radius, .. } => return Some(*radius),
161                    RenderNode::Transform { children, .. } | RenderNode::Layer { children, .. } => {
162                        current = children.first()?;
163                    }
164                    _ => return None,
165                }
166            }
167        };
168        assert_eq!(
169            radius_of(&image.view()).map(|r| r.top_left),
170            Some(6.0),
171            "the clip carries the radius the caller asked for"
172        );
173    }
174
175    #[test]
176    fn image_single_side_derives_aspect() {
177        reset_layout_runtime();
178        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
179        // Width pinned, height auto → height follows the 2:1 intrinsic aspect ratio.
180        let image = Image::new(
181            LayoutStyle::new().width(100.0),
182            move || Arc::clone(&data),
183            || ImageFilter::Linear,
184            || ObjectFit::Contain,
185        )
186        .unwrap();
187        let root = new_container(
188            LayoutStyle::new().flex_column().width(300.0).height(300.0),
189            &[image.layout_node()],
190        )
191        .unwrap();
192        compute_layout(
193            root,
194            AvailableSpace::Definite(300.0),
195            AvailableSpace::Definite(300.0),
196        )
197        .unwrap();
198
199        let rect = image.leaf.rect.get();
200        assert_eq!(rect.width, 100.0);
201        assert_eq!(rect.height, 50.0);
202    }
203}