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