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