Skip to main content

ravenrs_widgets/
image.rs

1use std::cell::RefCell;
2use std::fmt;
3use std::rc::Rc;
4
5use ravenrs_core::{ImageData, ImageQuad, Rect, Role};
6use ravenrs_layout::{Align, LayoutStyle};
7
8use crate::View;
9use crate::context::{BuildCx, NodeId, Paint, Semantics};
10
11/// How an image fills its frame when their aspect ratios differ.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
15pub enum ContentMode {
16    /// Fills the frame, cropping the overflowing part (CSS `object-fit: cover`).
17    #[default]
18    Cover,
19    /// Shows the whole image, letterboxed inside the frame (`contain`).
20    Fit,
21    /// Stretches to the frame, ignoring the aspect ratio.
22    Fill,
23}
24
25/// Why image bytes could not be decoded.
26#[derive(Debug)]
27#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
28pub struct DecodeError(String);
29
30impl fmt::Display for DecodeError {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "the image could not be decoded: {}", self.0)
33    }
34}
35
36impl std::error::Error for DecodeError {}
37
38/// Decodes PNG or JPEG bytes into RGBA pixels.
39#[cfg(feature = "decode")]
40pub fn decode_image(bytes: &[u8]) -> Result<ImageData, DecodeError> {
41    let decoded = image::load_from_memory(bytes).map_err(|e| DecodeError(e.to_string()))?.into_rgba8();
42    let (w, h) = decoded.dimensions();
43    ImageData::from_rgba8(w, h, decoded.into_raw()).ok_or_else(|| DecodeError("empty image".into()))
44}
45
46/// Shows an image. Without a size it takes the image's own size in logical
47/// pixels; with only a width (or height) it keeps the aspect ratio. With
48/// [`crossfade`](Self::crossfade), a new image dissolves over the one before it.
49///
50/// ```
51/// use ravenrs_core::ImageData;
52/// use ravenrs_widgets::{Image, Ui};
53///
54/// let pixel = ImageData::from_rgba8(1, 1, vec![200, 80, 40, 255]).expect("one pixel");
55/// let photo = Image::new(pixel).size(160.0, 90.0).crossfade(0.2).alt("A photo");
56/// let mut ui = Ui::new(photo, ravenrs_style::Theme::raven_dark());
57/// ui.update();
58/// ```
59#[derive(Clone, Debug)]
60#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
61pub struct Image {
62    data: ImageData,
63    mode: ContentMode,
64    radius: f32,
65    width: Option<f32>,
66    height: Option<f32>,
67    stretch: bool,
68    opacity: f32,
69    alt: Option<String>,
70    crossfade: Option<f64>,
71}
72
73/// The image on the way out while another comes in.
74struct Fade {
75    current: ImageData,
76    previous: Option<ImageData>,
77    started: f64,
78}
79
80#[cfg_attr(feature = "live", ravenrs_live::expose)]
81impl Image {
82    /// An image of decoded RGBA pixels, `Cover` mode.
83    pub fn new(data: ImageData) -> Self {
84        Self {
85            data,
86            mode: ContentMode::Cover,
87            radius: 0.0,
88            width: None,
89            height: None,
90            stretch: false,
91            opacity: 1.0,
92            alt: None,
93            crossfade: None,
94        }
95    }
96
97    /// Decodes PNG or JPEG bytes (e.g. from `include_bytes!`).
98    #[cfg(feature = "decode")]
99    pub fn from_bytes(bytes: &[u8]) -> Result<Self, DecodeError> {
100        decode_image(bytes).map(Self::new)
101    }
102
103    /// How the image fills its frame (`Cover` by default).
104    pub fn content_mode(mut self, mode: ContentMode) -> Self {
105        self.mode = mode;
106        self
107    }
108
109    /// Corner radius in logical pixels (default 0).
110    pub fn corner_radius(mut self, radius: f32) -> Self {
111        self.radius = radius;
112        self
113    }
114
115    /// Width in logical pixels.
116    pub fn width(mut self, width: f32) -> Self {
117        self.width = Some(width);
118        self
119    }
120
121    /// Height in logical pixels.
122    pub fn height(mut self, height: f32) -> Self {
123        self.height = Some(height);
124        self
125    }
126
127    /// Width and height in logical pixels.
128    pub fn size(self, width: f32, height: f32) -> Self {
129        self.width(width).height(height)
130    }
131
132    /// Stretches across the parent stack (height follows the aspect ratio unless set).
133    pub fn stretch(mut self) -> Self {
134        self.stretch = true;
135        self
136    }
137
138    /// Describes the image for screen readers. Images without it are treated
139    /// as decoration and hidden from them.
140    pub fn alt(mut self, text: impl Into<String>) -> Self {
141        self.alt = Some(text.into());
142        self
143    }
144
145    /// Opacity, 0.0 (invisible) to 1.0 (opaque, the default).
146    pub fn opacity(mut self, opacity: f32) -> Self {
147        self.opacity = opacity;
148        self
149    }
150
151    /// When the image changes, dissolves from the old one to the new one over this many
152    /// seconds. It asks for frames only while that lasts, and with reduced motion the new
153    /// one simply appears. Pass a clone of the same [`ImageData`] while the picture is the
154    /// same: new data counts as a new image, even with the same pixels.
155    pub fn crossfade(mut self, seconds: f64) -> Self {
156        self.crossfade = Some(seconds);
157        self
158    }
159
160    /// The image going out, if one is, and when it started. Kept by this view, because the
161    /// app only ever hands over the current one.
162    fn fading_out(&self, cx: &mut BuildCx<'_>, seconds: f64) -> Option<(ImageData, f64)> {
163        let (data, now) = (self.data.clone(), cx.now());
164        let fade = cx
165            .use_state(|| Rc::new(RefCell::new(Fade { current: data, previous: None, started: 0.0 })))
166            .get();
167        let mut fade = fade.borrow_mut();
168        if fade.current != self.data {
169            let old = std::mem::replace(&mut fade.current, self.data.clone());
170            fade.previous = (!cx.reduced_motion() && seconds > 0.0).then_some(old);
171            fade.started = now;
172        }
173        if now >= fade.started + seconds {
174            fade.previous = None;
175        }
176        fade.previous.clone().map(|old| (old, fade.started))
177    }
178}
179
180/// Draws `data` into `frame` the way an [`Image`] does.
181fn push_image(
182    scene: &mut ravenrs_core::Scene,
183    data: &ImageData,
184    frame: Rect,
185    (mode, radius, opacity): (ContentMode, f32, f32),
186) {
187    if frame.is_empty() {
188        return;
189    }
190    let (rect, uv) = placement(mode, frame, data.aspect_ratio());
191    let mut quad = ImageQuad::new(rect, data.clone()).radius(radius);
192    quad.uv = uv;
193    quad.opacity = opacity;
194    scene.push_image(quad);
195}
196
197/// The destination rectangle and source UVs for drawing `aspect` into `frame`.
198pub(crate) fn placement(mode: ContentMode, frame: Rect, aspect: f32) -> (Rect, [f32; 4]) {
199    let frame_aspect = frame.width() / frame.height().max(f32::EPSILON);
200    match mode {
201        ContentMode::Fill => (frame, [0.0, 0.0, 1.0, 1.0]),
202        ContentMode::Cover => {
203            if frame_aspect > aspect {
204                let visible = aspect / frame_aspect;
205                let v0 = (1.0 - visible) / 2.0;
206                (frame, [0.0, v0, 1.0, v0 + visible])
207            } else {
208                let visible = frame_aspect / aspect;
209                let u0 = (1.0 - visible) / 2.0;
210                (frame, [u0, 0.0, u0 + visible, 1.0])
211            }
212        }
213        ContentMode::Fit => {
214            let (w, h) = if frame_aspect > aspect {
215                (frame.height() * aspect, frame.height())
216            } else {
217                (frame.width(), frame.width() / aspect)
218            };
219            let x = frame.x() + (frame.width() - w) / 2.0;
220            let y = frame.y() + (frame.height() - h) / 2.0;
221            (Rect::new(x, y, w, h), [0.0, 0.0, 1.0, 1.0])
222        }
223    }
224}
225
226impl View for Image {
227    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
228        let aspect = self.data.aspect_ratio();
229        let (width, height) = match (self.width, self.height, self.stretch) {
230            (None, None, false) => (Some(self.data.width() as f32), Some(self.data.height() as f32)),
231            (w, h, _) => (w, h),
232        };
233        let style = LayoutStyle {
234            width,
235            height,
236            aspect_ratio: (width.is_none() || height.is_none()).then_some(aspect),
237            align_self: self.stretch.then_some(Align::Stretch),
238            ..LayoutStyle::leaf()
239        };
240        let (data, look) = (self.data.clone(), (self.mode, self.radius, self.opacity));
241        let fading = self.crossfade.and_then(|seconds| Some((self.fading_out(cx, seconds)?, seconds)));
242        let paint = if let Some(((old, started), seconds)) = fading {
243            // At the end it builds once more, to the plain image that asks for nothing.
244            cx.wake_at(started + seconds);
245            Paint::Live(Rc::new(move |frame: Rect, time, scene| {
246                let t = (((time - started) / seconds) as f32).clamp(0.0, 1.0);
247                push_image(scene, &old, frame, (look.0, look.1, look.2 * (1.0 - t)));
248                push_image(scene, &data, frame, (look.0, look.1, look.2 * t));
249            }))
250        } else {
251            Paint::Custom(Rc::new(move |frame: Rect, scene| push_image(scene, &data, frame, look)))
252        };
253        let node = cx.container(style, &[], paint);
254        if let Some(alt) = &self.alt {
255            cx.semantics(node, Semantics::new(Role::Image).name(alt.clone()));
256        }
257        node
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn cover_crops_and_fit_letterboxes() {
267        let frame = Rect::new(0.0, 0.0, 200.0, 100.0);
268        // A square image in a 2:1 frame.
269        let (r, uv) = placement(ContentMode::Cover, frame, 1.0);
270        assert_eq!(r, frame);
271        assert_eq!(uv, [0.0, 0.25, 1.0, 0.75]);
272        let (r, uv) = placement(ContentMode::Fit, frame, 1.0);
273        assert_eq!(r, Rect::new(50.0, 0.0, 100.0, 100.0));
274        assert_eq!(uv, [0.0, 0.0, 1.0, 1.0]);
275    }
276
277    #[cfg(feature = "decode")]
278    #[test]
279    fn decodes_png() {
280        // A 1x1 opaque red PNG.
281        let png: &[u8] = &[
282            0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
283            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
284            0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
285            0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
286            0x44, 0xae, 0x42, 0x60, 0x82,
287        ];
288        let image = decode_image(png).expect("valid png");
289        assert_eq!((image.width(), image.height()), (1, 1));
290        assert_eq!(image.pixels(), &[255, 0, 0, 255]);
291        assert!(decode_image(b"not an image").is_err());
292    }
293}