Skip to main content

smudge_widgets/
image.rs

1//! An image that stretches over its rectangle.
2//!
3//! The pixels travel as [`Image`], which the application builds from RGBA
4//! data it decoded off the UI thread. The widget paints no image while its
5//! source is empty, so an image that is still decoding holds a `None` until
6//! the pixels arrive. The style's background and border paint as they do on
7//! a panel, the image fills the content box inside the padding, and the
8//! style's [`corner_radius`](Element::corner_radius) rounds both.
9
10use smudge_core::element::{Element, Value};
11use smudge_core::geometry::{Corners, Size};
12use smudge_core::image::Image;
13use smudge_core::paint::PaintOp;
14use smudge_core::tree::Dirty;
15use smudge_core::widget::{MeasureInput, PaintCx, Panel, Widget};
16
17/// Creates an image that stretches its source over the rectangle its style
18/// gives it.
19///
20/// Without a fixed size it measures as the image's pixel size divided by the
21/// window's scale factor, so one image pixel is one logical pixel. With one
22/// axis fixed the other keeps the image's aspect ratio, and with both fixed
23/// the image stretches. A container that stretches its children, as a
24/// column does across its width by default, fixes that axis too, so the
25/// image scales to it; give the container any `align_items` but stretch to
26/// keep the image's pixel size. The image fills the content box, inside any
27/// padding and border, and the style's `corner_radius` rounds it.
28/// An empty source paints no image, only the style's background and border.
29///
30/// The source can be bound to a signal:
31///
32/// ```
33/// use smudge_core::reactive::{RwSignal, prelude::*};
34/// use smudge_widgets::image;
35///
36/// let source = RwSignal::new(None);
37/// let picture = image(move || source.get());
38/// ```
39pub fn image(source: impl Into<Value<Option<Image>>>) -> Element<ImageView> {
40    Element::new(ImageView { source: None }).bind(SourceKey, source.into(), set_source)
41}
42
43/// The key of an image's source binding.
44struct SourceKey;
45
46/// The widget behind [`image`]: it paints a panel's background and border,
47/// then one image stretched over its content box.
48///
49/// Build one with [`image`], which binds its source. The widget measures as
50/// the source's pixel size over the scale factor, so a source that appears,
51/// disappears or changes pixel size lays the node out again, and a swap
52/// between sources of the same size only repaints it.
53pub struct ImageView {
54    /// The pixels to draw, or `None` while there is nothing to show.
55    source: Option<Image>,
56}
57
58/// Replaces the image, and relays the node out when the replacement changes
59/// what the widget measures.
60///
61/// The widget measures as the image's pixel size, so a source that appears,
62/// disappears or changes pixel size must relay the node out: a paint-only
63/// update would leave the node's rectangle stale. A swap between images of
64/// the same size changes no layout answer, so it repaints alone.
65fn set_source(view: &mut ImageView, source: Option<Image>) -> Dirty {
66    let resized = match (&view.source, &source) {
67        (None, None) => false,
68        (Some(old), Some(new)) => old.width() != new.width() || old.height() != new.height(),
69        _ => true,
70    };
71    if view.source == source {
72        return Dirty::Clean;
73    }
74    view.source = source;
75    if resized { Dirty::Layout } else { Dirty::Paint }
76}
77
78impl Widget for ImageView {
79    /// Measures as the image's pixel size divided by the scale factor, so
80    /// one image pixel is one logical pixel.
81    ///
82    /// When one axis of the content box is fixed, by the style or by a
83    /// container that stretches the image, the other follows from the
84    /// image's aspect ratio. When both are, the image stretches to them.
85    ///
86    /// The answer depends on the source and the input alone, so it is the
87    /// same on every call with the same input, which the engine's caching
88    /// relies on.
89    fn measure(&mut self, input: MeasureInput) -> Option<Size> {
90        let image = self.source.as_ref()?;
91        let (width, height) = (image.width() as f32, image.height() as f32);
92        Some(match (input.known_width, input.known_height) {
93            (Some(known_width), Some(known_height)) => Size {
94                width: known_width,
95                height: known_height,
96            },
97            (Some(known_width), None) => Size {
98                width: known_width,
99                height: known_width * height / width,
100            },
101            (None, Some(known_height)) => Size {
102                width: known_height * width / height,
103                height: known_height,
104            },
105            (None, None) => Size {
106                width: width / input.scale,
107                height: height / input.scale,
108            },
109        })
110    }
111
112    /// Paints the style's background and border the way a panel does, then
113    /// the image stretched over the content box.
114    ///
115    /// The image's corners are the style's corner radius shrunk by the
116    /// border and padding next to them, so they stay concentric with the
117    /// outer corners.
118    fn paint(&self, cx: &PaintCx<'_>, out: &mut Vec<PaintOp>) {
119        Panel.paint(cx, out);
120        let Some(image) = &self.source else {
121            return;
122        };
123        let radius = cx.style.paint.corner_radius.resolve(cx.theme);
124        let left = cx.content.x0 - cx.rect.x0;
125        let top = cx.content.y0 - cx.rect.y0;
126        let right = cx.rect.x1 - cx.content.x1;
127        let bottom = cx.rect.y1 - cx.content.y1;
128        let inner = |a: f32, b: f32| (radius - a.max(b)).max(0.0);
129        out.push(PaintOp::Image {
130            rect: cx.content,
131            radius: Corners {
132                top_left: inner(left, top),
133                top_right: inner(right, top),
134                bottom_right: inner(right, bottom),
135                bottom_left: inner(left, bottom),
136            },
137            image: image.clone(),
138        });
139    }
140}