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