Skip to main content

frus_widgets/
image.rs

1//! [`Image`]: displays a bitmap image ([`frus_core::ImageHandle`]), fitted according to
2//! a [`BoxFit`] — aspect preserved, letterboxed or cropped. The image is uploaded
3//! **once** then cached by the renderer, keyed on the `ImageData`'s identity.
4//!
5//! **How big is it?** Whatever you say, and failing that whatever the bitmap is. The
6//! reference's rule, and the only one that lets an image be shown by someone who does not
7//! already know its pixel dimensions:
8//!
9//! | given | box |
10//! |---|---|
11//! | width and height | that box |
12//! | one of the two | the other from the image's own ratio |
13//! | neither | the image's own size |
14
15use frus_core::{Alignment, AlignmentGeometry, BoxFit, Color, Curve, ImageHandle, Rect, Scene};
16use frus_layout::{Dimension, Style};
17
18use crate::interaction::Status;
19use crate::skeleton::Skeleton;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23/// Turns file bytes into pixels, through whichever decoder this build carries — and says
24/// so in the console when it cannot. [`frus_core::cached`] and [`frus_core::fetched`] call
25/// this once per image, so it is said once; an image that paints nothing with no word
26/// anywhere is otherwise found on a device (milestone 626).
27fn decode(bytes: &[u8]) -> Result<frus_core::ImageData, String> {
28    decode_in_this_build(bytes).inspect_err(|why| {
29        log::warn!(
30            "frus: an image of {} bytes could not be decoded: {why}",
31            bytes.len()
32        )
33    })
34}
35
36#[cfg(feature = "images")]
37fn decode_in_this_build(bytes: &[u8]) -> Result<frus_core::ImageData, String> {
38    frus_image::decode(bytes).map_err(|e| e.message().to_string())
39}
40
41/// Without the `images` feature there is no decoder, and saying so is the whole job.
42/// It must not panic: an application that dropped the feature deliberately, or a test
43/// binary built with `--no-default-features`, is not a broken program. The message says
44/// how to get it back, because the usual way to lose it is `default-features = false`,
45/// written to drop the bundled fonts.
46#[cfg(not(feature = "images"))]
47fn decode_in_this_build(_bytes: &[u8]) -> Result<frus_core::ImageData, String> {
48    Err(
49        "no image decoder: this build dropped the `images` feature, which \
50         `default-features = false` turns off; add `features = [\"images\"]` to the frus \
51         dependency"
52            .to_string(),
53    )
54}
55
56/// Where an [`Image`]'s pixels are: here, on their way, or never coming.
57///
58/// Three and not two, because *not here yet* and *will never be here* are different
59/// answers and an interface shows them differently — one is a placeholder, the other is
60/// a message.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub enum State<'a> {
63    /// The pixels are here.
64    Ready,
65    /// In flight; a later frame will have them. Only [`Image::network`] reports this.
66    Loading,
67    /// They will not arrive, and this is why.
68    Failed(&'a str),
69}
70
71/// The same three, owning what they carry.
72enum Source {
73    Ready(ImageHandle),
74    Loading,
75    Failed(String),
76}
77
78/// How long an image takes to cross over its placeholder — the reference's own figure
79/// for `FadeInImage` (`fade_in_image.dart:37`).
80pub const IMAGE_FADE: f32 = 0.7;
81
82/// **What stands in for an image while it is on its way.**
83///
84/// Not [`crate::Placeholder`], which is the design tool's crossed box: this one is the
85/// slot [`Image::placeholder`] fills.
86///
87/// Three, because they are the three things anybody puts there: a flat colour, the
88/// shimmering block a list of thumbnails wants, or a smaller picture — a blurred
89/// thumbnail, a low-resolution copy, the one already in the cache.
90///
91/// It is **not a widget slot**, and that is deliberate: [`Image`] carries no message type
92/// (`impl<Msg> Widget<Msg> for Image`), which is what lets one be built once and dropped
93/// into any tree. Taking an arbitrary child would make it `Image<Msg>` and cost that
94/// everywhere, to buy a fourth case nobody has asked for.
95pub enum ImagePlaceholder {
96    /// A flat colour.
97    Color(Color),
98    /// The shimmering block, at whatever shape it was built with.
99    Skeleton(Skeleton),
100    /// Another picture: a blur, a thumbnail, the cached copy.
101    Image(Box<Image>),
102}
103
104impl From<Color> for ImagePlaceholder {
105    fn from(color: Color) -> Self {
106        ImagePlaceholder::Color(color)
107    }
108}
109
110impl From<Skeleton> for ImagePlaceholder {
111    fn from(skeleton: Skeleton) -> Self {
112        ImagePlaceholder::Skeleton(skeleton)
113    }
114}
115
116impl From<Image> for ImagePlaceholder {
117    fn from(image: Image) -> Self {
118        ImagePlaceholder::Image(Box::new(image))
119    }
120}
121
122impl ImagePlaceholder {
123    /// Draws the stand-in into `bounds` at `opacity`.
124    fn paint(&self, bounds: Rect, status: &Status, theme: &Theme, scene: &mut Scene) {
125        match self {
126            ImagePlaceholder::Color(color) => scene.fill_rect(bounds, color.fade(status.opacity)),
127            // Both of these are widgets already, and both are free of a message type, so
128            // they are painted where they stand rather than mounted as children. A child
129            // would have to be laid out, and the whole of what a placeholder wants is the
130            // box the image was going to have.
131            ImagePlaceholder::Skeleton(skeleton) => {
132                Widget::<()>::paint(skeleton, bounds, *status, theme, scene)
133            }
134            ImagePlaceholder::Image(image) => {
135                Widget::<()>::paint(image.as_ref(), bounds, *status, theme, scene)
136            }
137        }
138    }
139}
140
141/// An image, fitted by `fit` into whatever box it ends up with.
142pub struct Image {
143    /// The pixels, why there are none, or that they are still on their way.
144    source: Source,
145    width: Option<f32>,
146    height: Option<f32>,
147    fit: BoxFit,
148    tint: Option<Color>,
149    opacity: f32,
150    alignment: AlignmentGeometry,
151    /// What a screen reader is told this picture is; see [`Image::semantic_label`].
152    semantic_label: Option<String>,
153    /// Whether to leave it out of the tree a screen reader walks.
154    exclude_from_semantics: bool,
155    /// Whether the picture is **mirrored** in a right-to-left reading direction.
156    match_text_direction: bool,
157    /// What stands in while the pixels are on their way; `None` leaves the box empty,
158    /// which is what an image has always done.
159    placeholder: Option<ImagePlaceholder>,
160    /// How long the picture takes to cross over the placeholder.
161    fade: f32,
162    fade_curve: Curve,
163}
164
165impl Image {
166    /// An image at **its own size**, fitted with [`BoxFit::Contain`] by default.
167    pub fn new(image: ImageHandle) -> Self {
168        Self::from_source(Source::Ready(image))
169    }
170
171    /// An image **decoded from embedded bytes** — what `include_bytes!` gives, and the
172    /// answer to "how do I just show my logo".
173    ///
174    /// ```ignore
175    /// Image::memory(include_bytes!("../assets/logo.png")).width(96.0)
176    /// ```
177    ///
178    /// The bytes are decoded **once per process**, not once per frame. A view is rebuilt
179    /// every frame and decoding a PNG is not free, so the result is kept in a shared
180    /// store keyed by where the bytes live — see [`frus_core::cached`] for why an
181    /// address is the right key and why the `'static` bound is what makes it sound.
182    ///
183    /// **Failure paints nothing.** A file that is not an image does not become one on the
184    /// next frame, so the failure is remembered rather than retried, and the widget
185    /// occupies whatever box it was given without drawing into it. That is the
186    /// reference's behaviour for an image with no error widget supplied, and
187    /// [`Image::error`] is how an application asks what went wrong.
188    ///
189    /// Needs the `images` feature, which is on by default. Without it this reports a
190    /// missing decoder rather than panicking — the same rule the bundled fonts follow.
191    pub fn memory(bytes: &'static [u8]) -> Self {
192        Self::from_source(match frus_core::cached(bytes, decode) {
193            Ok(handle) => Source::Ready(handle),
194            Err(why) => Source::Failed(why),
195        })
196    }
197
198    /// An image **fetched over the network**, decoded and shown when it arrives.
199    ///
200    /// ```ignore
201    /// Image::network("https://example.com/ada.png").width(240.0)
202    /// ```
203    ///
204    /// The **first** frame that asks starts the request and reports [`State::Loading`];
205    /// every later frame is a lookup. That is what makes this safe to write in a view,
206    /// which runs sixty times a second — a fetch per frame would be sixty requests for
207    /// one picture. The interface keeps drawing while anything is in flight, so the
208    /// frame that shows the picture does happen without the application arranging it.
209    ///
210    /// There is no `loading` or `error` **slot** here, and that is the framework's model
211    /// rather than a gap. A view is a function of state, and this state is readable:
212    /// [`Image::state`] answers, and the application writes the `match` it would have
213    /// written anyway.
214    ///
215    /// ```ignore
216    /// let photo = Image::network(&url);
217    /// match photo.state() {
218    ///     State::Loading => CircularProgressIndicator::new().boxed(),
219    ///     State::Failed(why) => text(why).boxed(),
220    ///     State::Ready => photo.width(240.0).boxed(),
221    /// }
222    /// ```
223    ///
224    /// A closure stored in the widget would say the same thing in a place the
225    /// application cannot see it, and would need the widget to carry the message type
226    /// for the sake of a branch the view can already take.
227    ///
228    /// Needs a registered fetcher — [`frus_core::set_image_fetcher`] — which the shell
229    /// installs on the way up when built with the `net` feature. Without one this fails
230    /// with a message saying so, rather than waiting for ever.
231    pub fn network(url: impl AsRef<str>) -> Self {
232        Self::from_source(match frus_core::fetched(url.as_ref(), decode) {
233            frus_core::Fetched::Loading => Source::Loading,
234            frus_core::Fetched::Ready(handle) => Source::Ready(handle),
235            frus_core::Fetched::Failed(why) => Source::Failed(why),
236        })
237    }
238
239    /// Where this image has got to: here, on its way, or never coming.
240    ///
241    /// An embedded image is only ever [`State::Ready`] or [`State::Failed`] — there is
242    /// nothing to wait for. [`Image::network`] is the one that can be loading.
243    pub fn state(&self) -> State<'_> {
244        match &self.source {
245            Source::Ready(_) => State::Ready,
246            Source::Loading => State::Loading,
247            Source::Failed(why) => State::Failed(why),
248        }
249    }
250
251    /// Why there are no pixels, if there are none.
252    ///
253    /// An application that wants to put something else in the gap — a placeholder, a
254    /// message, a retry — asks here and builds it itself. That is a `match` in the view
255    /// rather than a closure the widget stores, which is the honest shape while the
256    /// pixels are something the application already holds.
257    ///
258    /// An image still **loading** has no error: it has not failed, it has not arrived.
259    /// [`Image::state`] is the one that tells the three apart.
260    pub fn error(&self) -> Option<&str> {
261        match &self.source {
262            Source::Failed(why) => Some(why),
263            _ => None,
264        }
265    }
266
267    /// The decoded pixels, once they are here: `None` while they are on their way or when
268    /// they will never come. This is how a picture loaded by `Image::memory` or
269    /// `Image::network` is painted by something other than an `Image` — a decoration, an
270    /// avatar — with the same loading and the same cache (milestone 647).
271    pub fn handle(&self) -> Option<frus_core::ImageHandle> {
272        match &self.source {
273            Source::Ready(handle) => Some(handle.clone()),
274            _ => None,
275        }
276    }
277
278    fn from_source(source: Source) -> Self {
279        Self {
280            source,
281            width: None,
282            height: None,
283            fit: BoxFit::Contain,
284            tint: None,
285            opacity: 1.0,
286            alignment: AlignmentGeometry::Physical(Alignment::CENTER),
287            semantic_label: None,
288            exclude_from_semantics: false,
289            match_text_direction: false,
290            placeholder: None,
291            fade: IMAGE_FADE,
292            fade_curve: Curve::ease_out(),
293        }
294    }
295
296    /// Sets the width, in logical pixels. With no height, the height follows from the
297    /// image's own ratio.
298    pub fn width(mut self, width: f32) -> Self {
299        self.width = Some(width);
300        self
301    }
302
303    /// Sets the height, in logical pixels. With no width, the width follows from the
304    /// image's own ratio.
305    pub fn height(mut self, height: f32) -> Self {
306        self.height = Some(height);
307        self
308    }
309
310    /// Both at once.
311    pub fn size(self, width: f32, height: f32) -> Self {
312        self.width(width).height(height)
313    }
314
315    /// Changes the fit mode.
316    pub fn fit(mut self, fit: BoxFit) -> Self {
317        self.fit = fit;
318        self
319    }
320
321    /// Where the image sits in a box it does not fill, and which part of it survives a
322    /// box it overflows. Centre by default, as in the reference.
323    ///
324    /// It is the same anchor either way round: aligning to the top means the top of the
325    /// box for a letterboxed image and the top of the *image* for a cropped one, which
326    /// is the answer a photograph of a person wants in both cases.
327    pub fn alignment(mut self, alignment: impl Into<AlignmentGeometry>) -> Self {
328        self.alignment = alignment.into();
329        self
330    }
331
332    /// A multiplied tint, white leaving it unchanged — for bitmap icons, say.
333    pub fn tint(mut self, tint: Color) -> Self {
334        self.tint = Some(tint);
335        self
336    }
337
338    /// Draws the image at a fraction of its opacity, `1.0` being fully opaque.
339    ///
340    /// Unlike a group opacity this needs no layer: an image is one primitive, so the
341    /// fade goes into the tint it is already multiplied by.
342    pub fn opacity(mut self, opacity: f32) -> Self {
343        self.opacity = opacity.clamp(0.0, 1.0);
344        self
345    }
346
347    /// What a screen reader says instead of the picture.
348    ///
349    /// A picture with no label is a picture nobody reading by ear can see, and until
350    /// this existed **every** image in every application was one: the vocabulary had
351    /// [`frus_core::Role::Image`] in it and nothing ever emitted it.
352    ///
353    /// Say what the picture *is*, not that it is a picture — a reader is already told
354    /// the role. If it carries no meaning of its own, do not label it: reach for
355    /// [`exclude_from_semantics`](Image::exclude_from_semantics) instead.
356    pub fn semantic_label(mut self, label: impl Into<String>) -> Self {
357        self.semantic_label = Some(label.into());
358        self
359    }
360
361    /// Leaves the image out of the tree a screen reader walks.
362    ///
363    /// The right answer for **decoration** — a divider, a texture, a shape behind a
364    /// heading. Announcing those interrupts a reader with something that was never
365    /// meant to be read, and an empty label would still announce the role. This is the
366    /// reference's `excludeFromSemantics`, and like it, it wins over any label given.
367    pub fn exclude_from_semantics(mut self, exclude: bool) -> Self {
368        self.exclude_from_semantics = exclude;
369        self
370    }
371
372    /// Mirrors the image horizontally when the reading direction is right-to-left.
373    ///
374    /// **Off** by default, which is the reference's default and the right one: an image
375    /// is a picture rather than a run of text, and a photograph of a person does not
376    /// want to be flipped because the interface is in Arabic. It is a per-image
377    /// decision, so it is a per-image switch.
378    ///
379    /// Turn it on for a picture that **points**: an arrow meaning *forward*, a
380    /// speech bubble with a tail, a hand indicating the next step. Those follow the
381    /// direction the reader's eye travels, and in RTL that is the other way round.
382    pub fn match_text_direction(mut self, match_direction: bool) -> Self {
383        self.match_text_direction = match_direction;
384        self
385    }
386
387    /// **What stands in while the pixels are on their way**, and what the picture crosses
388    /// over when they arrive.
389    ///
390    /// Takes a colour, a [`Skeleton`], or another [`Image`] — see [`ImagePlaceholder`]. Without
391    /// one the box stays empty and the picture appears in a single frame, which is what an
392    /// image has always done here and what a list of thumbnails should not do.
393    ///
394    /// ```ignore
395    /// Image::network(url).width(120.0).placeholder(Skeleton::new().height(90.0))
396    /// ```
397    #[must_use]
398    pub fn placeholder(mut self, placeholder: impl Into<ImagePlaceholder>) -> Self {
399        self.placeholder = Some(placeholder.into());
400        self
401    }
402
403    /// How long the crossing takes, in seconds. [`IMAGE_FADE`] — the reference's 700 ms —
404    /// unless a caller says otherwise. `0.0` is a hard cut with a placeholder before it.
405    #[must_use]
406    pub fn fade_duration(mut self, seconds: f32) -> Self {
407        self.fade = seconds.max(0.0);
408        self
409    }
410
411    /// The crossing's curve; [`Curve::ease_out`](Curve::ease_out) by
412    /// default, so the picture arrives quickly and settles.
413    #[must_use]
414    pub fn fade_curve(mut self, curve: Curve) -> Self {
415        self.fade_curve = curve;
416        self
417    }
418
419    /// Whether the picture is here.
420    fn ready(&self) -> bool {
421        matches!(self.source, Source::Ready(_))
422    }
423
424    /// The box this asks for, given the bitmap's own size.
425    fn box_style(&self) -> Style {
426        // Nothing decoded: there is no natural size to fall back on, so an image given
427        // no measurements takes no room at all. Anything the caller *did* say is still
428        // honoured, which is what keeps a layout from jumping when one asset is broken.
429        let Source::Ready(image) = &self.source else {
430            return Style {
431                width: self.width.map_or(Dimension::Length(0.0), Dimension::Length),
432                height: self
433                    .height
434                    .map_or(Dimension::Length(0.0), Dimension::Length),
435                ..Default::default()
436            };
437        };
438        let natural = image.size();
439        let ratio = if natural.height > 0.0 {
440            natural.width / natural.height
441        } else {
442            1.0
443        };
444        match (self.width, self.height) {
445            (Some(w), Some(h)) => Style {
446                width: Dimension::Length(w),
447                height: Dimension::Length(h),
448                ..Default::default()
449            },
450            // One side and a ratio: the layout engine derives the other, which is the
451            // reference's rule and the reason an image can be given a width alone.
452            (Some(w), None) => Style {
453                width: Dimension::Length(w),
454                aspect_ratio: Some(ratio),
455                ..Default::default()
456            },
457            (None, Some(h)) => Style {
458                height: Dimension::Length(h),
459                aspect_ratio: Some(ratio),
460                ..Default::default()
461            },
462            (None, None) => Style {
463                width: Dimension::Length(natural.width),
464                height: Dimension::Length(natural.height),
465                ..Default::default()
466            },
467        }
468    }
469}
470
471impl<Msg> Widget<Msg> for Image {
472    fn style(&self) -> Style {
473        self.box_style()
474    }
475
476    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
477        &[]
478    }
479
480    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
481        self.paint_tinted(bounds, status, theme, scene, None);
482    }
483
484    /// **Where the crossing is going**: all the way over once the pixels are here, and
485    /// nowhere at all until then.
486    ///
487    /// Only claimed when there is a placeholder, so an image without one costs the runtime
488    /// nothing and keeps the behaviour it has always had.
489    fn anim_target(&self) -> Option<f32> {
490        self.placeholder
491            .as_ref()
492            .map(|_| if self.ready() { 1.0 } else { 0.0 })
493    }
494
495    /// **The crossing has a duration in one direction only.**
496    ///
497    /// Coming in it takes its time: that is the whole point. Going back — a source
498    /// replaced at the same place in the tree, so the widget is loading again — it is
499    /// instant, because there is nothing left to cross *from*. The pixels of the picture
500    /// that was there are already gone by the time this is asked; tweening down would fade
501    /// the placeholder **in** over three quarters of a second while nothing else was
502    /// drawn, which reads as a stall rather than as a change.
503    ///
504    /// This is also what answers *do not restart on a rebuild*. The value lives against
505    /// the widget's place in the tree and survives every rebuild, so nothing restarts
506    /// while the source stands still; and a source that changes passes through *not
507    /// ready*, which resets it to nought in one frame and lets the next arrival cross
508    /// properly. No record of which image this timeline is about is needed, because the
509    /// journey through the middle is the record.
510    fn anim_duration(&self) -> f32 {
511        match self.ready() {
512            true => self.fade,
513            false => 0.0,
514        }
515    }
516
517    fn anim_curve(&self) -> Curve {
518        self.fade_curve.clone()
519    }
520
521    fn on_click(&self) -> Option<Msg> {
522        None
523    }
524
525    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
526        if self.exclude_from_semantics {
527            return None;
528        }
529        let mut semantics = frus_core::SemanticsProperties::new(frus_core::Role::Image);
530        if let Some(label) = self.semantic_label.as_deref() {
531            semantics = semantics.label(label);
532        }
533        Some(semantics)
534    }
535}
536
537impl Image {
538    /// The paint, with a **tint the caller supplies** rather than the one on the widget —
539    /// what [`ImageIcon`] needs, since it holds an `Image` by reference and cannot rebuild
540    /// one to colour it.
541    pub(crate) fn paint_tinted(
542        &self,
543        bounds: Rect,
544        status: Status,
545        theme: &Theme,
546        scene: &mut Scene,
547        over: Option<Color>,
548    ) {
549        // **How far across the crossing is**, and it is only asked for when there is
550        // something to cross over from. Without a placeholder the picture is drawn at its
551        // own opacity as it always was, so nothing already written changes.
552        let across = match self.placeholder {
553            Some(_) => status.value.clamp(0.0, 1.0),
554            None => 1.0,
555        };
556        if let Some(placeholder) = &self.placeholder {
557            if across < 1.0 {
558                let under = Status {
559                    opacity: status.opacity * (1.0 - across),
560                    ..status
561                };
562                placeholder.paint(bounds, &under, theme, scene);
563            }
564        }
565        // The **alignment** stays physical whatever the direction. An image is a
566        // picture rather than a run of text: a portrait aligned to the top of its
567        // crop wants the top of its crop in every language. Mirroring is the separate,
568        // opt-in question below, which is how the reference splits it too.
569        // Nothing decoded, nothing drawn: the box stays empty rather than showing a
570        // stand-in nobody asked for. `Image::error` is how an application finds out.
571        let Source::Ready(image) = &self.source else {
572            return;
573        };
574        let align = self.alignment.resolve(frus_core::TextDirection::Ltr);
575        let (dst, mut uv) = self.fit.apply_aligned(image.size(), bounds, align);
576        // A mirror costs nothing but a sign. The shader reads
577        // `uv.xy + unit_pos * uv.zw` with `unit_pos` running 0..1, so a **negative**
578        // width walks the same span backwards — which is the reference's "scaling
579        // factor of -1 in the horizontal direction", without a transform, a layer, or
580        // a second copy of the pixels.
581        if self.match_text_direction && theme.direction == frus_core::TextDirection::Rtl {
582            uv = Rect::new(uv.x + uv.width, uv.y, -uv.width, uv.height);
583        }
584        let tint = over
585            .or(self.tint)
586            .unwrap_or(Color::WHITE)
587            .fade(status.opacity * self.opacity * across);
588        scene.draw_image(image, dst, uv, tint);
589    }
590}
591
592/// **A picture used where an icon would go**: a brand mark, a flag, a custom glyph that is
593/// artwork rather than a path.
594///
595/// It is an icon in every way that matters to the layout and the theme — the ambient size
596/// from [`IconTheme`](crate::IconTheme), the same square box, the same place in a row of
597/// them — and a picture in the one way that matters to the painter. So it sits in an
598/// [`IconButton`](crate::IconButton), a list tile's leading slot or an app bar action
599/// without any of them knowing.
600///
601/// **Untinted by default**, which is the one place it parts from [`crate::Icon`]. An icon
602/// is a silhouette and takes the foreground colour; a picture usually has colours of its
603/// own, and a brand mark flattened to `on_surface` the first time an application themes its
604/// icons is a brand mark nobody recognises. [`ImageIcon::color`] is for the case that wants
605/// it — a monochrome glyph shipped as a bitmap, which is why the reference's own honours
606/// `IconTheme.color` at all.
607///
608/// ```ignore
609/// ImageIcon::new(Image::memory(include_bytes!("../assets/mark.png")))
610/// ```
611pub struct ImageIcon {
612    image: Image,
613    size: Option<f32>,
614    color: Option<Color>,
615}
616
617impl ImageIcon {
618    /// A picture at the ambient icon size, in its own colours.
619    pub fn new(image: Image) -> Self {
620        Self {
621            image,
622            size: None,
623            color: None,
624        }
625    }
626
627    /// The square's side, in logical pixels. Outranks the theme.
628    #[must_use]
629    pub fn size(mut self, size: f32) -> Self {
630        self.size = Some(size);
631        self
632    }
633
634    /// **Tints** the picture, the way an icon is coloured.
635    ///
636    /// Multiplied into the pixels, so it is only a colour for artwork that is white or
637    /// grey to begin with — which is what a bitmap glyph is. A photograph tinted red comes
638    /// out a red photograph.
639    ///
640    /// Say it with [`crate::Icon`]'s own default and the picture follows the icon theme:
641    /// `image_icon.color(theme.widgets.icon.color.unwrap_or(theme.on_surface))`. It is not
642    /// done for you, for the reason in the type's own documentation.
643    #[must_use]
644    pub fn color(mut self, color: Color) -> Self {
645        self.color = Some(color);
646        self
647    }
648
649    /// The side actually drawn: `caller ?? theme ?? the grid`. The same chain
650    /// [`crate::Icon`] answers, so a picture and a path line up in one row.
651    fn resolved_size(&self, theme: Option<&Theme>) -> f32 {
652        self.size
653            .or_else(|| theme.and_then(|t| t.widgets.icon.size))
654            .unwrap_or(crate::icons::GRID)
655    }
656
657    fn sized(&self, side: f32) -> Style {
658        Style {
659            width: Dimension::Length(side),
660            height: Dimension::Length(side),
661            ..Default::default()
662        }
663    }
664
665    /// Draws the picture into a square somebody else chose — what
666    /// [`IconButton`](crate::IconButton) needs, since a button sizes its own mark and a
667    /// picture must be the same size as the path beside it.
668    pub(crate) fn paint_at(&self, square: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
669        self.image
670            .paint_tinted(square, status, theme, scene, self.color);
671    }
672}
673
674impl<Msg> Widget<Msg> for ImageIcon {
675    fn style(&self) -> Style {
676        self.sized(self.resolved_size(None))
677    }
678
679    /// The theme has a say in the **size**, not only the colour, exactly as it does for a
680    /// path icon — so an app bar that makes its glyphs smaller makes this smaller too.
681    fn style_themed(&self, theme: &Theme) -> Style {
682        self.sized(self.resolved_size(Some(theme)))
683    }
684
685    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
686        &[]
687    }
688
689    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
690        // The square the icon would have had, centred in whatever box the layout gave —
691        // the same two lines `Icon` uses, which is what keeps a picture and a path level
692        // in one row.
693        let side = self.resolved_size(Some(theme));
694        let square = Rect::new(
695            bounds.x + (bounds.width - side) * 0.5,
696            bounds.y + (bounds.height - side) * 0.5,
697            side,
698            side,
699        );
700        self.paint_at(square, status, theme, scene);
701    }
702
703    fn on_click(&self) -> Option<Msg> {
704        None
705    }
706
707    /// The crossing is the picture's, forwarded — a brand mark fetched over the network
708    /// fades in behind an icon button exactly as it would anywhere else.
709    fn anim_target(&self) -> Option<f32> {
710        Widget::<()>::anim_target(&self.image)
711    }
712
713    fn anim_duration(&self) -> f32 {
714        Widget::<()>::anim_duration(&self.image)
715    }
716
717    fn anim_curve(&self) -> Curve {
718        Widget::<()>::anim_curve(&self.image)
719    }
720
721    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
722        Widget::<()>::semantics(&self.image)
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use crate::Runtime;
730    use frus_core::{ImageData, Primitive};
731
732    fn handle(w: u32, h: u32) -> ImageHandle {
733        ImageData::from_rgba(w, h, vec![255u8; (w * h * 4) as usize]).into_handle()
734    }
735
736    /// Renders one image on its own and returns `(the image tints, the flat fills)`.
737    fn painted(image: Image, value: f32) -> (Vec<Color>, Vec<Color>) {
738        let mut runtime = Runtime::default();
739        runtime.set_value(crate::interaction::WidgetId::ROOT, value);
740        let ui: crate::Ui<()> = crate::ui::build_ui(
741            &image,
742            frus_core::Size::new(100.0, 100.0),
743            &runtime,
744            &Theme::default(),
745        );
746        let mut images = Vec::new();
747        let mut fills = Vec::new();
748        fn walk(primitives: &[Primitive], images: &mut Vec<Color>, fills: &mut Vec<Color>) {
749            for p in primitives {
750                match p {
751                    Primitive::Image { tint, .. } => images.push(*tint),
752                    Primitive::Rect { color, .. } => fills.push(*color),
753                    Primitive::Layer { primitives, .. } => walk(primitives, images, fills),
754                    _ => {}
755                }
756            }
757        }
758        walk(ui.scene().primitives(), &mut images, &mut fills);
759        (images, fills)
760    }
761
762    /// **An image with no placeholder is drawn exactly as it always was.** The crossing is
763    /// only asked for when there is something to cross over from, so nothing already
764    /// written acquired an animation or a frame of transparency.
765    #[test]
766    fn an_image_with_no_placeholder_is_untouched() {
767        let image = Image::new(handle(8, 8)).size(50.0, 50.0);
768        assert_eq!(Widget::<()>::anim_target(&image), None);
769        // Even asked at nought — which is what a runtime that had never seen it would
770        // answer — the picture is opaque.
771        let (images, _) = painted(image, 0.0);
772        assert_eq!(images.len(), 1);
773        assert_eq!(images[0].a, 1.0);
774    }
775
776    /// **Half way across, both are drawn and neither is whole.** That is the crossing: the
777    /// placeholder is on its way out at the same rate the picture is on its way in, so the
778    /// box is never empty and never shows two solid things at once.
779    #[test]
780    fn half_way_across_both_are_drawn() {
781        let stand_in = Color::rgb(0.9, 0.1, 0.1);
782        let image = Image::new(handle(8, 8))
783            .size(50.0, 50.0)
784            .placeholder(stand_in);
785        let (images, fills) = painted(image, 0.5);
786        assert_eq!(images.len(), 1, "the picture");
787        assert!(
788            (images[0].a - 0.5).abs() < 1e-3,
789            "half faded in: {:?}",
790            images[0].a
791        );
792        let under = fills
793            .iter()
794            .find(|c| (c.r, c.g, c.b) == (stand_in.r, stand_in.g, stand_in.b))
795            .expect("the placeholder");
796        assert!(
797            (under.a - 0.5).abs() < 1e-3,
798            "half faded out: {:?}",
799            under.a
800        );
801    }
802
803    /// **At rest the placeholder is not drawn at all** — not drawn at nought opacity,
804    /// which would still be a primitive per image on every frame of a list of thumbnails.
805    #[test]
806    fn at_rest_only_the_picture_is_drawn() {
807        let stand_in = Color::rgb(0.9, 0.1, 0.1);
808        let image = Image::new(handle(8, 8))
809            .size(50.0, 50.0)
810            .placeholder(stand_in);
811        let (images, fills) = painted(image, 1.0);
812        assert_eq!(images.len(), 1);
813        assert_eq!(images[0].a, 1.0);
814        assert!(
815            !fills
816                .iter()
817                .any(|c| (c.r, c.g, c.b) == (stand_in.r, stand_in.g, stand_in.b)),
818            "no placeholder once it is over"
819        );
820    }
821
822    /// **The crossing has a duration in one direction only.**
823    ///
824    /// Coming in it takes its time. Going back it is instant, because there is nothing
825    /// left to cross *from*: the pixels of the picture that was there are gone by then, so
826    /// a tween down would fade the placeholder **in** over three quarters of a second with
827    /// nothing else on screen — a stall rather than a change.
828    ///
829    /// It is also what makes a source swapped at one place in the tree do the right thing
830    /// without anyone recording which picture the timeline was about: the swap passes
831    /// through *not ready*, which resets it in one frame, and the next arrival crosses
832    /// properly.
833    #[test]
834    fn the_crossing_runs_one_way() {
835        let ready = Image::new(handle(8, 8)).placeholder(Color::WHITE);
836        assert_eq!(Widget::<()>::anim_target(&ready), Some(1.0));
837        assert_eq!(Widget::<()>::anim_duration(&ready), IMAGE_FADE);
838
839        let waiting = Image::network("https://example.invalid/x.png").placeholder(Color::WHITE);
840        assert_eq!(Widget::<()>::anim_target(&waiting), Some(0.0));
841        assert_eq!(
842            Widget::<()>::anim_duration(&waiting),
843            0.0,
844            "back to the placeholder in one frame"
845        );
846    }
847
848    /// **A rebuild does not restart it.** A view is rebuilt every frame here, so a
849    /// crossing that began again each time would never finish — the picture would sit at
850    /// its first step for ever.
851    ///
852    /// Nothing in the widget arranges that: the value lives against the widget's place in
853    /// the tree and the target does not move while the source stands still, so the runtime
854    /// carries it. Worth a test because it is the property the whole design turns on.
855    #[test]
856    fn a_rebuild_does_not_restart_the_crossing() {
857        let image = || {
858            Image::new(handle(8, 8))
859                .size(50.0, 50.0)
860                .placeholder(Color::WHITE)
861        };
862        let mut runtime = Runtime::default();
863        // Mount: adopts, since there was no placeholder shown before the widget existed.
864        runtime.advance_values::<()>(&image(), 0.0);
865        let mounted = runtime.value(crate::interaction::WidgetId::ROOT);
866        assert_eq!(mounted, 1.0, "an image already here does not fade in");
867
868        // Ten rebuilds of the same view, a frame apart.
869        for _ in 0..10 {
870            runtime.advance_values::<()>(&image(), 1.0 / 60.0);
871        }
872        assert_eq!(
873            runtime.value(crate::interaction::WidgetId::ROOT),
874            1.0,
875            "and it has not gone back to the beginning"
876        );
877    }
878
879    /// **A picture takes the ambient icon size**, the same chain a path icon answers, so
880    /// the two are interchangeable in a row of actions.
881    #[test]
882    fn an_image_icon_takes_the_ambient_icon_size() {
883        let icon = ImageIcon::new(Image::new(handle(64, 64)));
884        assert_eq!(
885            Widget::<()>::style(&icon).width,
886            frus_layout::Dimension::Length(crate::icons::GRID)
887        );
888        let mut theme = Theme::default();
889        theme.widgets.icon.size = Some(18.0);
890        assert_eq!(
891            Widget::<()>::style_themed(&icon, &theme).width,
892            frus_layout::Dimension::Length(18.0),
893            "the theme has a say in the size, not only the colour"
894        );
895        assert_eq!(
896            Widget::<()>::style_themed(&icon.size(30.0), &theme).width,
897            frus_layout::Dimension::Length(30.0),
898            "and the caller outranks it"
899        );
900    }
901
902    /// **A picture is not tinted unless the caller asks.** That is the one place this
903    /// parts from a path icon, and it is deliberate: an icon is a silhouette and takes the
904    /// foreground colour, where a brand mark flattened to one grey the first time an
905    /// application themes its icons is a brand mark nobody recognises.
906    #[test]
907    fn a_picture_is_not_tinted_unless_it_is_asked() {
908        let mut theme = Theme::default();
909        theme.widgets.icon.color = Some(Color::rgb(0.9, 0.1, 0.1));
910        let paint = |icon: ImageIcon| {
911            let mut scene = frus_core::Scene::new();
912            let status = Status::default();
913            Widget::<()>::paint(
914                &icon,
915                Rect::new(0.0, 0.0, 24.0, 24.0),
916                status,
917                &theme,
918                &mut scene,
919            );
920            scene
921                .primitives()
922                .iter()
923                .find_map(|p| match p {
924                    Primitive::Image { tint, .. } => Some(*tint),
925                    _ => None,
926                })
927                .expect("a picture")
928        };
929        let plain = paint(ImageIcon::new(Image::new(handle(8, 8))));
930        assert_eq!(
931            (plain.r, plain.g, plain.b),
932            (1.0, 1.0, 1.0),
933            "its own colours, whatever the icon theme says"
934        );
935        let tinted =
936            paint(ImageIcon::new(Image::new(handle(8, 8))).color(Color::rgb(0.0, 1.0, 0.0)));
937        assert_eq!((tinted.r, tinted.g, tinted.b), (0.0, 1.0, 0.0));
938    }
939
940    /// The box an image asks for, down a column 400 wide.
941    ///
942    /// A `Column` and not a `Flex`: the reference's column centres its children, where
943    /// flexbox stretches them, and a **stretched** cross axis is a width the image was
944    /// handed — which beats a ratio, here as there. That is the parent's say, not a
945    /// property of the image.
946    fn asked_box(image: Image) -> Rect {
947        let root = crate::Column::<()>::new().child(image);
948        let runtime = crate::Runtime::default();
949        let theme = Theme::default();
950        let mut layout = frus_layout::Layout::new();
951        let node = crate::ui::build_layout(
952            &root,
953            crate::interaction::WidgetId::ROOT,
954            &runtime,
955            &theme,
956            &mut layout,
957        );
958        layout.compute_filled(node, 400.0, 400.0);
959        // The column is first; the image is its only child.
960        layout.absolute_rects(node)[1].0
961    }
962
963    /// No size given: the image's own, which is the whole point — showing a bitmap
964    /// should not require already knowing how many pixels across it is.
965    #[test]
966    fn an_image_with_no_size_is_its_own_size() {
967        let r = asked_box(Image::new(handle(64, 32)));
968        assert_eq!((r.width, r.height), (64.0, 32.0));
969    }
970
971    /// One side given: the other follows the image's ratio, as in the reference — as
972    /// long as the parent is not handing that side a size of its own.
973    #[test]
974    fn one_side_derives_the_other_from_the_ratio() {
975        let by_width = asked_box(Image::new(handle(64, 32)).width(128.0));
976        assert_eq!((by_width.width, by_width.height), (128.0, 64.0));
977
978        let by_height = asked_box(Image::new(handle(64, 32)).height(8.0));
979        assert_eq!((by_height.width, by_height.height), (16.0, 8.0));
980    }
981
982    /// Both given: that box, ratio or no ratio.
983    #[test]
984    fn both_sides_given_win() {
985        let r = asked_box(Image::new(handle(64, 32)).size(10.0, 90.0));
986        assert_eq!((r.width, r.height), (10.0, 90.0));
987    }
988
989    /// The alignment decides where a letterboxed image sits, and which part of a
990    /// cropped one survives. Both from one anchor, which is the reference's answer too.
991    #[test]
992    fn the_alignment_places_the_letterbox_and_moves_the_crop() {
993        let bounds = Rect::new(0.0, 0.0, 100.0, 40.0);
994        // A square in a wide box, contained: 40×40, and the anchor says where across.
995        let left = paint(
996            Image::new(handle(10, 10))
997                .size(100.0, 40.0)
998                .alignment(Alignment::CENTER_LEFT),
999            bounds,
1000        );
1001        let right = paint(
1002            Image::new(handle(10, 10))
1003                .size(100.0, 40.0)
1004                .alignment(Alignment::CENTER_RIGHT),
1005            bounds,
1006        );
1007        let x_of = |p: &Primitive| match p {
1008            Primitive::Image { rect, .. } => rect.x,
1009            other => panic!("an image, not {other:?}"),
1010        };
1011        assert_eq!(x_of(&left), 0.0);
1012        assert_eq!(x_of(&right), 60.0, "hard against the right edge");
1013
1014        // A tall image cropped to a wide box keeps the part the anchor names, which
1015        // travels the other way: aligning to the top keeps the top of the image.
1016        let uv_y = |p: &Primitive| match p {
1017            Primitive::Image { uv, .. } => uv.y,
1018            other => panic!("an image, not {other:?}"),
1019        };
1020        let top = paint(
1021            Image::new(handle(10, 100))
1022                .size(100.0, 40.0)
1023                .fit(BoxFit::Cover)
1024                .alignment(Alignment::TOP_CENTER),
1025            bounds,
1026        );
1027        let middle = paint(
1028            Image::new(handle(10, 100))
1029                .size(100.0, 40.0)
1030                .fit(BoxFit::Cover),
1031            bounds,
1032        );
1033        assert_eq!(uv_y(&top), 0.0, "the top of the image is kept");
1034        assert!(uv_y(&middle) > 0.0, "centred, the top is cropped away");
1035    }
1036
1037    /// Opacity goes into the tint rather than into a layer: an image is one primitive.
1038    #[test]
1039    fn opacity_fades_the_tint() {
1040        let faded = paint(
1041            Image::new(handle(4, 4)).size(20.0, 20.0).opacity(0.5),
1042            Rect::new(0.0, 0.0, 20.0, 20.0),
1043        );
1044        match faded {
1045            Primitive::Image { tint, .. } => assert_eq!(tint.a, 0.5),
1046            other => panic!("an image, not {other:?}"),
1047        }
1048    }
1049
1050    fn paint(image: Image, bounds: Rect) -> Primitive {
1051        paint_in(image, bounds, &Theme::default())
1052    }
1053
1054    /// The same, under a theme of the caller's choosing — which is how the reading
1055    /// direction reaches `paint`.
1056    fn paint_in(image: Image, bounds: Rect, theme: &Theme) -> Primitive {
1057        let mut scene = Scene::new();
1058        Widget::<()>::paint(&image, bounds, Status::default(), theme, &mut scene);
1059        scene.primitives()[0].clone()
1060    }
1061
1062    #[test]
1063    fn contain_letterboxes_a_square_in_a_wide_box() {
1064        // A square 10×10 source in a 100×40 box gives 40×40, centred in x.
1065        let prim = paint(
1066            Image::new(handle(10, 10)).size(100.0, 40.0),
1067            Rect::new(0.0, 0.0, 100.0, 40.0),
1068        );
1069        match prim {
1070            Primitive::Image { rect, uv, .. } => {
1071                assert_eq!(rect, Rect::new(30.0, 0.0, 40.0, 40.0));
1072                assert_eq!(uv, Rect::new(0.0, 0.0, 1.0, 1.0));
1073            }
1074            _ => panic!("expected an image"),
1075        }
1076    }
1077
1078    #[test]
1079    fn tint_override_is_applied() {
1080        let prim = paint(
1081            Image::new(handle(4, 4))
1082                .size(20.0, 20.0)
1083                .tint(Color::rgb(1.0, 0.0, 0.0)),
1084            Rect::new(0.0, 0.0, 20.0, 20.0),
1085        );
1086        match prim {
1087            Primitive::Image { tint, .. } => {
1088                assert_eq!(tint.r, 1.0);
1089                assert_eq!(tint.g, 0.0);
1090            }
1091            _ => panic!("expected an image"),
1092        }
1093    }
1094
1095    #[test]
1096    fn size_drives_the_layout_box() {
1097        let image = Image::new(handle(4, 4)).size(64.0, 48.0);
1098        let style = Widget::<()>::style(&image);
1099        assert_eq!(style.width, Dimension::Length(64.0));
1100        assert_eq!(style.height, Dimension::Length(48.0));
1101    }
1102
1103    /// The hole this milestone came for. `Role::Image` was in the vocabulary and mapped
1104    /// to the platform's, and **nothing in the framework ever emitted it** — so every
1105    /// picture in every application was silent to a screen reader.
1106    #[test]
1107    fn a_labelled_image_is_announced() {
1108        let semantics = Widget::<()>::semantics(&Image::new(handle(4, 4)).semantic_label("Ada"))
1109            .expect("an image is announced");
1110        assert_eq!(semantics.role, frus_core::Role::Image);
1111        assert_eq!(semantics.label.as_deref(), Some("Ada"));
1112    }
1113
1114    /// Unlabelled, it still says *there is a picture here*. A reader who meets it knows
1115    /// something is there and can move past it; leaving it out entirely would be the
1116    /// application's decision, not the widget's default.
1117    #[test]
1118    fn an_unlabelled_image_still_announces_the_role() {
1119        let semantics =
1120            Widget::<()>::semantics(&Image::new(handle(4, 4))).expect("still announced");
1121        assert_eq!(semantics.role, frus_core::Role::Image);
1122        assert_eq!(semantics.label, None);
1123    }
1124
1125    /// Decoration is excluded outright, and the exclusion wins over a label — the
1126    /// reference's rule, and the only one that is not ambiguous when both are given.
1127    #[test]
1128    fn decoration_is_left_out_of_the_tree_a_reader_walks() {
1129        let decoration = Image::new(handle(4, 4))
1130            .semantic_label("a texture")
1131            .exclude_from_semantics(true);
1132        assert!(Widget::<()>::semantics(&decoration).is_none());
1133    }
1134
1135    /// The sub-region an image samples, as painted.
1136    fn sampled(image: Image, theme: &Theme) -> Rect {
1137        match paint_in(image, Rect::new(0.0, 0.0, 40.0, 40.0), theme) {
1138            Primitive::Image { uv, .. } => uv,
1139            _ => panic!("expected an image"),
1140        }
1141    }
1142
1143    /// **The pixels, once they are here**, for painting them elsewhere; none while there
1144    /// are none (milestone 647).
1145    #[test]
1146    fn the_handle_is_the_pixels_once_here() {
1147        let pixels = handle(4, 2);
1148        assert_eq!(Image::new(pixels.clone()).handle(), Some(pixels));
1149        assert_eq!(Image::from_source(Source::Loading).handle(), None);
1150        assert_eq!(
1151            Image::from_source(Source::Failed("gone".into())).handle(),
1152            None
1153        );
1154    }
1155
1156    /// A picture that **points** is mirrored in a right-to-left reading direction, which
1157    /// the reference describes as a scaling factor of -1 horizontally. Here it is a sign
1158    /// on the sampled width: the shader reads `uv.xy + unit_pos * uv.zw`, so a negative
1159    /// width walks the same span backwards — no transform, no layer, no second copy of
1160    /// the pixels.
1161    #[test]
1162    fn a_directional_image_is_mirrored_in_rtl() {
1163        let forward = sampled(Image::new(handle(8, 4)), &Theme::default());
1164        let mirrored = sampled(
1165            Image::new(handle(8, 4)).match_text_direction(true),
1166            &Theme::default().rtl(),
1167        );
1168        assert_eq!(
1169            mirrored.width, -forward.width,
1170            "the span is walked backwards"
1171        );
1172        assert_eq!(
1173            mirrored.x,
1174            forward.x + forward.width,
1175            "and it starts at the far edge"
1176        );
1177        // The vertical span is untouched: this is a mirror, not a rotation.
1178        assert_eq!((mirrored.y, mirrored.height), (forward.y, forward.height));
1179    }
1180
1181    /// It is **opt-in**, and off it stays off in both directions. A photograph of a
1182    /// person does not want to be flipped because the interface is in Arabic, which is
1183    /// why the reference makes this a per-image switch rather than a global rule.
1184    #[test]
1185    fn an_ordinary_image_is_not_mirrored_by_the_reading_direction() {
1186        let plain = Image::new(handle(8, 4));
1187        assert_eq!(
1188            sampled(plain, &Theme::default().rtl()),
1189            sampled(Image::new(handle(8, 4)), &Theme::default()),
1190        );
1191    }
1192
1193    /// And the switch alone does nothing: it is the direction that mirrors, not the flag.
1194    #[test]
1195    fn a_directional_image_is_left_alone_in_ltr() {
1196        assert_eq!(
1197            sampled(
1198                Image::new(handle(8, 4)).match_text_direction(true),
1199                &Theme::default()
1200            ),
1201            sampled(Image::new(handle(8, 4)), &Theme::default()),
1202        );
1203    }
1204
1205    /// And it reaches the tree, not just the hook.
1206    ///
1207    /// A trait method nobody calls is the shape of bug this project has already been
1208    /// bitten by — a hook that answers correctly while the walk never asks it, green
1209    /// unit tests over a feature that does nothing. So this drives `build_ui` and reads
1210    /// what the walk actually collected.
1211    #[test]
1212    fn the_walk_collects_the_image_and_skips_the_decoration() {
1213        let tree = crate::Flex::<()>::column()
1214            .child(Image::new(handle(8, 8)).semantic_label("Ada Lovelace"))
1215            .child(Image::new(handle(8, 8)).exclude_from_semantics(true));
1216        let ui = crate::build_ui(
1217            &tree,
1218            crate::Size::new(200.0, 200.0),
1219            &crate::Runtime::default(),
1220            &Theme::default(),
1221        );
1222        let images: Vec<_> = ui
1223            .semantics()
1224            .iter()
1225            .filter(|(_, _, s)| s.role == frus_core::Role::Image)
1226            .map(|(_, _, s)| s.label.clone())
1227            .collect();
1228        assert_eq!(
1229            images,
1230            vec![Some("Ada Lovelace".to_string())],
1231            "the labelled one is announced and the decoration is not"
1232        );
1233    }
1234
1235    /// A 2x2 PNG, encoded at run time so the test carries no binary fixture.
1236    ///
1237    /// Leaked deliberately: [`frus_core::cached`] keys on the address of `'static`
1238    /// bytes, and this is how a test gets some. One 90-odd byte leak per test process is
1239    /// the price of exercising the real path rather than a stand-in.
1240    #[cfg(feature = "images")]
1241    fn png() -> &'static [u8] {
1242        let mut out = Vec::new();
1243        {
1244            let mut encoder = png::Encoder::new(&mut out, 2, 2);
1245            encoder.set_color(png::ColorType::Rgba);
1246            encoder.set_depth(png::BitDepth::Eight);
1247            let mut writer = encoder.write_header().expect("header");
1248            writer.write_image_data(&[255u8; 16]).expect("pixels");
1249        }
1250        Vec::leak(out)
1251    }
1252
1253    /// The question an application asks first: how do I show my logo. One call, and the
1254    /// pixels come out the far side at the size the file says.
1255    #[cfg(feature = "images")]
1256    #[test]
1257    fn embedded_bytes_become_pixels() {
1258        let image = Image::memory(png());
1259        assert_eq!(image.error(), None, "a valid PNG decodes");
1260        let style = Widget::<()>::style(&image);
1261        assert_eq!(
1262            style.width,
1263            Dimension::Length(2.0),
1264            "at the file's own size"
1265        );
1266    }
1267
1268    /// **Once** per process, not once per frame.
1269    ///
1270    /// This is the whole reason the store exists: a view is rebuilt sixty times a second
1271    /// and decoding a PNG is not free. The two handles being the same `Arc` is the proof
1272    /// — a second decode would produce a second `ImageData` with a different identity.
1273    #[cfg(feature = "images")]
1274    #[test]
1275    fn the_same_bytes_are_decoded_once() {
1276        let bytes = png();
1277        let first = Image::memory(bytes);
1278        let second = Image::memory(bytes);
1279        let handle = |image: &Image| match &image.source {
1280            Source::Ready(handle) => handle.clone(),
1281            _ => panic!("decoded"),
1282        };
1283        let (a, b) = (handle(&first), handle(&second));
1284        let (a, b) = (&a, &b);
1285        assert_eq!(a.id(), b.id(), "one decode, one image, shared");
1286        assert!(
1287            std::sync::Arc::ptr_eq(a, b),
1288            "and literally the same handle"
1289        );
1290    }
1291
1292    /// Bytes that are not an image: the widget says so and paints nothing, rather than
1293    /// panicking or drawing a stand-in nobody asked for.
1294    #[test]
1295    fn bytes_that_are_not_an_image_report_it_and_draw_nothing() {
1296        static NOT_AN_IMAGE: &[u8] = b"this is not a PNG";
1297        let broken = Image::memory(NOT_AN_IMAGE);
1298        assert!(broken.error().is_some(), "it says what went wrong");
1299        let mut scene = Scene::new();
1300        Widget::<()>::paint(
1301            &broken,
1302            Rect::new(0.0, 0.0, 40.0, 40.0),
1303            Status::default(),
1304            &Theme::default(),
1305            &mut scene,
1306        );
1307        assert!(scene.primitives().is_empty(), "and draws nothing at all");
1308    }
1309
1310    /// A broken image takes **no room** unless it was given some. There is no natural
1311    /// size to fall back on, and anything the caller did say is still honoured — which
1312    /// is what keeps a page from jumping because one asset is bad.
1313    #[test]
1314    fn a_broken_image_takes_the_room_it_was_given_and_no_more() {
1315        static NOT_AN_IMAGE: &[u8] = b"nor is this";
1316        let bare = Widget::<()>::style(&Image::memory(NOT_AN_IMAGE));
1317        assert_eq!(bare.width, Dimension::Length(0.0));
1318        assert_eq!(bare.height, Dimension::Length(0.0));
1319        let sized = Widget::<()>::style(&Image::memory(NOT_AN_IMAGE).size(64.0, 48.0));
1320        assert_eq!(sized.width, Dimension::Length(64.0));
1321        assert_eq!(sized.height, Dimension::Length(48.0));
1322    }
1323
1324    /// The failure is remembered too. A file that is not a PNG will not become one on
1325    /// the next frame, and retrying every frame would turn one broken asset into a
1326    /// permanent cost.
1327    #[test]
1328    fn a_failure_is_remembered_rather_than_retried() {
1329        static NOT_AN_IMAGE: &[u8] = b"still not a PNG";
1330        let first = Image::memory(NOT_AN_IMAGE).error().map(str::to_string);
1331        let second = Image::memory(NOT_AN_IMAGE).error().map(str::to_string);
1332        assert!(first.is_some());
1333        assert_eq!(first, second);
1334    }
1335
1336    /// The network path, with the network standing in for itself.
1337    ///
1338    /// These share one process-wide store and one registered fetcher, so they run under
1339    /// a lock rather than in parallel: two of them racing would see each other's URLs
1340    /// and each other's fetcher. That is a property of the thing being tested — it is
1341    /// deliberately global — rather than a weakness of the tests.
1342    #[cfg(feature = "images")]
1343    mod network {
1344        use super::*;
1345        use std::sync::{Mutex, MutexGuard, OnceLock};
1346
1347        fn serialised() -> MutexGuard<'static, ()> {
1348            static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1349            let lock = LOCK.get_or_init(|| Mutex::new(()));
1350            let guard = lock.lock().unwrap_or_else(|e| e.into_inner());
1351            frus_core::forget_fetched_images();
1352            guard
1353        }
1354
1355        /// A fetcher that answers **on the spot**, with a PNG.
1356        ///
1357        /// Synchronous on purpose: it is the harder case for the store, since the
1358        /// callback runs while `fetched` is still inside the call that started it. If
1359        /// the lock were still held there, this would deadlock rather than fail.
1360        fn instant_png(_url: &str, deliver: Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>) {
1361            deliver(Ok(png().to_vec()));
1362        }
1363
1364        /// A fetcher that answers on the spot with a failure.
1365        fn instant_failure(_url: &str, deliver: Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>) {
1366            deliver(Err("404".to_string()));
1367        }
1368
1369        /// One that never answers at all — a request still in flight.
1370        fn never(_url: &str, _deliver: Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>) {}
1371
1372        #[test]
1373        fn a_fetched_image_arrives_and_is_shown() {
1374            let _guard = serialised();
1375            frus_core::set_image_fetcher(instant_png);
1376            // The first ask started the work; this fetcher had already finished by the
1377            // time it returned, so the second ask has the pixels.
1378            let _ = Image::network("https://example.com/a.png");
1379            let arrived = Image::network("https://example.com/a.png");
1380            assert_eq!(arrived.state(), State::Ready);
1381            assert_eq!(Widget::<()>::style(&arrived).width, Dimension::Length(2.0));
1382        }
1383
1384        #[test]
1385        fn a_request_still_in_flight_reads_as_loading_and_keeps_the_frames_coming() {
1386            let _guard = serialised();
1387            frus_core::set_image_fetcher(never);
1388            let waiting = Image::network("https://example.com/slow.png");
1389            assert_eq!(waiting.state(), State::Loading);
1390            assert_eq!(waiting.error(), None, "loading has not failed");
1391            // The interface must keep drawing, or the frame that would show the picture
1392            // never happens.
1393            assert!(frus_core::images_in_flight() > 0);
1394        }
1395
1396        #[test]
1397        fn a_request_that_fails_says_why_rather_than_waiting_for_ever() {
1398            let _guard = serialised();
1399            frus_core::set_image_fetcher(instant_failure);
1400            let _ = Image::network("https://example.com/gone.png");
1401            let dead = Image::network("https://example.com/gone.png");
1402            assert_eq!(dead.state(), State::Failed("404"));
1403            assert_eq!(dead.error(), Some("404"));
1404        }
1405
1406        /// **Once**, however many frames ask. A view runs sixty times a second, and a
1407        /// fetch per frame would be sixty requests for one picture.
1408        #[test]
1409        fn a_view_asking_every_frame_fetches_once() {
1410            let _guard = serialised();
1411            static CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
1412            fn counted(_url: &str, deliver: Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>) {
1413                CALLS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1414                deliver(Ok(png().to_vec()));
1415            }
1416            CALLS.store(0, std::sync::atomic::Ordering::Relaxed);
1417            frus_core::set_image_fetcher(counted);
1418            for _ in 0..60 {
1419                let _ = Image::network("https://example.com/once.png");
1420            }
1421            assert_eq!(CALLS.load(std::sync::atomic::Ordering::Relaxed), 1);
1422        }
1423
1424        /// And nothing is left in flight once it has landed, so the interface settles
1425        /// back to drawing only when something changes.
1426        #[test]
1427        fn the_count_falls_back_to_zero_when_the_work_is_done() {
1428            let _guard = serialised();
1429            frus_core::set_image_fetcher(instant_png);
1430            let _ = Image::network("https://example.com/settle.png");
1431            assert_eq!(frus_core::images_in_flight(), 0);
1432        }
1433    }
1434}