Skip to main content

max / makeover-layout

0.22.0: a picture can say how much room to hold 0.21.0 added Image and shipped it unable to answer the question a renderer most needs answered. Without intrinsic dimensions nothing can reserve space, so a picture occupies nothing until its bytes arrive and then takes its full height at once, shoving the page down. Measured on MNW's landing page: a 478px jump per frame, 0.087 cumulative layout shift for the document. Image::intrinsic carries the picture's own dimensions as an Extent. A fact about the asset, not a display size, which is what keeps it on this side of the deferral rule: 5120x3412 is what the file IS, and no renderer can learn it without fetching the bytes. None stays honest for a creator upload whose size was never recorded. Loading, and the default is Eager. 0.21.0 emitted the webview's loading="lazy" for every picture, reading one consumer's habit as a rule. Deferring a picture that is on screen at first paint saves nothing and lands its arrival later, so the page moves more rather than less. A carousel is the case proving this cannot be one renderer-wide setting: its first frame is on screen and its others are not, in one widget, at one moment.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 21:14 UTC
Signed with PGP, not checked
Commit: 8f3d6dc9048c4a8ab006da7c4ab82d6f044aed86
Parent: e8b3bda
2 files changed, +155 insertions, -1 deletion
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.21.0"
3 + version = "0.22.0"
4 4 edition = "2024"
5 5 # One copy of this vocabulary per dependency graph, enforced by cargo rather
6 6 # than by remembering. Two versions of a description layer in one build means
M src/lib.rs +154
@@ -246,6 +246,23 @@
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,6 +1324,99 @@
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,9 +1427,25 @@
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,6 +3101,34 @@
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,