Skip to main content

max / quasi

A slot says how many of its children show, which one, and what each is called The current answer and the per-child name, beside makeover-layout 0.23.0's kind. Same split Selector already made against Node::Select: a layer that defers every address does not hold what is picked either. current() reads shown against the body, clamping an index past the end for the reason Share::percent clamps -- a carousel stuck on its last frame is findable, a region that vanished is not. labels() is all-or-nothing, because a strip with a hole in it is worse than the prev/next row it would have replaced. Which idiom a renderer draws now falls out of whether the children carry labels, never out of the widget's name. Nothing renders it yet.
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:18 UTC
Signed with PGP, not checked
Commit: bbd8dd3760cfd49d7096b6971f566aeaa902fe55
Parent: 4ce44e4
7 files changed, +243 insertions, -30 deletions
M Cargo.lock +17 -23
@@ -2296,9 +2296,7 @@
2296 2296
2297 2297 [[package]]
2298 2298 name = "makeover-immediate"
2299 - version = "0.20.0"
2300 - source = "registry+https://github.com/rust-lang/crates.io-index"
2301 - checksum = "4562842f4badc11fe48b96e7cf22a37f42ad57a2f6e313f48680edbc07554bbe"
2299 + version = "0.21.0"
2302 2300 dependencies = [
2303 2301 "egui",
2304 2302 "egui_extras",
@@ -2307,15 +2305,11 @@
2307 2305
2308 2306 [[package]]
2309 2307 name = "makeover-layout"
2310 - version = "0.22.0"
2311 - source = "registry+https://github.com/rust-lang/crates.io-index"
2312 - checksum = "58dad575602dd23c8f69297e9ef774be84e4c6e4c58a54892448175cb6a096da"
2308 + version = "0.23.0"
2313 2309
2314 2310 [[package]]
2315 2311 name = "makeover-touch"
2316 - version = "0.14.0"
2317 - source = "registry+https://github.com/rust-lang/crates.io-index"
2318 - checksum = "2d6241ec48fe2dea0d1051b1bfa3c6213bf0e7a75731153c20ca91c501953f24"
2312 + version = "0.15.0"
2319 2313 dependencies = [
2320 2314 "makeover-geometry",
2321 2315 "makeover-layout",
@@ -2323,9 +2317,7 @@
2323 2317
2324 2318 [[package]]
2325 2319 name = "makeover-tui"
2326 - version = "0.20.0"
2327 - source = "registry+https://github.com/rust-lang/crates.io-index"
2328 - checksum = "417dc9cf87884338402e4fac007c66a844bbe2289f31b26cf23d1ac33896d0aa"
2320 + version = "0.21.0"
2329 2321 dependencies = [
2330 2322 "makeover",
2331 2323 "makeover-layout",
@@ -2334,9 +2326,7 @@
2334 2326
2335 2327 [[package]]
2336 2328 name = "makeover-webview"
2337 - version = "0.38.0"
2338 - source = "registry+https://github.com/rust-lang/crates.io-index"
2339 - checksum = "cf73eb60125c7ae695cfce2678b2338f22101ae5c835a080a599a9d52b595f18"
2329 + version = "0.39.0"
2340 2330 dependencies = [
2341 2331 "makeover-geometry",
2342 2332 "makeover-layout",
@@ -5524,14 +5514,6 @@
5524 5514 source = "registry+https://github.com/rust-lang/crates.io-index"
5525 5515 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5526 5516
5527 - [[patch.unused]]
5528 - name = "synckit-client"
5529 - version = "0.8.0"
5530 -
5531 - [[patch.unused]]
5532 - name = "synckit-config"
5533 - version = "0.2.0"
5534 -
5535 5517 [[patch.unused]]
5536 5518 name = "kberg"
5537 5519 version = "0.1.0"
@@ -5547,3 +5529,15 @@
5547 5529 [[patch.unused]]
5548 5530 name = "tagtree"
5549 5531 version = "0.4.0"
5532 +
5533 + [[patch.unused]]
5534 + name = "synckit-client"
5535 + version = "0.8.0"
5536 +
5537 + [[patch.unused]]
5538 + name = "synckit-config"
5539 + version = "0.2.0"
5540 +
5541 + [[patch.unused]]
5542 + name = "makeover-build"
5543 + version = "0.32.0"
@@ -14,7 +14,7 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.4.0" }
17 - makeover-layout = "0.22.0"
17 + makeover-layout = "0.23.0"
18 18
19 19 [dev-dependencies]
20 20 # A widget's guarantees are claims about what a renderer draws, so they are
@@ -15,7 +15,7 @@
15 15 # widget here that is not a container comes from it: a screen walk that painted
16 16 # its own meter would be the divergence the suite exists to end, one copy per
17 17 # renderer instead of one copy per app.
18 - makeover-immediate = "0.20.0"
18 + makeover-immediate = "0.21.0"
19 19 egui = { version = "0.35", default-features = false }
20 20 # Markdown to text for `Node::Rich`. The same crate the webview renderer takes,
21 21 # reading the same source: what differs is that it can emit markup and this
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.22.0"
16 + makeover-layout = "0.23.0"
@@ -14,13 +14,13 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.4.0" }
17 - makeover-layout = "0.22.0"
17 + makeover-layout = "0.23.0"
18 18 # The depth palette, the theme bridge and the table. Everything else this crate
19 19 # draws is written here first and lifted upstream once a second consumer wants
20 20 # it, which is the order the suite has always moved in: the constrained consumer
21 21 # finds the shape, and the shared crate takes it after it is known rather than
22 22 # before.
23 - makeover-tui = { version = "0.20.0", features = ["theme"] }
23 + makeover-tui = { version = "0.21.0", features = ["theme"] }
24 24 ratatui = { version = "0.30", default-features = false }
25 25 # `Node::Rich` carries markdown source. A terminal has no markup to hand it to,
26 26 # so it takes the runs: `render_runs` is the words with the marks that were over
@@ -15,8 +15,8 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.4.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.4.0" }
18 - makeover-layout = "0.22.0"
19 - makeover-webview = "0.38.0"
18 + makeover-layout = "0.23.0"
19 + makeover-webview = "0.39.0"
20 20 # `Node::Rich` carries markdown source and this is what turns it into markup.
21 21 # Sanitising comes with it, which is why the node can carry what a user typed.
22 22 #
@@ -1226,6 +1226,32 @@
1226 1226 /// that screen rather than a property of the vocabulary, and the bound is
1227 1227 /// not the place to hold opinions about taste.
1228 1228 pub body: Vec<Node>,
1229 + /// How many of [`body`](Self::body) are visible at once.
1230 + ///
1231 + /// `4dcd241b`. [`layout::Showing::All`] by default, which is what every
1232 + /// region did before this field existed, so a description written against
1233 + /// the previous version says the same thing.
1234 + ///
1235 + /// This is the kind. The two fields below are the current answer and the
1236 + /// per-child name, and they are here rather than in `makeover-layout` for
1237 + /// the reason [`Node::Select`]'s `chosen` is: a layer that defers every
1238 + /// address does not hold what is picked either.
1239 + pub showing: layout::Showing,
1240 + /// Which child is up, when only one of them is.
1241 + ///
1242 + /// Meaningless under [`layout::Showing::All`] and ignored there. Read
1243 + /// through [`current`](Self::current) rather than directly, which is where
1244 + /// an index past the end of the body is dealt with.
1245 + pub shown: Option<usize>,
1246 + /// What this region is called, when something above it is showing one child
1247 + /// at a time.
1248 + ///
1249 + /// The tab's name, and the whole of what separates a tab strip from a
1250 + /// prev/next row: a renderer draws the strip when the children carry these
1251 + /// and the row when they do not. A carousel's frames are [`Node::Image`] and
1252 + /// have nowhere to put one, which is correct rather than a gap — a frame has
1253 + /// a caption, not a tab name.
1254 + pub label: Option<String>,
1229 1255 }
1230 1256
1231 1257 impl Slot {
@@ -1236,6 +1262,9 @@
1236 1262 kind,
1237 1263 readiness: layout::Readiness::Ready,
1238 1264 body: Vec::new(),
1265 + showing: layout::Showing::All,
1266 + shown: None,
1267 + label: None,
1239 1268 }
1240 1269 }
1241 1270
@@ -1276,6 +1305,93 @@
1276 1305 self
1277 1306 }
1278 1307
1308 + /// Show one child at a time, starting at this one.
1309 + ///
1310 + /// The carousel and the tab group, which are one thing said twice: whether
1311 + /// a host draws a strip of names or a prev/next row falls out of whether
1312 + /// the children carry a [`label`](Self::label), never out of the widget's
1313 + /// name.
1314 + #[must_use]
1315 + pub fn showing_one(mut self, shown: usize) -> Self {
1316 + self.showing = layout::Showing::One;
1317 + self.shown = Some(shown);
1318 + self
1319 + }
1320 +
1321 + /// Show one child or none, starting closed unless a child is named.
1322 + ///
1323 + /// Disclosure. `None` is the closed state and is a legal resting place,
1324 + /// which is the whole of what separates this from
1325 + /// [`showing_one`](Self::showing_one).
1326 + #[must_use]
1327 + pub fn showing_at_most_one(mut self, shown: Option<usize>) -> Self {
1328 + self.showing = layout::Showing::AtMostOne;
1329 + self.shown = shown;
1330 + self
1331 + }
1332 +
1333 + /// Name this region, for when something above it shows one child at a time.
1334 + #[must_use]
1335 + pub fn label(mut self, label: impl Into<String>) -> Self {
1336 + self.label = Some(label.into());
1337 + self
1338 + }
1339 +
1340 + /// Which child to draw, once [`shown`](Self::shown) is read against the body.
1341 + ///
1342 + /// `None` means draw them all, which is both [`layout::Showing::All`] and a
1343 + /// closed disclosure — the two cases differ in what chrome sits around them
1344 + /// and not in what a renderer does with the body, so they answer the same
1345 + /// here.
1346 + ///
1347 + /// An index past the end is clamped rather than refused. A description
1348 + /// pointing at a frame that is not there is a bug in the app, and a renderer
1349 + /// that answers it by drawing nothing reports it as a region that vanished,
1350 + /// which is the hardest kind of bug to find from what is on the screen.
1351 + /// [`layout::Share::percent`] clamps for the same reason.
1352 + #[must_use]
1353 + pub fn current(&self) -> Option<usize> {
1354 + if self.body.is_empty() {
1355 + return None;
1356 + }
1357 + let last = self.body.len() - 1;
1358 + match self.showing {
1359 + layout::Showing::One => Some(self.shown.unwrap_or(0).min(last)),
1360 + layout::Showing::AtMostOne => self.shown.map(|shown| shown.min(last)),
1361 + layout::Showing::All => None,
1362 + // `Showing` is `#[non_exhaustive]`, so this arm is compulsory even
1363 + // with every member above it named. Drawing the whole body is the
1364 + // right default for a member this crate has not been taught yet:
1365 + // more content rather than less, which is how every other unknown
1366 + // in this vocabulary degrades.
1367 + _ => None,
1368 + }
1369 + }
1370 +
1371 + /// The children's names, when they have them.
1372 + ///
1373 + /// Empty unless *every* child is a named region, which is the test a
1374 + /// renderer applies before drawing a strip: a strip with a hole in it is
1375 + /// worse than the prev/next row it would otherwise have drawn, and a
1376 + /// half-labelled body is an app bug rather than a third idiom.
1377 + #[must_use]
1378 + pub fn labels(&self) -> Vec<&str> {
1379 + let named: Vec<&str> = self
1380 + .body
1381 + .iter()
1382 + .filter_map(|node| match node {
1383 + Node::Region(slot) => slot.label.as_deref(),
1384 + _ => None,
1385 + })
1386 + .collect();
1387 +
1388 + if named.len() == self.body.len() {
1389 + named
1390 + } else {
1391 + Vec::new()
1392 + }
1393 + }
1394 +
1279 1395 /// This slot, or the first slot under this address anywhere inside it.
1280 1396 #[must_use]
1281 1397 pub fn find(&self, id: &str) -> Option<&Self> {
@@ -2805,3 +2921,106 @@
2805 2921 true
2806 2922 }
2807 2923 }
2924 +
2925 + #[cfg(test)]
2926 + mod tests {
2927 + use super::*;
2928 +
2929 + fn frames(count: usize) -> Vec<Node> {
2930 + (0..count)
2931 + .map(|n| {
2932 + Node::Image(Picture::new(
2933 + format!("/frame-{n}.png"),
2934 + format!("frame {n}"),
2935 + ))
2936 + })
2937 + .collect()
2938 + }
2939 +
2940 + #[test]
2941 + fn a_region_shows_everything_until_it_says_otherwise() {
2942 + // The default has to be the old behaviour, or every description written
2943 + // before this field existed changes meaning when it arrives.
2944 + let pane = Slot::new("content", RegionKind::Pane).extend(frames(3));
2945 +
2946 + assert_eq!(pane.showing, layout::Showing::All);
2947 + assert_eq!(pane.current(), None);
2948 + }
2949 +
2950 + #[test]
2951 + fn a_carousel_with_no_stated_frame_is_on_its_first() {
2952 + // `Showing::One` says exactly one is up, so there is no honest reading
2953 + // of a missing index other than the first. A renderer never has to
2954 + // decide this for itself, which is the point of the method.
2955 + let mut carousel = Slot::widget("shots", "carousel").extend(frames(3));
2956 + carousel.showing = layout::Showing::One;
2957 +
2958 + assert_eq!(carousel.current(), Some(0));
2959 + }
2960 +
2961 + #[test]
2962 + fn a_frame_past_the_end_clamps_rather_than_vanishing() {
2963 + // An out-of-range index is an app bug either way. Clamping reports it as
2964 + // a carousel stuck on its last frame, which is findable; drawing nothing
2965 + // reports it as a region that disappeared, which is not.
2966 + let carousel = Slot::widget("shots", "carousel")
2967 + .extend(frames(3))
2968 + .showing_one(9);
2969 +
2970 + assert_eq!(carousel.current(), Some(2));
2971 +
2972 + // And an empty body has no frame to clamp to.
2973 + assert_eq!(
2974 + Slot::widget("shots", "carousel").showing_one(0).current(),
2975 + None
2976 + );
2977 + }
2978 +
2979 + #[test]
2980 + fn a_closed_disclosure_is_the_one_selective_region_showing_nothing() {
2981 + let closed = Slot::widget("details", "disclosure")
2982 + .extend(frames(1))
2983 + .showing_at_most_one(None);
2984 + let open = Slot::widget("details", "disclosure")
2985 + .extend(frames(1))
2986 + .showing_at_most_one(Some(0));
2987 +
2988 + assert_eq!(closed.current(), None);
2989 + assert_eq!(open.current(), Some(0));
2990 +
2991 + // Closed and `Showing::All` answer the same here on purpose: they differ
2992 + // in the chrome around the body, not in what a renderer does with it.
2993 + assert!(closed.showing.selective());
2994 + }
2995 +
2996 + #[test]
2997 + fn labels_are_all_or_nothing() {
2998 + // A strip with a hole in it is worse than the prev/next row it would
2999 + // have replaced, so a half-labelled body gets the row.
3000 + let tabs = Slot::new("detail", RegionKind::TabGroup)
3001 + .with(Node::Region(
3002 + Slot::new("overview", RegionKind::Pane).label("Overview"),
3003 + ))
3004 + .with(Node::Region(
3005 + Slot::new("files", RegionKind::Pane).label("Files"),
3006 + ));
3007 + assert_eq!(tabs.labels(), ["Overview", "Files"]);
3008 +
3009 + let half = tabs
3010 + .clone()
3011 + .with(Node::Region(Slot::new("history", RegionKind::Pane)));
3012 + assert!(half.labels().is_empty());
3013 + }
3014 +
3015 + #[test]
3016 + fn a_carousels_frames_carry_no_label_and_that_is_the_switch() {
3017 + // Which idiom a renderer draws falls out of this rather than out of the
3018 + // widget's name. A frame has a caption; only a region has a tab name.
3019 + let carousel = Slot::widget("shots", "carousel")
3020 + .extend(frames(3))
3021 + .showing_one(1);
3022 +
3023 + assert!(carousel.labels().is_empty());
3024 + assert_eq!(carousel.current(), Some(1));
3025 + }
3026 + }