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(self, radius: f32) -> Self {
49        self.with_border_radius(BorderRadius::all(radius))
50    }
51
52    /// Rounds each corner separately, for a picture that meets an edge on one side only.
53    pub fn with_border_radius(mut self, radius: BorderRadius) -> Self {
54        // A negative corner is not a shape the clip can answer, and it reaches here from a `radius:` the
55        // author typed rather than from anything the widget controls.
56        self.radius = BorderRadius {
57            top_left: radius.top_left.max(0.0),
58            top_right: radius.top_right.max(0.0),
59            bottom_right: radius.bottom_right.max(0.0),
60            bottom_left: radius.bottom_left.max(0.0),
61        };
62        self
63    }
64}
65
66impl Component for Image {
67    fn view(&self) -> RenderNode {
68        let r = self.leaf.rect.get();
69        let r_local = Rect {
70            x: 0.0,
71            y: 0.0,
72            width: r.width,
73            height: r.height,
74        };
75        let data = (self.data)();
76        let (content, clip) = geometry_core::fit_rect(
77            (data.width as f32, data.height as f32),
78            r_local,
79            (self.fit)(),
80        );
81        let image = RenderNode::Primitive(DrawCommand::Image {
82            data,
83            rect: content,
84            filter: (self.filter)(),
85        });
86        // 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.
87        let node = if clip || !self.radius.is_zero() {
88            RenderNode::Clip {
89                rect: r_local,
90                radius: self.radius,
91                children: NodeVec::collect([image]),
92            }
93        } else {
94            image
95        };
96        self.leaf.at_layout_position(node)
97    }
98
99    fn on_event(&mut self, _event: &Event) -> EventResult {
100        EventResult::Ignored
101    }
102
103    fn debug_name(&self) -> &'static str {
104        "Image"
105    }
106}
107
108impl_leaf_widget!(Image);
109
110#[cfg(test)]
111mod tests {
112    use crate::context::reset_layout_runtime;
113    use layout_core::AvailableSpace;
114
115    use super::*;
116    use crate::context::{compute_layout, new_container};
117    use crate::layout_item::LayoutItem;
118
119    #[test]
120    fn image_without_size_uses_intrinsic_size() {
121        reset_layout_runtime();
122        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
123        let image = Image::new(
124            LayoutStyle::new(),
125            move || Arc::clone(&data),
126            || ImageFilter::Linear,
127            || ObjectFit::Contain,
128        )
129        .unwrap();
130        let root = new_container(
131            LayoutStyle::new().flex_column().width(200.0).height(200.0),
132            &[image.layout_node()],
133        )
134        .unwrap();
135        compute_layout(
136            root,
137            AvailableSpace::Definite(200.0),
138            AvailableSpace::Definite(200.0),
139        )
140        .unwrap();
141
142        let rect = image.leaf.rect.get();
143        assert_eq!(rect.width, 40.0);
144        assert_eq!(rect.height, 20.0);
145    }
146
147    /// A radius has to reach the render tree even on a fit that overflows nothing, since `Contain` takes the
148    /// early return that skips the clip entirely.
149    #[test]
150    fn a_radius_clips_the_picture_whatever_the_fit() {
151        reset_layout_runtime();
152        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
153        let image = Image::new(
154            LayoutStyle::new().width(40.0).height(20.0),
155            move || Arc::clone(&data),
156            || ImageFilter::Linear,
157            || ObjectFit::Contain,
158        )
159        .unwrap()
160        .with_radius(6.0);
161
162        let radius_of = |node: &RenderNode| -> Option<BorderRadius> {
163            let mut current = node;
164            loop {
165                match current {
166                    RenderNode::Clip { radius, .. } => return Some(*radius),
167                    RenderNode::Transform { children, .. } | RenderNode::Layer { children, .. } => {
168                        current = children.first()?;
169                    }
170                    _ => return None,
171                }
172            }
173        };
174        assert_eq!(
175            radius_of(&image.view()).map(|r| r.top_left),
176            Some(6.0),
177            "the clip carries the radius the caller asked for"
178        );
179    }
180
181    #[test]
182    fn image_single_side_derives_aspect() {
183        reset_layout_runtime();
184        let data = Arc::new(ImageData::new(vec![0u8; 40 * 20 * 4], 40, 20));
185        // Width pinned, height auto → height follows the 2:1 intrinsic aspect ratio.
186        let image = Image::new(
187            LayoutStyle::new().width(100.0),
188            move || Arc::clone(&data),
189            || ImageFilter::Linear,
190            || ObjectFit::Contain,
191        )
192        .unwrap();
193        let root = new_container(
194            LayoutStyle::new().flex_column().width(300.0).height(300.0),
195            &[image.layout_node()],
196        )
197        .unwrap();
198        compute_layout(
199            root,
200            AvailableSpace::Definite(300.0),
201            AvailableSpace::Definite(300.0),
202        )
203        .unwrap();
204
205        let rect = image.leaf.rect.get();
206        assert_eq!(rect.width, 100.0);
207        assert_eq!(rect.height, 50.0);
208    }
209}