Skip to main content

max / quasi

Read the share and the measure off the description quasi-tui's 24-column sidebar and 40% list pane are gone. They were this renderer's taste with nothing behind them, and a webview showing the same screen had no way to agree with them because nothing in the description said what the proportions were. Both now come off `Arrangement::share`, so the assertion that two hosts agree about a screen's proportions can be written -- which it now is, and could not have been before. Screen gains a measure, beside the arrangement and answering the level above it. The terminal's answer is gutters and a text measure: 76 columns for reading, because past roughly 75 characters a line costs the reader the return sweep, and centred, because a narrowed column against the left edge of a wide terminal reads as a window that failed to resize. A window already narrower than the cap is left alone. The webview emits the measure as a second class beside the arrangement's, and the share as custom properties. A class rather than a compound name per pairing for the reason `1786cb94` settled one level down; properties rather than a class for the share because a share is a number the description carries and a class can only name a number some stylesheet already fixed. Nothing styled the arrangement classes before this, so the check the finding asked for -- that adopting the description does not change what the webview draws -- had nothing to change. The measure classes join the by-design list with their reason: a measure is placement, like an arrangement, and what it comes to in pixels is the app's stylesheet answering once instead of 69 templates answering separately. What moved is the choice, not the number.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-13 00:06 UTC
Signed with PGP, not checked
Commit: b2df8126fa2edf3c65b6c7a7381f7b9d0d626fca
Parent: 315ce05
10 files changed, +267 insertions, -44 deletions
M Cargo.lock +17 -17
@@ -2076,7 +2076,7 @@
2076 2076
2077 2077 [[package]]
2078 2078 name = "makeover-layout"
2079 - version = "0.17.0"
2079 + version = "0.18.0"
2080 2080
2081 2081 [[package]]
2082 2082 name = "makeover-touch"
@@ -5151,22 +5151,6 @@
5151 5151 source = "registry+https://github.com/rust-lang/crates.io-index"
5152 5152 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
5153 5153
5154 - [[patch.unused]]
5155 - name = "kberg"
5156 - version = "0.1.0"
5157 -
5158 - [[patch.unused]]
5159 - name = "ops-status"
5160 - version = "0.1.0"
5161 -
5162 - [[patch.unused]]
5163 - name = "painhours"
5164 - version = "0.1.0"
5165 -
5166 - [[patch.unused]]
5167 - name = "tagtree"
5168 - version = "0.4.0"
5169 -
5170 5154 [[patch.unused]]
5171 5155 name = "makeover-build"
5172 5156 version = "0.26.0"
@@ -5182,3 +5166,19 @@
5182 5166 [[patch.unused]]
5183 5167 name = "synckit-config"
5184 5168 version = "0.2.0"
5169 +
5170 + [[patch.unused]]
5171 + name = "kberg"
5172 + version = "0.1.0"
5173 +
5174 + [[patch.unused]]
5175 + name = "ops-status"
5176 + version = "0.1.0"
5177 +
5178 + [[patch.unused]]
5179 + name = "painhours"
5180 + version = "0.1.0"
5181 +
5182 + [[patch.unused]]
5183 + name = "tagtree"
5184 + version = "0.4.0"
@@ -13,4 +13,4 @@
13 13 workspace = true
14 14
15 15 [dependencies]
16 - makeover-layout = "0.17.0"
16 + makeover-layout = "0.18.0"
@@ -14,7 +14,7 @@
14 14
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 - makeover-layout = "0.17.0"
17 + makeover-layout = "0.18.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
@@ -15,7 +15,7 @@
15 15 [dependencies]
16 16 quasi-router = { path = "../quasi-router", version = "0.1.0" }
17 17 quasi-http = { path = "../quasi-http", version = "0.1.0" }
18 - makeover-layout = "0.17.0"
18 + makeover-layout = "0.18.0"
19 19 makeover-webview = "0.33.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.
@@ -271,7 +271,7 @@
271 271
272 272 #[test]
273 273 fn a_slot_is_found_at_any_depth() {
274 - let screen = Screen::new("Tabs", Arrangement::ListDetail { tabbed: true }).with(
274 + let screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
275 275 Slot::new("tabs", RegionKind::TabGroup)
276 276 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
277 277 );
@@ -298,7 +298,7 @@
298 298
299 299 #[test]
300 300 fn a_fragment_reaches_a_region_nested_inside_another() {
301 - let mut screen = Screen::new("Tabs", Arrangement::ListDetail { tabbed: true }).with(
301 + let mut screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
302 302 Slot::new("tabs", RegionKind::TabGroup)
303 303 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
304 304 );
@@ -2377,6 +2377,23 @@
2377 2377 /// [`Row::value`]: Row::value
2378 2378 /// [`Act::over`]: Act::over
2379 2379 pub selection: Option<String>,
2380 + /// How wide this screen's content runs.
2381 + ///
2382 + /// `0eccff0d`. Measured in the MNW server, where 69 of 72 templates carry
2383 + /// one of three mutually exclusive CSS classes for it and nothing described
2384 + /// it, so the choice lived in the template rather than in the screen.
2385 + ///
2386 + /// Beside [`arrangement`](Self::arrangement) and answering the level above
2387 + /// it: that one divides the screen's width between regions, this says how
2388 + /// much of the window the screen takes in the first place. Both are the
2389 + /// description's, which is what answering `e0fd485e` and `0eccff0d`
2390 + /// together settled.
2391 + ///
2392 + /// Not an `Option`, for [`discovery`](Self::discovery)'s reason. The
2393 + /// default is meaningful -- a screen nobody said anything about uses the
2394 + /// window it was given -- and an `Option` would make "nobody said" and
2395 + /// "the whole width" two spellings of one thing.
2396 + pub measure: layout::Measure,
2380 2397 }
2381 2398
2382 2399 /// How a screen is found, shared and indexed.
@@ -2485,9 +2502,20 @@
2485 2502 notices: Vec::new(),
2486 2503 discovery: Discovery::default(),
2487 2504 selection: None,
2505 + measure: layout::Measure::default(),
2488 2506 }
2489 2507 }
2490 2508
2509 + /// How wide this screen's content runs, chaining.
2510 + ///
2511 + /// See [`measure`](Self::measure). [`Measure::Wide`](layout::Measure::Wide)
2512 + /// is the default and does not need saying.
2513 + #[must_use]
2514 + pub const fn measured(mut self, measure: layout::Measure) -> Self {
2515 + self.measure = measure;
2516 + self
2517 + }
2518 +
2491 2519 /// This screen holds a set of ticks under this name, chaining.
2492 2520 ///
2493 2521 /// The rows that join it say so with [`Row::ticking`], and the control that
@@ -2535,12 +2563,12 @@
2535 2563
2536 2564 /// A list that chooses what the detail beside it shows.
2537 2565 pub fn list_detail(title: impl Into<String>, tabbed: bool) -> Self {
2538 - Self::new(title, layout::Arrangement::ListDetail { tabbed })
2566 + Self::new(title, layout::Arrangement::list_detail(tabbed))
2539 2567 }
2540 2568
2541 2569 /// Navigation down the side, content filling the rest.
2542 2570 pub fn sidebar_content(title: impl Into<String>) -> Self {
2543 - Self::new(title, layout::Arrangement::SidebarContent)
2571 + Self::new(title, layout::Arrangement::sidebar_content())
2544 2572 }
2545 2573
2546 2574 /// Add a region, chaining.
@@ -28,16 +28,14 @@
28 28
29 29 use crate::{Pass, Tui, below};
30 30
31 - /// How wide a sidebar is, in columns.
32 - ///
33 - /// A number this renderer chose. See the module header.
34 - const SIDEBAR: u16 = 24;
35 -
36 - /// What share of the width a list pane takes in a list-detail arrangement.
37 - const LIST_SHARE: u16 = 40;
31 + // The sidebar's 24 columns and the list pane's 40% used to be declared here,
32 + // as two numbers this renderer chose with nothing behind them. `e0fd485e`:
33 + // they are `Arrangement::share` now, so the terminal and the webview honour one
34 + // fact and two hosts showing one screen agree about its proportions.
38 35
39 36 /// Lay a screen's regions out and draw them.
40 37 pub(crate) fn screen_regions(pass: &mut Pass<'_>, screen: &Screen, area: Rect, buf: &mut Buffer) {
38 + let area = measured(screen.measure, area);
41 39 let mut rest = area;
42 40
43 41 // Bands stack at the top, full width, in the order they were said. A band
@@ -55,8 +53,8 @@
55 53 let body = body_slots(screen);
56 54
57 55 match screen.arrangement {
58 - layout::Arrangement::SidebarContent => {
59 - let (left, right) = split(rest, SIDEBAR);
56 + layout::Arrangement::SidebarContent { share } => {
57 + let (left, right) = split(rest, share.of(rest.width));
60 58 let mut sidebars = 0;
61 59 let mut content = right;
62 60 for slot in &body {
@@ -69,7 +67,7 @@
69 67 }
70 68 }
71 69 }
72 - layout::Arrangement::ListDetail { tabbed } => {
70 + layout::Arrangement::ListDetail { tabbed, share } => {
73 71 if tabbed {
74 72 // One at a time, and nothing says which. `body_slots` has
75 73 // already cut the rest away, so this is the one region there is.
@@ -77,7 +75,7 @@
77 75 draw(pass, first, rest, buf);
78 76 }
79 77 } else {
80 - let (left, right) = split(rest, rest.width * LIST_SHARE / 100);
78 + let (left, right) = split(rest, share.of(rest.width));
81 79 let mut detail = right;
82 80 for (index, slot) in body.iter().enumerate() {
83 81 if index == 0 {
@@ -103,6 +101,40 @@
103 101 }
104 102 }
105 103
104 + /// The screen's area, narrowed to the measure it asked for.
105 + ///
106 + /// `0eccff0d`, the terminal's half of "every renderer owes an answer". The
107 + /// description says how wide the content should run and this says what that is
108 + /// in columns, the same division the webview makes: the screen chose one of
109 + /// three, and what each one comes to is the renderer's.
110 + ///
111 + /// The two caps are this renderer's numbers, and only the second has a reason
112 + /// outside taste: past roughly 75 characters a line costs the reader the return
113 + /// sweep, which is why [`layout::Measure::Reading`] is the narrowest. Centred
114 + /// rather than left-aligned, because a narrowed column against the left edge of
115 + /// a wide terminal reads as a window that failed to resize.
116 + ///
117 + /// A terminal narrower than the cap is left alone rather than padded. There is
118 + /// no measure to enforce when the window is already tighter than it.
119 + fn measured(measure: layout::Measure, area: Rect) -> Rect {
120 + let cap = match measure {
121 + layout::Measure::Reading => 76,
122 + layout::Measure::Contained => 100,
123 + // Every column there is, which is what `Wide` means. Also the arm a
124 + // member added upstream lands in: a measure this renderer has not
125 + // learned should show the whole screen, not hide part of it.
126 + _ => return area,
127 + };
128 + if area.width <= cap {
129 + return area;
130 + }
131 + Rect {
132 + x: area.x + (area.width - cap) / 2,
133 + width: cap,
134 + ..area
135 + }
136 + }
137 +
106 138 /// The regions that fill the body, after the arrangement has had its say.
107 139 ///
108 140 /// The tabbed cut lives here and only here. It is the one place a described
@@ -116,7 +148,7 @@
116 148 .filter(|slot| !matches!(slot.kind, RegionKind::Band | RegionKind::Modal));
117 149
118 150 match screen.arrangement {
119 - layout::Arrangement::ListDetail { tabbed: true } => body.take(1).collect(),
151 + layout::Arrangement::ListDetail { tabbed: true, .. } => body.take(1).collect(),
120 152 _ => body.collect(),
121 153 }
122 154 }
@@ -87,6 +87,15 @@
87 87 rows(&buf).join(" ")
88 88 }
89 89
90 + /// The column some text starts at, in whichever row holds it.
91 + ///
92 + /// A region's contents sit below its top border, so the row a word lands on is
93 + /// not the row the region starts on. Asserting against row 0 measures the
94 + /// border and not the content.
95 + fn column_of(rows: &[String], text: &str) -> Option<usize> {
96 + rows.iter().find_map(|row| row.find(text))
97 + }
98 +
90 99 fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> {
91 100 let area = Rect::new(0, 0, width, height);
92 101 let mut buf = Buffer::empty(area);
@@ -1122,3 +1131,80 @@
1122 1131 0
1123 1132 );
1124 1133 }
1134 +
1135 + #[test]
1136 + fn a_terminal_and_a_webview_agree_about_a_screens_proportions() {
1137 + // The done-condition of `e0fd485e`: a screen described once, rendered by
1138 + // two hosts, agreeing about its proportions. The assertion that did not
1139 + // exist while each renderer held its own number.
1140 + let screen = Screen::sidebar_content("Mail")
1141 + .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("Folders")))
1142 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
1143 +
1144 + // A quarter of 100 columns is 25, and the webview writes the same quarter
1145 + // into its grid. Read off the description rather than off either renderer.
1146 + assert_eq!(screen.arrangement.share().as_percent(), 25);
1147 + assert_eq!(screen.arrangement.share().of(100), 25);
1148 +
1149 + // And the terminal honours it rather than a number of its own: "Folders"
1150 + // fits in 25 columns and "Messages" starts after them.
1151 + let out = shown(&screen, 100, 6);
1152 + assert!(column_of(&out, "Folders") == Some(0), "{out:?}");
1153 + assert!(
1154 + column_of(&out, "Messages").is_some_and(|at| at >= 25),
1155 + "the content should start after the sidebar's quarter: {out:?}"
1156 + );
1157 + }
1158 +
1159 + #[test]
1160 + fn a_narrower_share_moves_the_boundary_on_the_terminal_too() {
1161 + let screen = Screen::new(
1162 + "Mail",
1163 + layout::Arrangement::sidebar_content().with_share(layout::Share::percent(10)),
1164 + )
1165 + .with(Slot::new("side", RegionKind::Sidebar).with(Node::text("F")))
1166 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages")));
1167 +
1168 + let out = shown(&screen, 100, 6);
1169 + assert!(
1170 + column_of(&out, "Messages").is_some_and(|at| (10..25).contains(&at)),
1171 + "the boundary should follow the described share: {out:?}"
1172 + );
1173 + }
1174 +
1175 + #[test]
1176 + fn a_reading_measure_narrows_the_screen_and_centres_it() {
1177 + // The terminal's answer to `Measure`, and the one with a reason outside
1178 + // taste: past roughly 75 characters a line costs the reader the return
1179 + // sweep.
1180 + let wide = Screen::sidebar_content("Doc")
1181 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")));
1182 + let reading = wide.clone().measured(layout::Measure::Reading);
1183 +
1184 + let full = shown(&wide, 120, 4);
1185 + let narrowed = shown(&reading, 120, 4);
1186 +
1187 + let at = |rows: &[String]| column_of(rows, "Words");
1188 + assert!(
1189 + at(&narrowed) > at(&full),
1190 + "a narrowed screen is centred, so its content starts further in: \
1191 + {full:?} then {narrowed:?}"
1192 + );
1193 + }
1194 +
1195 + #[test]
1196 + fn a_terminal_narrower_than_the_measure_is_left_alone() {
1197 + // There is no measure to enforce when the window is already tighter than
1198 + // it, and padding one would waste the only columns there are.
1199 + let screen = Screen::sidebar_content("Doc")
1200 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("Words")))
1201 + .measured(layout::Measure::Reading);
1202 +
1203 + let out = shown(&screen, 40, 4);
1204 + // Drawn at all, and not squeezed into a centred column of a window that is
1205 + // already narrower than the cap.
1206 + assert!(
1207 + column_of(&out, "Words").is_some_and(|at| at < 15),
1208 + "{out:?}"
1209 + );
1210 + }
@@ -135,11 +135,30 @@
135 135 /// description becomes a framework.
136 136 fn arrangement_class(arrangement: Arrangement) -> &'static str {
137 137 match arrangement {
138 - Arrangement::ListDetail { tabbed: false } => "list-detail",
139 - Arrangement::ListDetail { tabbed: true } => "list-detail-tabbed",
140 - Arrangement::SidebarContent => "sidebar-content",
138 + Arrangement::ListDetail { tabbed: false, .. } => "list-detail",
139 + Arrangement::ListDetail { tabbed: true, .. } => "list-detail-tabbed",
140 + Arrangement::SidebarContent { .. } => "sidebar-content",
141 141 }
142 142 }
143 +
144 + /// The share, as the grid that honours it.
145 + ///
146 + /// `e0fd485e`. An inline style rather than a class, because the share is a
147 + /// number the description carries and a class can only name a number some
148 + /// stylesheet already fixed. Nothing in `makeover-webview` styled these
149 + /// classes at all before this, so no shipped rule is being overridden: the
150 + /// check the finding asked for was whether adopting the description changes
151 + /// what the webview draws, and there was nothing there to change.
152 + ///
153 + /// `fr` rather than a percentage, so the gap between the regions comes out
154 + /// of the whole rather than out of the second one.
155 + fn share_style(arrangement: Arrangement) -> String {
156 + let first = u16::from(arrangement.share().as_percent());
157 + format!(
158 + " style=\"--region-share:{first}fr;--region-rest:{}fr\"",
159 + 100 - first
160 + )
161 + }
143 162 }
144 163
145 164 impl Serves for Webview {
@@ -153,7 +172,18 @@
153 172 Self::arrangement_class(screen.arrangement),
154 173 &self.emit,
155 174 ));
156 - out.push_str("\">");
175 + // How wide the screen runs, beside how its width is divided. Two
176 + // classes rather than one compound name: they vary independently, and a
177 + // `wide-list-detail` class per pairing is the enumeration `1786cb94`
178 + // settled against one level down.
179 + out.push(' ');
180 + out.push_str(&makeover_webview::class(
181 + &format!("measure-{}", screen.measure.as_str()),
182 + &self.emit,
183 + ));
184 + out.push('"');
185 + out.push_str(&Self::share_style(screen.arrangement));
186 + out.push('>');
157 187
158 188 // Notices before the regions, because a notice belongs to the screen
159 189 // rather than to a place in it, and the first thing in the document is
@@ -1563,9 +1563,37 @@
1563 1563 let tabbed = render(&Screen::list_detail("A", true));
1564 1564 let sidebar = render(&Screen::sidebar_content("A"));
1565 1565
1566 - assert!(plain.contains("class=\"list-detail\""));
1567 - assert!(tabbed.contains("class=\"list-detail-tabbed\""));
1568 - assert!(sidebar.contains("class=\"sidebar-content\""));
1566 + // The measure rides in the same attribute, and the two vary independently:
1567 + // a compound `wide-list-detail` per pairing is the enumeration `1786cb94`
1568 + // settled against one level down.
1569 + assert!(plain.contains("class=\"list-detail measure-wide\""));
1570 + assert!(tabbed.contains("class=\"list-detail-tabbed measure-wide\""));
1571 + assert!(sidebar.contains("class=\"sidebar-content measure-wide\""));
1572 +
1573 + let reading = render(&Screen::list_detail("A", false).measured(layout::Measure::Reading));
1574 + assert!(reading.contains("class=\"list-detail measure-reading\""));
1575 + }
1576 +
1577 + #[test]
1578 + fn a_screen_carries_the_share_as_a_number_rather_than_a_class() {
1579 + // `e0fd485e`. A share is a number the description carries, and a class can
1580 + // only name a number some stylesheet already fixed -- which is the drift
1581 + // the member exists to end, since a terminal has no stylesheet to read it
1582 + // out of.
1583 + let wide = render(&Screen::sidebar_content("A"));
1584 + assert!(
1585 + wide.contains("--region-share:25fr;--region-rest:75fr"),
1586 + "{wide}"
1587 + );
1588 +
1589 + let narrow = render(&Screen::new(
1590 + "A",
1591 + layout::Arrangement::sidebar_content().with_share(layout::Share::percent(20)),
1592 + ));
1593 + assert!(
1594 + narrow.contains("--region-share:20fr;--region-rest:80fr"),
1595 + "{narrow}"
1596 + );
1569 1597 }
1570 1598
1571 1599 #[test]
@@ -1582,7 +1610,7 @@
1582 1610 );
1583 1611 let html = Webview::new().with_emit(emit).screen(&screen);
1584 1612
1585 - assert!(html.contains("class=\"qs-list-detail\""));
1613 + assert!(html.contains("class=\"qs-list-detail qs-measure-wide\""));
1586 1614 assert!(html.contains("qs-region qs-pane"));
1587 1615 assert!(html.contains("qs-heading"));
1588 1616 assert!(html.contains("qs-list"));
@@ -2163,6 +2191,17 @@
2163 2191 Slot::bespoke("bespoke", "map").with(Node::text("beside a fill")),
2164 2192 )));
2165 2193 htmls.push(render(&Screen::list_detail("Tabbed", true)));
2194 + // Every measure, so the accounting below covers all three rather than only
2195 + // the default a screen gets for saying nothing.
2196 + for measure in [
2197 + layout::Measure::Wide,
2198 + layout::Measure::Contained,
2199 + layout::Measure::Reading,
2200 + ] {
2201 + htmls.push(render(
2202 + &Screen::list_detail("Measured", false).measured(measure),
2203 + ));
2204 + }
2166 2205
2167 2206 let acts = || Act::new("Remove", Action::post("/keys/7/delete")).tone(layout::Tone::Danger);
2168 2207 for node in [
@@ -2287,6 +2326,14 @@
2287 2326 "list-detail",
2288 2327 "list-detail-tabbed",
2289 2328 "sidebar-content",
2329 + // Measures, from `Screen::measure`. Placement for the same reason an
2330 + // arrangement is: the screen says which of the three it is, and what a
2331 + // measure means in pixels is the app's stylesheet answering once instead of
2332 + // 69 templates answering separately. `0eccff0d` moved the choice, not the
2333 + // number.
2334 + "measure-wide",
2335 + "measure-contained",
2336 + "measure-reading",
2290 2337 // Regions, from `region_class`.
2291 2338 "band",
2292 2339 "bespoke",