Skip to main content

max / makeover-layout

Say how wide a screen runs and how it divides its width Two measures, one level apart, answered together because they are the same question at two scales and inconsistent answers would have been worse than either answer. Share, on Arrangement. Nothing said how much room a region got, so every renderer invented its own number: quasi-tui drew a 24-column sidebar and a 40% list pane and neither had anything behind it. A webview never noticed, because the stylesheet answered once for every consumer, which is exactly why the terminal is what found it. A proportion and never a unit -- columns would be describing a terminal and pixels a webview, and the point is that both honour one fact. It rides on Arrangement because how much a sidebar takes and how much a list side takes are different questions and this enum is the only thing that knows which is being asked; geometry would have had to invent a channel to be told, and its scales answer the same for every screen. Measure, for the level above: how much of the window the screen uses before its width is divided at all. Measured in the MNW server, where 69 of 72 templates carried one of three mutually exclusive classes and nothing described it, and in GoingsOn and Balanced Breakfast, which reach for max-width 56 and 12 times with no token for it. Named for the measure rather than for MNW's classes, because a renderer that is not a browser owes an answer too and `padded-page` tells a terminal nothing. Breaking: both Arrangement members carry a share, so the unit variant is a struct one. `list_detail` and `sidebar_content` build them at the defaults, and those defaults are quasi-tui's two numbers said in a form a webview can honour.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 00:05 UTC
Signed with PGP, not checked
Commit: 104788a1f30bd14d3c39e5ac75914e3ad1b53af0
Parent: eb63ba3
2 files changed, +242 insertions, -5 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.17.0"
3 + version = "0.18.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 +241 -4
@@ -1313,6 +1313,81 @@
1313 1313 }
1314 1314 }
1315 1315
1316 + /// How much of the width an arrangement's first region takes.
1317 + ///
1318 + /// `e0fd485e`. Nothing said how much room a region got, so every renderer
1319 + /// invented its own number and two hosts showing one screen disagreed about
1320 + /// its proportions. A webview never noticed, because the stylesheet answered
1321 + /// once for every consumer; a terminal has no stylesheet to inherit from, so
1322 + /// `quasi-tui` picked 24 columns for a sidebar and 40% for a list pane and
1323 + /// neither had anything behind it.
1324 + ///
1325 + /// # A proportion, never a unit
1326 + ///
1327 + /// Held as a percentage, and that is the only form it comes in. A description
1328 + /// carrying columns would be describing a terminal and one carrying pixels a
1329 + /// webview, and the whole point is that both honour the same fact: a terminal
1330 + /// resolves it against a column count, a webview writes it into a grid, and
1331 + /// neither has to know what the other did.
1332 + ///
1333 + /// It is not [`makeover_geometry::Ratio`]'s job either, which was the first
1334 + /// guess. Geometry is scales that answer the same for every screen and takes
1335 + /// no input that would let a sidebar screen differ from a list-detail one.
1336 + ///
1337 + /// [`makeover_geometry::Ratio`]: https://docs.rs/makeover-geometry
1338 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
1339 + pub struct Share(u8);
1340 +
1341 + impl Share {
1342 + /// What a sidebar takes, when nobody says otherwise.
1343 + ///
1344 + /// A quarter. `quasi-tui` drew 24 columns, which is a quarter of a
1345 + /// 96-column terminal and about a fifth of a wide one; a quarter is that
1346 + /// number said in the form a webview can honour too.
1347 + pub const SIDEBAR: Self = Self(25);
1348 +
1349 + /// What the list side of a list-detail takes, when nobody says otherwise.
1350 + ///
1351 + /// `quasi-tui`'s 40%, which was already a proportion and is the one number
1352 + /// this member did not have to invent.
1353 + pub const LIST: Self = Self(40);
1354 +
1355 + /// A share of the width, as a percentage.
1356 + ///
1357 + /// Clamped to 5..=95 rather than refused. A description that asked for a
1358 + /// region of nothing is a bug in the app, and a renderer drawing a region
1359 + /// zero cells wide reports it as a region that vanished, which is the
1360 + /// hardest kind of bug to find from what is on the screen.
1361 + #[must_use]
1362 + pub const fn percent(percent: u8) -> Self {
1363 + Self(if percent < 5 {
1364 + 5
1365 + } else if percent > 95 {
1366 + 95
1367 + } else {
1368 + percent
1369 + })
1370 + }
1371 +
1372 + /// The share as a percentage.
1373 + #[must_use]
1374 + pub const fn as_percent(self) -> u8 {
1375 + self.0
1376 + }
1377 +
1378 + /// This share of a width, rounded to the nearest whole unit.
1379 + ///
1380 + /// What a terminal calls to turn the proportion into columns. At least one,
1381 + /// because a region the description named should be visible: a screen
1382 + /// 3 columns wide is unusable either way, and a sidebar that is there is a
1383 + /// truer picture of the description than a sidebar that is not.
1384 + #[must_use]
1385 + pub const fn of(self, whole: u16) -> u16 {
1386 + let taken = (whole as u32 * self.0 as u32).div_ceil(100);
1387 + if taken == 0 { 1 } else { taken as u16 }
1388 + }
1389 + }
1390 +
1316 1391 /// How a screen is laid out.
1317 1392 ///
1318 1393 /// Two, and the second is not a variant of the first. goingson is list-detail,
@@ -1323,15 +1398,127 @@
1323 1398 /// This exists at all because the router has to be able to express a screen
1324 1399 /// rather than only a control. Discovering the arrangement layer missing after
1325 1400 /// the renderers exist is a redesign; naming two now is a morning.
1401 + ///
1402 + /// # Why the share rides here
1403 + ///
1404 + /// `e0fd485e`. A share is per-arrangement: how much a sidebar takes and how
1405 + /// much a list side takes are different questions, and this enum is the only
1406 + /// thing that knows which one is being asked. Geometry would have had to invent
1407 + /// a channel to be told.
1408 + ///
1409 + /// [`list_detail`](Self::list_detail) and
1410 + /// [`sidebar_content`](Self::sidebar_content) build these with the default
1411 + /// shares, so a screen that has no opinion does not have to have one.
1326 1412 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1327 1413 pub enum Arrangement {
1328 1414 /// A list that chooses what the detail beside it shows.
1329 1415 ListDetail {
1330 1416 /// Whether the detail side is a [`Region::TabGroup`].
1331 1417 tabbed: bool,
1418 + /// How much of the width the list side takes.
1419 + share: Share,
1332 1420 },
1333 1421 /// Navigation down the side, content filling the rest.
1334 - SidebarContent,
1422 + SidebarContent {
1423 + /// How much of the width the sidebar takes.
1424 + share: Share,
1425 + },
1426 + }
1427 +
1428 + impl Arrangement {
1429 + /// A list and a detail beside it, at the default share.
1430 + #[must_use]
1431 + pub const fn list_detail(tabbed: bool) -> Self {
1432 + Self::ListDetail {
1433 + tabbed,
1434 + share: Share::LIST,
1435 + }
1436 + }
1437 +
1438 + /// A sidebar and content beside it, at the default share.
1439 + #[must_use]
1440 + pub const fn sidebar_content() -> Self {
1441 + Self::SidebarContent {
1442 + share: Share::SIDEBAR,
1443 + }
1444 + }
1445 +
1446 + /// How much of the width the first region takes.
1447 + #[must_use]
1448 + pub const fn share(self) -> Share {
1449 + match self {
1450 + Self::ListDetail { share, .. } | Self::SidebarContent { share } => share,
1451 + }
1452 + }
1453 +
1454 + /// The same arrangement, at this share.
1455 + #[must_use]
1456 + pub const fn with_share(self, share: Share) -> Self {
1457 + match self {
1458 + Self::ListDetail { tabbed, .. } => Self::ListDetail { tabbed, share },
1459 + Self::SidebarContent { .. } => Self::SidebarContent { share },
1460 + }
1461 + }
1462 + }
1463 +
1464 + /// How wide the content of a whole screen runs.
1465 + ///
1466 + /// `0eccff0d`, and [`Share`]'s sibling one level up: that one says how a
1467 + /// screen's width is divided between regions, this says how much of the window
1468 + /// the screen uses in the first place. Both are the description's, which is
1469 + /// what answering the two together settled.
1470 + ///
1471 + /// Measured in the MNW server, where 69 of 72 templates carry exactly one of
1472 + /// three mutually exclusive classes and the choice is per screen. GoingsOn
1473 + /// reaches for `max-width` 56 times and Balanced Breakfast 12, neither with a
1474 + /// token for it, so three apps were solving one thing by hand.
1475 + ///
1476 + /// # Named for the measure, not for MNW's classes
1477 + ///
1478 + /// A renderer that is not a browser has to answer this too, and `padded-page`
1479 + /// tells a terminal nothing. The three say how wide the text runs, which is a
1480 + /// question every renderer can answer: a webview with a `max-width`, a terminal
1481 + /// with gutters, an immediate-mode frame with its own width.
1482 + ///
1483 + /// `#[non_exhaustive]` for [`Fill`]'s reason. The set is closed today because
1484 + /// the measurement found three, and a fourth arriving should not be a lockstep
1485 + /// release across nine repos.
1486 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1487 + #[non_exhaustive]
1488 + pub enum Measure {
1489 + /// The whole width, with gutters. The default, and 53 of the 69.
1490 + ///
1491 + /// What a dashboard, a table and a settings screen want: the content is
1492 + /// wide because the content *is* wide, and constraining it would waste the
1493 + /// window.
1494 + #[default]
1495 + Wide,
1496 + /// Capped at a comfortable page width, centred. 13 of the 69.
1497 + ///
1498 + /// A form, a sign-in, a purchase. Content that does not get better by
1499 + /// getting wider, but is not prose either.
1500 + Contained,
1501 + /// Capped at a line length that reads well. 3 of the 69.
1502 + ///
1503 + /// Prose. The narrowest of the three, and the one with a reason outside
1504 + /// taste: a line of text past roughly 75 characters costs the reader the
1505 + /// return sweep.
1506 + Reading,
1507 + }
1508 +
1509 + impl Measure {
1510 + /// A stable name, for a renderer that needs to spell it.
1511 + ///
1512 + /// Here rather than in each renderer for [`Sort::as_str`]'s reason: three
1513 + /// renderers spelling one enum is three chances to spell it differently.
1514 + #[must_use]
1515 + pub const fn as_str(self) -> &'static str {
1516 + match self {
1517 + Self::Wide => "wide",
1518 + Self::Contained => "contained",
1519 + Self::Reading => "reading",
1520 + }
1521 + }
1335 1522 }
1336 1523
1337 1524 /// What kind of value a form field takes.
@@ -2746,10 +2933,60 @@
2746 2933 fn an_arrangement_carries_the_tab_group_as_a_modifier() {
2747 2934 // goingson uses the tab group inside the content region rather than
2748 2935 // instead of one, so it is not a third arrangement.
2749 - let go = Arrangement::ListDetail { tabbed: true };
2750 - let plain = Arrangement::ListDetail { tabbed: false };
2936 + let go = Arrangement::list_detail(true);
2937 + let plain = Arrangement::list_detail(false);
2751 2938 assert_ne!(go, plain);
2752 - assert_ne!(go, Arrangement::SidebarContent);
2939 + assert_ne!(go, Arrangement::sidebar_content());
2940 + }
2941 +
2942 + #[test]
2943 + fn a_share_is_a_proportion_and_resolves_the_same_way_everywhere() {
2944 + // The point of the member: a terminal reading columns and a webview
2945 + // reading a grid honour one fact, so two hosts showing one screen agree
2946 + // about its proportions.
2947 + assert_eq!(Share::LIST.as_percent(), 40);
2948 + assert_eq!(Share::LIST.of(100), 40);
2949 + assert_eq!(
2950 + Share::SIDEBAR.of(96),
2951 + 24,
2952 + "quasi-tui's 24 columns, said as a quarter"
2953 + );
2954 + }
2955 +
2956 + #[test]
2957 + fn a_region_never_resolves_to_nothing() {
2958 + // A region the description named should be visible. A zero-width one
2959 + // reads on screen as a region that vanished, which is the hardest kind
2960 + // of bug to find from what is drawn.
2961 + assert_eq!(Share::percent(5).of(1), 1);
2962 + assert_eq!(Share::percent(5).of(0), 1);
2963 + }
2964 +
2965 + #[test]
2966 + fn a_share_outside_the_range_is_clamped_rather_than_refused() {
2967 + assert_eq!(Share::percent(0), Share::percent(5));
2968 + assert_eq!(Share::percent(200), Share::percent(95));
2969 + }
2970 +
2971 + #[test]
2972 + fn the_share_rides_on_the_arrangement_that_knows_which_question_it_is() {
2973 + // How much a sidebar takes and how much a list side takes are different
2974 + // questions, and this enum is the only thing that knows which is being
2975 + // asked.
2976 + assert_eq!(Arrangement::sidebar_content().share(), Share::SIDEBAR);
2977 + assert_eq!(Arrangement::list_detail(false).share(), Share::LIST);
2978 +
2979 + let narrow = Arrangement::sidebar_content().with_share(Share::percent(20));
2980 + assert_eq!(narrow.share(), Share::percent(20));
2981 + assert!(matches!(narrow, Arrangement::SidebarContent { .. }));
2982 + }
2983 +
2984 + #[test]
2985 + fn a_measure_defaults_to_the_one_53_of_69_templates_asked_for() {
2986 + // The default is meaningful: a screen nobody said anything about uses
2987 + // the window it was given.
2988 + assert_eq!(Measure::default(), Measure::Wide);
2989 + assert_eq!(Measure::Reading.as_str(), "reading");
2753 2990 }
2754 2991
2755 2992 #[test]