| 246 |
246 |
|
//! [`Image`] carries no source, the split [`Act`] already makes: an address is
|
| 247 |
247 |
|
//! not this crate's to hold. See its own docs, which is where the argument is.
|
| 248 |
248 |
|
//!
|
|
249 |
+ |
//! 0.22.0 finishes [`Image`], which 0.21.0 shipped unable to say how much room
|
|
250 |
+ |
//! a picture needs. Without that a renderer cannot reserve space, so a picture
|
|
251 |
+ |
//! occupies nothing until its bytes arrive and then takes its full height at
|
|
252 |
+ |
//! once. Measured on MNW's landing page: a 478px jump per frame and a
|
|
253 |
+ |
//! cumulative layout shift of 0.087 for the page.
|
|
254 |
+ |
//!
|
|
255 |
+ |
//! - [`Image::intrinsic`], the picture's own dimensions, carried as [`Extent`].
|
|
256 |
+ |
//! A fact about the asset rather than a display size, which is what keeps it
|
|
257 |
+ |
//! on this side of the deferral rule: 5120x3412 is what the file *is*, and no
|
|
258 |
+ |
//! renderer can learn it without fetching the bytes.
|
|
259 |
+ |
//! - [`Loading`], and the default flips to [`Loading::Eager`]. 0.21.0 emitted
|
|
260 |
+ |
//! the webview's `loading="lazy"` for every picture, which read one
|
|
261 |
+ |
//! consumer's habit as a rule. Deferring a picture that is on screen at first
|
|
262 |
+ |
//! paint saves nothing and makes its shift land later. The carousel is the
|
|
263 |
+ |
//! case that proves this cannot be one renderer-wide setting: its first frame
|
|
264 |
+ |
//! is on screen and its others are not, in one widget, at one moment.
|
|
265 |
+ |
//!
|
| 249 |
266 |
|
//! # Reach, focus and the focus ring
|
| 250 |
267 |
|
//!
|
| 251 |
268 |
|
//! Three terms, and no others, for what 0.19.0 moved out of the description.
|
| 1307 |
1324 |
|
pub caption: Option<&'a str>,
|
| 1308 |
1325 |
|
/// How it sits in the box it is given.
|
| 1309 |
1326 |
|
pub fit: Fit,
|
|
1327 |
+ |
/// The picture's own dimensions, where the app knows them.
|
|
1328 |
+ |
///
|
|
1329 |
+ |
/// **Not a display size**, and that distinction is what makes this belong
|
|
1330 |
+ |
/// here rather than fall foul of the deferral rule. Saying a picture should
|
|
1331 |
+ |
/// be 320 points wide is a layout value and is not the description's to
|
|
1332 |
+ |
/// give. Saying the file is 5120x3412 is a fact *about the picture*, the
|
|
1333 |
+ |
/// same kind of fact [`alt`](Self::alt) is, and no renderer can find it out
|
|
1334 |
+ |
/// without fetching the bytes.
|
|
1335 |
+ |
///
|
|
1336 |
+ |
/// # What it is for, and it is not decoration
|
|
1337 |
+ |
///
|
|
1338 |
+ |
/// Without it a renderer cannot reserve room, so the picture occupies
|
|
1339 |
+ |
/// nothing until it arrives and then takes its full height at once,
|
|
1340 |
+ |
/// shoving everything below it down the screen. Measured on MNW's landing
|
|
1341 |
+ |
/// page 2026-08-14: a 478px jump per frame, and a cumulative layout shift
|
|
1342 |
+ |
/// of 0.087 for the page, which is most of the way to the 0.1 that counts
|
|
1343 |
+ |
/// as bad.
|
|
1344 |
+ |
///
|
|
1345 |
+ |
/// Every host wants it and none can derive it. A webview writes `width` and
|
|
1346 |
+ |
/// `height` so the browser holds the space; egui sizes a texture; a
|
|
1347 |
+ |
/// terminal with a graphics protocol scales a blit into cells. This was
|
|
1348 |
+ |
/// missing from 0.21.0, which is the release that added [`Image`], and its
|
|
1349 |
+ |
/// absence is the defect rather than an omission.
|
|
1350 |
+ |
///
|
|
1351 |
+ |
/// `None` is honest and common: a creator-uploaded image whose dimensions
|
|
1352 |
+ |
/// the app never recorded genuinely does not know. It means the renderer
|
|
1353 |
+ |
/// cannot reserve, not that the picture has no size.
|
|
1354 |
+ |
pub intrinsic: Option<Extent>,
|
|
1355 |
+ |
/// Whether the picture is needed with the screen, or can arrive later.
|
|
1356 |
+ |
pub loading: Loading,
|
|
1357 |
+ |
}
|
|
1358 |
+ |
|
|
1359 |
+ |
/// A picture's own pixel dimensions.
|
|
1360 |
+ |
///
|
|
1361 |
+ |
/// Deliberately not [`makeover_geometry`]'s business. Geometry answers *how
|
|
1362 |
+ |
/// much space a thing should get*, which is a scale question with the same
|
|
1363 |
+ |
/// answer on every screen. This is the intrinsic size of one asset, which is a
|
|
1364 |
+ |
/// fact about that asset and varies per picture.
|
|
1365 |
+ |
///
|
|
1366 |
+ |
/// [`makeover_geometry`]: https://docs.rs/makeover-geometry
|
|
1367 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
1368 |
+ |
pub struct Extent {
|
|
1369 |
+ |
/// Width in the picture's own pixels.
|
|
1370 |
+ |
pub width: u32,
|
|
1371 |
+ |
/// Height in the picture's own pixels.
|
|
1372 |
+ |
pub height: u32,
|
|
1373 |
+ |
}
|
|
1374 |
+ |
|
|
1375 |
+ |
impl Extent {
|
|
1376 |
+ |
/// A picture's dimensions.
|
|
1377 |
+ |
#[must_use]
|
|
1378 |
+ |
pub const fn new(width: u32, height: u32) -> Self {
|
|
1379 |
+ |
Self { width, height }
|
|
1380 |
+ |
}
|
|
1381 |
+ |
|
|
1382 |
+ |
/// Width over height, or `None` if either side is zero.
|
|
1383 |
+ |
///
|
|
1384 |
+ |
/// The form a renderer actually reserves space with: a box that knows its
|
|
1385 |
+ |
/// proportion holds the right height at any width, which is what a
|
|
1386 |
+ |
/// responsive picture needs and what a fixed pixel height cannot give.
|
|
1387 |
+ |
#[must_use]
|
|
1388 |
+ |
pub fn ratio(self) -> Option<f32> {
|
|
1389 |
+ |
(self.width > 0 && self.height > 0).then(|| self.width as f32 / self.height as f32)
|
|
1390 |
+ |
}
|
|
1391 |
+ |
}
|
|
1392 |
+ |
|
|
1393 |
+ |
/// When a picture is needed.
|
|
1394 |
+ |
///
|
|
1395 |
+ |
/// A claim about *importance and position* rather than a fetch mechanism, which
|
|
1396 |
+ |
/// is why it is the description's to make: only the app knows whether a picture
|
|
1397 |
+ |
/// is the first thing on the screen or the fortieth thing down a list.
|
|
1398 |
+ |
///
|
|
1399 |
+ |
/// # Eager is the default, and that is a correctness choice
|
|
1400 |
+ |
///
|
|
1401 |
+ |
/// 0.21.0 emitted the webview's `loading="lazy"` for every picture, on the
|
|
1402 |
+ |
/// evidence that the one consumer measured wrote it. That was reading a habit
|
|
1403 |
+ |
/// as a rule. Deferring a picture that is on screen at first paint does not
|
|
1404 |
+ |
/// save anything -- it is needed immediately either way -- and it delays the
|
|
1405 |
+ |
/// arrival, so the space it eventually takes is claimed later and the shift is
|
|
1406 |
+ |
/// more visible, not less.
|
|
1407 |
+ |
///
|
|
1408 |
+ |
/// So the safe answer is the default and the optimisation is opted into. A
|
|
1409 |
+ |
/// carousel is the case that proves the two cannot be one setting for the
|
|
1410 |
+ |
/// renderer to choose: its first frame is on screen and its other frames are
|
|
1411 |
+ |
/// not, in the same widget, at the same moment.
|
|
1412 |
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
|
1413 |
+ |
#[non_exhaustive]
|
|
1414 |
+ |
pub enum Loading {
|
|
1415 |
+ |
/// Needed with the screen. Fetch it now.
|
|
1416 |
+ |
#[default]
|
|
1417 |
+ |
Eager,
|
|
1418 |
+ |
/// Not on screen yet. It can wait until it is near.
|
|
1419 |
+ |
Lazy,
|
| 1310 |
1420 |
|
}
|
| 1311 |
1421 |
|
|
| 1312 |
1422 |
|
impl<'a> Image<'a> {
|
| 1317 |
1427 |
|
alt,
|
| 1318 |
1428 |
|
caption: None,
|
| 1319 |
1429 |
|
fit: Fit::Natural,
|
|
1430 |
+ |
intrinsic: None,
|
|
1431 |
+ |
loading: Loading::Eager,
|
| 1320 |
1432 |
|
}
|
| 1321 |
1433 |
|
}
|
| 1322 |
1434 |
|
|
|
1435 |
+ |
/// The picture's own dimensions, so a renderer can hold its place.
|
|
1436 |
+ |
#[must_use]
|
|
1437 |
+ |
pub const fn intrinsic(mut self, width: u32, height: u32) -> Self {
|
|
1438 |
+ |
self.intrinsic = Some(Extent::new(width, height));
|
|
1439 |
+ |
self
|
|
1440 |
+ |
}
|
|
1441 |
+ |
|
|
1442 |
+ |
/// This picture is not on screen yet; it can arrive when it is near.
|
|
1443 |
+ |
#[must_use]
|
|
1444 |
+ |
pub const fn lazy(mut self) -> Self {
|
|
1445 |
+ |
self.loading = Loading::Lazy;
|
|
1446 |
+ |
self
|
|
1447 |
+ |
}
|
|
1448 |
+ |
|
| 1323 |
1449 |
|
/// A visible line under it.
|
| 1324 |
1450 |
|
#[must_use]
|
| 1325 |
1451 |
|
pub const fn caption(mut self, caption: &'a str) -> Self {
|
| 2975 |
3101 |
|
assert_ne!(shot.alt, shot.caption.unwrap());
|
| 2976 |
3102 |
|
}
|
| 2977 |
3103 |
|
|
|
3104 |
+ |
#[test]
|
|
3105 |
+ |
fn a_picture_can_say_how_much_room_to_hold() {
|
|
3106 |
+ |
// The whole point: a renderer reserves from the ratio, so the space is
|
|
3107 |
+ |
// right at any width. A fixed height would only be right at one.
|
|
3108 |
+ |
let shot = Image::new("a screenshot").intrinsic(5120, 3412);
|
|
3109 |
+ |
let e = shot.intrinsic.expect("carried");
|
|
3110 |
+ |
assert_eq!((e.width, e.height), (5120, 3412));
|
|
3111 |
+ |
assert!((e.ratio().unwrap() - 1.5006).abs() < 0.001);
|
|
3112 |
+ |
}
|
|
3113 |
+ |
|
|
3114 |
+ |
#[test]
|
|
3115 |
+ |
fn a_picture_with_no_dimensions_reserves_nothing_rather_than_guessing() {
|
|
3116 |
+ |
// `None` is honest: a creator upload whose size was never recorded does
|
|
3117 |
+ |
// not know it. A renderer must not invent one.
|
|
3118 |
+ |
assert_eq!(Image::new("unknown upload").intrinsic, None);
|
|
3119 |
+ |
assert_eq!(Extent::new(0, 10).ratio(), None);
|
|
3120 |
+ |
assert_eq!(Extent::new(10, 0).ratio(), None);
|
|
3121 |
+ |
}
|
|
3122 |
+ |
|
|
3123 |
+ |
#[test]
|
|
3124 |
+ |
fn a_picture_is_wanted_now_unless_the_app_says_otherwise() {
|
|
3125 |
+ |
// Eager is the safe default and lazy is the opt-in, because deferring
|
|
3126 |
+ |
// something already on screen saves nothing and moves its shift later.
|
|
3127 |
+ |
assert_eq!(Image::new("hero").loading, Loading::Eager);
|
|
3128 |
+ |
assert_eq!(Loading::default(), Loading::Eager);
|
|
3129 |
+ |
assert_eq!(Image::new("frame 2").lazy().loading, Loading::Lazy);
|
|
3130 |
+ |
}
|
|
3131 |
+ |
|
| 2978 |
3132 |
|
#[test]
|
| 2979 |
3133 |
|
fn a_picture_keeps_its_own_proportions_unless_told_otherwise() {
|
| 2980 |
3134 |
|
// The default is the one that shows the whole picture at its own shape,
|