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