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}