Skip to main content

max / makeover-layout

0.20.0: name an assembly of primitives Region::Widget is the third tier, between a primitive and Region::Bespoke. A carousel is not a primitive -- a terminal has none, which is the test Node::Html failed -- and it is not bespoke either, because every part of it is furniture the vocabulary already says. Only the assembly had no name. The member is the pair the other two are not: a name AND contents. A renderer that recognises the name draws it its own way; one that does not walks the body, which is primitives all the way down. So an unrecognised widget degrades to its assembly instead of failing, and the set can be open without three renderers releasing in lockstep. It buys no expressive power, which is what makes that safe: a widget is an assembly of members that already exist, so the header's refusal about timelines stands. A widget is never how a primitive gets added by the back door. non_exhaustive arrives on Region with it, the pairing RowPart made at 0.9.0. Region::name() joins them so a renderer reading a name does not write the matches! itself and let Bespoke and Widget drift apart.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 15:16 UTC
Signed with PGP, not checked
Commit: 315a110e4f3f1080dbf4d0204d02ecd58f031a16
Parent: 3008f01
2 files changed, +162 insertions, -8 deletions
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-layout"
3 - version = "0.19.1"
3 + version = "0.20.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 +161 -7
@@ -205,6 +205,25 @@
205 205 //! webview draws it from `:focus-visible`, egui refused the variant outright,
206 206 //! and quasi-tui honoured it once at startup and overrode it thereafter.
207 207 //!
208 + //! 0.20.0 adds [`Region::Widget`], the third tier, and `#[non_exhaustive]` to
209 + //! [`Region`] with it. Every vocabulary finding until now had two answers
210 + //! available — grow the primitive set, or [`Region::Bespoke`] — and a whole
211 + //! class of thing is wrong for both. A carousel is not a primitive, because a
212 + //! terminal has none and that is the test `Node::Html` failed. It is not
213 + //! bespoke either, because bespoke is what one app owns and every part of a
214 + //! carousel is furniture plus members this crate already has.
215 + //!
216 + //! The cost of the binary was that refusing a primitive was expensive: the app
217 + //! hand-rolls the thing forever, so the pressure always ran toward growing the
218 + //! primitive set with one host's idioms. A named assembly changes what "no"
219 + //! costs without changing what the vocabulary can say.
220 + //!
221 + //! MNW's carousel is the first consumer and was the finding that started it: one
222 + //! partial, three pages, an ordered set of frames with a position, prev/next and
223 + //! a dot strip, all of it sayable already and none of it nameable. See wiki
224 + //! `widget-tier` for the ownership model, which is why this member carries a
225 + //! name a renderer may decline to know.
226 + //!
208 227 //! # Reach, focus and the focus ring
209 228 //!
210 229 //! Three terms, and no others, for what 0.19.0 moved out of the description.
@@ -220,11 +239,10 @@
220 239 //!
221 240 //! # Where the description stops
222 241 //!
223 - //! The bespoke widgets, a day-plan timeline and a kanban board and a calendar,
224 - //! are not describable here and will not become describable. A description
225 - //! expressive enough to produce a timeline is a widget library wearing a
226 - //! description's name. Generate the boring 80% so the bespoke 20% gets the
227 - //! attention.
242 + //! A day-plan timeline, a kanban board and a calendar are not describable here
243 + //! and will not become describable. A description expressive enough to produce
244 + //! a timeline is a component library wearing a description's name. Generate the
245 + //! boring 80% so the bespoke 20% gets the attention.
228 246 //!
229 247 //! [`Region::Bespoke`] is how that limit is stated rather than hidden. The
230 248 //! description names the *place* and the app owns the contents, so a screen
@@ -232,6 +250,13 @@
232 250 //! it, the four goingson screens that make the app worth using would need a
233 251 //! second, undescribed path beside the router, and two paths is how a
234 252 //! vocabulary starts drifting from its app again.
253 + //!
254 + //! [`Region::Widget`] sits between that limit and the primitives, and it does
255 + //! not move the limit. A widget is an assembly of members this crate *already*
256 + //! has, under a name a renderer may or may not recognise. Anything that needs a
257 + //! member the vocabulary does not have is still a finding about the vocabulary
258 + //! or still bespoke; naming an assembly buys no new expressive power, which is
259 + //! exactly why it is safe to let the set grow outside this crate.
235 260
236 261 #![forbid(unsafe_code)]
237 262
@@ -1259,7 +1284,12 @@
1259 1284 /// `layout.css` currently names exactly two things, `.raised` and `.well`, so
1260 1285 /// this layer is absent rather than divergent, which makes it the cheapest of
1261 1286 /// the schemas to add and the easiest to over-build.
1287 + ///
1288 + /// `#[non_exhaustive]` arrives with [`Region::Widget`], the pairing [`RowPart`]
1289 + /// made at 0.9.0 and [`Readiness`] at 0.12.0, and for the same reason: the
1290 + /// member after this one should not be a lockstep event across three renderers.
1262 1291 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1292 + #[non_exhaustive]
1263 1293 pub enum Region<'a> {
1264 1294 /// A full-width strip with a title slot and an actions cluster, either of
1265 1295 /// which may be empty. goingson's `.page-header`, Balanced Breakfast's
@@ -1298,9 +1328,61 @@
1298 1328 /// What the app calls it. Never interpreted here.
1299 1329 name: &'a str,
1300 1330 },
1331 + /// A named assembly of things the vocabulary already says.
1332 + ///
1333 + /// The third tier, between a primitive and [`Bespoke`](Self::Bespoke).
1334 + /// Stated by Max 2026-08-12 answering the carousel: "something in between a
1335 + /// primitive and a bespoke interface, like a widget, which is just an
1336 + /// assembly of primitives." Full note: wiki `widget-tier`.
1337 + ///
1338 + /// # What separates it from the two members either side
1339 + ///
1340 + /// A primitive is a thing every renderer draws from scratch, and the test
1341 + /// it has to pass is that every host has an honest answer. A carousel fails
1342 + /// that test — a terminal has no carousel — which is the same refusal
1343 + /// `Node::Html` got and is why the carousel sat unsayable for months.
1344 + ///
1345 + /// [`Bespoke`](Self::Bespoke) fails it from the other side. Bespoke is for
1346 + /// what one app owns and nobody will build twice, and it carries *no*
1347 + /// contents: the description names the place and stops. A carousel is
1348 + /// furniture any app would have, and every part of it — an ordered set of
1349 + /// frames, a position, prev and next, a strip of position indicators — is
1350 + /// already sayable. Only the assembly had no name.
1351 + ///
1352 + /// So this member is the pair the other two are not: a name **and**
1353 + /// contents. The contents are the assembly, in the region's own body, said
1354 + /// in members that already exist.
1355 + ///
1356 + /// # Why the name does not have to be understood
1357 + ///
1358 + /// A renderer that recognises the name draws it the way its host does it: a
1359 + /// carousel in a webview, a pager with a count in a terminal, a selector in
1360 + /// egui. A renderer that does not recognise it walks the body, which is
1361 + /// primitives all the way down and which it can already draw.
1362 + ///
1363 + /// That is what lets the widget set be **open** without every renderer
1364 + /// knowing every widget. An unrecognised widget degrades to its assembly
1365 + /// instead of failing, so a second or third party can name one without
1366 + /// three renderers releasing in lockstep to accept it. Contrast
1367 + /// [`Bespoke`](Self::Bespoke), which no renderer can degrade: there is
1368 + /// nothing under it to fall back to.
1369 + ///
1370 + /// # What it does not do
1371 + ///
1372 + /// It does not make a timeline describable, and the refusal in the crate
1373 + /// header stands unchanged. A widget is an assembly of things the
1374 + /// vocabulary *already* says; anything that needs a member the vocabulary
1375 + /// does not have is a finding about the vocabulary or it is
1376 + /// [`Bespoke`](Self::Bespoke). A widget is never the way a primitive gets
1377 + /// added by the back door.
1378 + Widget {
1379 + /// What the assembly is called. This crate never interprets it, and a
1380 + /// renderer is free not to know it.
1381 + name: &'a str,
1382 + },
1301 1383 }
1302 1384
1303 - impl Region<'_> {
1385 + impl<'a> Region<'a> {
1304 1386 /// How the region sits on what is behind it.
1305 1387 #[must_use]
1306 1388 pub const fn depth(self) -> Depth {
@@ -1312,7 +1394,13 @@
1312 1394 // Flat because it inherits: a bespoke region takes the depth of
1313 1395 // whatever frames it. An app that wants its timeline in a well puts
1314 1396 // it in a `Pane`, which composes rather than adding a knob here.
1315 - Self::Bespoke { .. } => Depth::Flat,
1397 + //
1398 + // A widget inherits for the same reason and it matters more here,
1399 + // because a widget is drawn by whichever renderer recognises it. A
1400 + // depth set here would be this crate deciding that a carousel is
1401 + // raised on every host, which is the kind of value the deferral
1402 + // rule exists to refuse.
1403 + Self::Bespoke { .. } | Self::Widget { .. } => Depth::Flat,
1316 1404 }
1317 1405 }
1318 1406
@@ -1322,10 +1410,40 @@
1322 1410 /// to the right drawing code. This is how it tells the two apart, and the
1323 1411 /// reason it is a method rather than a `matches!` at each renderer: there
1324 1412 /// is exactly one opaque member and there should stay exactly one.
1413 + ///
1414 + /// [`Widget`](Self::Widget) is described, and that is the whole of what
1415 + /// separates it from [`Bespoke`](Self::Bespoke) here. Both carry a name
1416 + /// this crate never interprets; only one of them carries contents under it.
1417 + /// A renderer that does not recognise a widget's name still walks its body,
1418 + /// so there is nothing for it to hand over and nothing it cannot draw.
1325 1419 #[must_use]
1326 1420 pub const fn described(self) -> bool {
1327 1421 !matches!(self, Self::Bespoke { .. })
1328 1422 }
1423 +
1424 + /// The name an app gave this region, if it gave one.
1425 + ///
1426 + /// [`Bespoke`](Self::Bespoke) and [`Widget`](Self::Widget) are the two
1427 + /// members that carry a name, for two different purposes: one says what the
1428 + /// app will fill the space with, the other says what the assembly under it
1429 + /// is called. A renderer dispatching on either wants the string without
1430 + /// caring which member it came from, and writing that `matches!` at each
1431 + /// renderer is how the two drift apart.
1432 + #[must_use]
1433 + pub const fn name(self) -> Option<&'a str> {
1434 + match self {
1435 + Self::Bespoke { name } | Self::Widget { name } => Some(name),
1436 + // Spelled out rather than a wildcard, so a member added later has
1437 + // to answer whether it carries a name instead of inheriting `None`
1438 + // by sitting under a `_`.
1439 + Self::Band
1440 + | Self::Sidebar
1441 + | Self::Pane
1442 + | Self::Split
1443 + | Self::TabGroup
1444 + | Self::Modal => None,
1445 + }
1446 + }
1329 1447 }
1330 1448
1331 1449 /// How much of the width an arrangement's first region takes.
@@ -2694,12 +2812,48 @@
2694 2812 Region::Split,
2695 2813 Region::TabGroup,
2696 2814 Region::Modal,
2815 + // A widget is described, and that is the whole of what separates it
2816 + // from a bespoke here. Both carry a name this crate never reads;
2817 + // only one of them has contents under it that a renderer which does
2818 + // not know the name can still walk.
2819 + Region::Widget { name: "carousel" },
2697 2820 ] {
2698 2821 assert!(r.described(), "{r:?} should be describable");
2699 2822 }
2700 2823 assert!(!Region::Bespoke { name: "day-plan" }.described());
2701 2824 }
2702 2825
2826 + #[test]
2827 + fn a_widget_inherits_its_depth_the_way_a_bespoke_does() {
2828 + // Stronger than the bespoke case: a widget is drawn by whichever
2829 + // renderer recognises the name, so a depth chosen here would be this
2830 + // crate deciding a carousel is raised on every host.
2831 + assert_eq!(Region::Widget { name: "carousel" }.depth(), Depth::Flat);
2832 + assert_eq!(Region::Widget { name: "pager" }.depth(), Depth::Flat);
2833 + }
2834 +
2835 + #[test]
2836 + fn a_name_is_readable_without_asking_which_member_carried_it() {
2837 + // A renderer dispatching on a name wants the string, not the member.
2838 + // Writing that `matches!` at each renderer is how the two drift apart.
2839 + assert_eq!(Region::Widget { name: "carousel" }.name(), Some("carousel"));
2840 + assert_eq!(
2841 + Region::Bespoke { name: "day-plan" }.name(),
2842 + Some("day-plan")
2843 + );
2844 +
2845 + for r in [
2846 + Region::Band,
2847 + Region::Sidebar,
2848 + Region::Pane,
2849 + Region::Split,
2850 + Region::TabGroup,
2851 + Region::Modal,
2852 + ] {
2853 + assert_eq!(r.name(), None, "{r:?} names nothing an app chose");
2854 + }
2855 + }
2856 +
2703 2857 #[test]
2704 2858 fn a_bespoke_region_inherits_its_depth_rather_than_choosing_one() {
2705 2859 // The app owns the contents, not the placement. An app that wants its