Skip to main content

max / quasi

18.3 KB · 455 lines History Blame Raw
1 //! What each element of a description may hold.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! # Why this exists
6 //!
7 //! Before this, composability was a per-pairing enumeration: every
8 //! primitive-inside-container pairing was a member, three renderer arms and a
9 //! release. `RowPart::Tokens` put a tag in a row, `Cell::tokens` put the same
10 //! tag in a cell, `RowPart::Proportion` put a meter in a row, and a meter in a
11 //! cell was simply not sayable. Three of the seven pairings in that matrix were
12 //! filled in two days, which is the measurement that decided this: the rate was
13 //! rising, not falling.
14 //!
15 //! The rule the enumeration was defending -- "a row holds no nodes, the door
16 //! through which a description becomes a templating language" -- was a rider on
17 //! a different 2026-08-08 decision (the one that shipped `RowPart::Tokens`),
18 //! promoted to a constitution by later sessions and cited six times in
19 //! `screen.rs` as standing law. It also did not do what it claimed:
20 //!
21 //! 1. It bounded one seam rather than the tree. `Node::Region(Slot)` holding
22 //! `Vec<Node>` holding `Node::Region(Slot)` is unbounded nesting and has
23 //! always been accepted.
24 //! 2. It was enforced by the absence of a type rather than by a type, so
25 //! nothing stopped [`Cell`](crate::Cell) growing a member per release until
26 //! it was a node under another name -- which it was already doing, going
27 //! from a `String` to four fields in one release.
28 //! 3. The thing worth bounding is what a constrained renderer must be able to
29 //! draw, not how deep the tree goes. The two got conflated.
30 //!
31 //! So the bound moves from a doc comment to a property every element declares
32 //! and one test checks. The 2026-08-08 rider was never checkable; this is.
33 //!
34 //! # The bound
35 //!
36 //! Three rules, all asserted by `the_containment_ladder_only_goes_down`:
37 //!
38 //! - **An inline run may not reach blocks or collections.** Depth below a line
39 //! is one, so a run is always drawable on one wrapped line. That is the
40 //! constrained-consumer test: a terminal can draw any run in a cell without
41 //! knowing what is in it.
42 //! - **A collection's element type is fixed and non-recursive.** A [`Cell`] is
43 //! never a [`Row`]. The container names what it holds, once.
44 //! - **Blocks may contain blocks.** Already true, already accepted, and a
45 //! nested region is a nested rect on every host.
46 //!
47 //! # What this is not
48 //!
49 //! Not a type-erased tree. Every element stays a closed enum, so a renderer
50 //! still matches exhaustively to pick a drawing and the compiler still says
51 //! when a member is added. A `Vec<Box<dyn Container>>` would take that away and
52 //! cost `Clone` and `PartialEq` besides. [`Element`] is a query surface over a
53 //! closed set, which is what `makeover_layout::Intent` already is.
54
55 use crate::screen::{
56 Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Node, Prose, RegionKind, Row, Slot, Tag,
57 };
58
59 /// What an element may hold.
60 ///
61 /// The default is [`Text`](Self::Text), because most of the vocabulary is a
62 /// label. [`Heading`](Node::Heading), [`Text`](Node::Text), [`Token`](Node::Token)
63 /// and the rest declare nothing and get it.
64 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
65 #[non_exhaustive]
66 pub enum Containment {
67 /// Prose, and only prose.
68 ///
69 /// [`Prose`] rather than `String`, so the markdown answer that
70 /// `df246b95` settled for a row part comes along free everywhere else: a
71 /// leaf says which of the two kinds of string it is, and a renderer that
72 /// can draw markdown does.
73 Text,
74 /// A run of things on a line, holding no blocks.
75 Inlines,
76 /// Other blocks, regions included.
77 Blocks,
78 /// A homogeneous sequence whose element type the container fixes.
79 Collection(Of),
80 /// The app fills it, so what is finally there is not the description's to
81 /// know.
82 ///
83 /// What [`RegionKind::Bespoke`](crate::RegionKind::Bespoke) always was.
84 /// Under the enumeration it was an exception to a rule; here it is an
85 /// answer to the same question every other element answers.
86 ///
87 /// Not the same as holding nothing, which is what this said until `Slot`
88 /// was migrated and the renderer was read against it: a bespoke region
89 /// draws the blocks the description put in it and *then* the host's fill,
90 /// which is a deliberate arrangement -- a heading the description owns
91 /// above a canvas it does not. So the claim is about knowledge rather than
92 /// about emptiness. A renderer cannot answer "what is in this region" from
93 /// the description alone, which is the property every consumer of this
94 /// actually needs, and it is why the answer is off the ladder rather than
95 /// on top of it.
96 Opaque,
97 }
98
99 /// The element type a [`Containment::Collection`] holds.
100 ///
101 /// One variant per element type rather than the five the review sketched,
102 /// because the tree has two kinds of row: a list holds [`Row`], and a table
103 /// holds [`Cells`], which is a row of [`Cell`]. Folding those into one variant
104 /// would make "the element type is fixed by the container" false on the first
105 /// container anyone checked.
106 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107 #[non_exhaustive]
108 pub enum Of {
109 /// A list's rows. [`Row`].
110 Rows,
111 /// A table's rows. [`Cells`].
112 TableRows,
113 /// A table row's cells. [`Cell`].
114 Cells,
115 /// A form's questions. [`Field`].
116 Fields,
117 /// A stats strip's figures. [`Figure`].
118 Figures,
119 /// A control's options. [`Choice`].
120 Choices,
121 }
122
123 impl Of {
124 /// What one element of this collection may itself hold.
125 ///
126 /// The recursion check reads this rather than reaching for the element's
127 /// own [`Element`] impl, because an `Of` names a type and not a value and
128 /// there is nothing to call the trait on.
129 #[must_use]
130 pub fn element_containment(self) -> Containment {
131 match self {
132 // A row is a run: parts on a line, none of them a block.
133 Self::Rows | Self::TableRows | Self::Cells => Containment::Inlines,
134 // A field holds its options when it has any, and text otherwise.
135 // The looser of the two is what the bound has to hold against.
136 Self::Fields => Containment::Collection(Of::Choices),
137 Self::Figures | Self::Choices => Containment::Text,
138 }
139 }
140 }
141
142 /// How far down the containment ladder a rung sits.
143 ///
144 /// [`Opaque`](Containment::Opaque) is off the ladder rather than on top of it:
145 /// the description stops there, so there is nothing below it to bound and
146 /// nothing it can reach. Giving it a rank would make it either a block that
147 /// must not appear in a run, which is right by accident, or a leaf, which is
148 /// wrong.
149 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
150 pub enum Level {
151 /// [`Containment::Text`].
152 Leaf,
153 /// [`Containment::Inlines`].
154 Run,
155 /// [`Containment::Blocks`] and [`Containment::Collection`].
156 Block,
157 }
158
159 impl Containment {
160 /// Where this sits on the ladder, if it is on it.
161 #[must_use]
162 pub fn level(self) -> Option<Level> {
163 match self {
164 Self::Text => Some(Level::Leaf),
165 Self::Inlines => Some(Level::Run),
166 Self::Blocks | Self::Collection(_) => Some(Level::Block),
167 Self::Opaque => None,
168 }
169 }
170 }
171
172 /// What an element of a description may hold.
173 ///
174 /// Implemented for every element rather than for a bucket of them, which is the
175 /// difference between this and the two-tier shape the review also costed. Two
176 /// tiers leave [`Node::List`], [`Node::Table`] and [`Node::Form`] in neither,
177 /// so all three become special-cased members with hardcoded children and the
178 /// enumeration problem moves up a level. [`Containment::Collection`] states
179 /// outright what that shape has to special-case.
180 pub trait Element {
181 /// What this may hold. Most of the vocabulary is a label and takes the
182 /// default.
183 fn containment(&self) -> Containment {
184 Containment::Text
185 }
186 }
187
188 impl Element for Node {
189 fn containment(&self) -> Containment {
190 match self {
191 // Labels. The default, spelled out here only because a match has to
192 // be exhaustive.
193 Self::Heading { .. } | Self::Text { .. } | Self::Rich { .. } | Self::Token(_) => {
194 Containment::Text
195 }
196 // Text that goes somewhere. The address is not content, the same
197 // way an act's is not.
198 Self::Link { .. } => Containment::Text,
199 // One figure on a line. The strip is the collection; this is not.
200 Self::Figure(_) => Containment::Text,
201 // An act is a label and an address. The address is not content.
202 Self::Act(_) => Containment::Text,
203 // A notice is a sentence with a tone. It was never allowed to hold
204 // anything and this does not change that.
205 Self::Notice { .. } => Containment::Text,
206 // A sentence and, sometimes, the way out. Two things on a line.
207 Self::StandIn { .. } => Containment::Inlines,
208 // One field, so this is the field's own answer rather than a
209 // collection of one.
210 Self::Field(field) => field.containment(),
211 Self::Form { .. } => Containment::Collection(Of::Fields),
212 Self::List { .. } => Containment::Collection(Of::Rows),
213 // The columns are the table's schema and not its content, the way
214 // `Screen::discovery` is metadata rather than a region. What a
215 // table *holds* is rows.
216 Self::Table { .. } => Containment::Collection(Of::TableRows),
217 Self::Select { .. } => Containment::Collection(Of::Choices),
218 Self::Meter(meter) => meter.containment(),
219 Self::Stats { .. } => Containment::Collection(Of::Figures),
220 Self::Region(slot) => slot.containment(),
221 }
222 }
223 }
224
225 impl Element for Slot {
226 fn containment(&self) -> Containment {
227 // The one place `Opaque` is reached, and the reason it exists. A
228 // bespoke region is filled by the host, so what is finally in it is not
229 // answerable from the description -- not that the description put
230 // nothing there, which is a different claim and the wrong one. Every
231 // other region holds blocks, regions included, which is the nesting
232 // that was always accepted and that the row rule never touched.
233 if matches!(self.kind, RegionKind::Bespoke { .. }) {
234 Containment::Opaque
235 } else {
236 Containment::Blocks
237 }
238 }
239 }
240
241 impl Element for Row {
242 fn containment(&self) -> Containment {
243 Containment::Inlines
244 }
245 }
246
247 impl Element for Cells {
248 fn containment(&self) -> Containment {
249 Containment::Collection(Of::Cells)
250 }
251 }
252
253 impl Element for Cell {
254 fn containment(&self) -> Containment {
255 Containment::Inlines
256 }
257 }
258
259 impl Element for Field {
260 fn containment(&self) -> Containment {
261 if self.kind.offers_options() {
262 Containment::Collection(Of::Choices)
263 } else {
264 Containment::Text
265 }
266 }
267 }
268
269 // The leaves. Each takes the default, and each says so by name rather than by
270 // silence, because "nobody wrote an impl" and "this holds a label" look the
271 // same from outside and only one of them is a decision.
272 impl Element for Act {}
273 impl Element for Tag {}
274 impl Element for Figure {}
275 impl Element for Meter {}
276 impl Element for Choice {}
277 impl Element for Column {}
278 impl Element for Prose {}
279
280 #[cfg(test)]
281 mod tests {
282 use super::*;
283 use crate::layout;
284 use crate::screen::Action;
285
286 /// Every element type, as a value, so the ladder test can ask each one.
287 ///
288 /// A list rather than a derive because the question is about the vocabulary
289 /// and not about any one screen: adding a `Node` member and not adding it
290 /// here is caught by the exhaustive match in [`Element for Node`], which is
291 /// where a new member has to be answered for anyway.
292 fn every_node() -> Vec<Node> {
293 vec![
294 Node::page("t"),
295 Node::text("t"),
296 Node::rich("*t*"),
297 Node::Act(Act::new("go", Action::get("/"))),
298 Node::Token(Tag::badge("tag")),
299 Node::Notice {
300 kind: layout::Notice::Banner,
301 tone: layout::Tone::Neutral,
302 text: "t".into(),
303 },
304 Node::StandIn {
305 state: layout::Readiness::Empty,
306 message: "nothing yet".into(),
307 act: None,
308 },
309 Node::Field(Box::new(Field::new(
310 layout::FieldKind::Text,
311 "name",
312 "Name",
313 ))),
314 Node::Form {
315 action: Action::post("/"),
316 submit: "Save".into(),
317 fields: Vec::new(),
318 },
319 Node::List {
320 rows: Vec::new(),
321 more: None,
322 },
323 Node::Table {
324 columns: Vec::new(),
325 rows: Vec::new(),
326 },
327 Node::Select {
328 kind: layout::Selector::Tabs,
329 options: Vec::new(),
330 chosen: None,
331 action: None,
332 },
333 Node::Meter(Meter::new(1, 2)),
334 Node::Stats {
335 figures: Vec::new(),
336 },
337 Node::Region(Slot::new("r", RegionKind::Pane)),
338 ]
339 }
340
341 #[test]
342 fn the_containment_ladder_only_goes_down() {
343 // Rule 1. An inline run may not reach blocks or collections, so depth
344 // below a line is one and a run is always drawable on one wrapped line.
345 // This is the constrained-consumer test: a terminal draws a cell
346 // without knowing what is in it.
347 for node in every_node() {
348 if node.containment() != Containment::Text {
349 continue;
350 }
351 // A node that may sit in a run is a leaf, by construction. Nothing
352 // to assert beyond the classification itself being total.
353 assert_eq!(node.containment().level(), Some(Level::Leaf));
354 }
355
356 // Rule 2. A collection's element type is fixed and its element sits
357 // strictly below a collection: never another collection, never blocks.
358 for of in [
359 Of::Rows,
360 Of::TableRows,
361 Of::Cells,
362 Of::Fields,
363 Of::Figures,
364 Of::Choices,
365 ] {
366 let inner = of.element_containment();
367 // Fields are the one collection whose element is itself a
368 // collection, and it is the terminating one: a choice is a label.
369 if let Containment::Collection(nested) = inner {
370 assert_eq!(
371 nested.element_containment(),
372 Containment::Text,
373 "{of:?} nests {nested:?}, which must terminate in text"
374 );
375 assert_ne!(nested, of, "{of:?} holds itself");
376 continue;
377 }
378 assert!(
379 matches!(inner, Containment::Text | Containment::Inlines),
380 "{of:?} holds {inner:?}, which is not below a collection"
381 );
382 }
383
384 // Rule 3. Blocks may contain blocks, and a region is the one that does.
385 let region = Node::Region(Slot::new("r", RegionKind::Pane));
386 assert_eq!(region.containment(), Containment::Blocks);
387 }
388
389 #[test]
390 fn a_run_holds_no_blocks() {
391 // The rule stated the other way round, over the members that are runs.
392 // A row and a cell hold parts on a line; neither may hold a list. Both
393 // are runs in fact and not only by declaration since `Row` moved:
394 // `Row::part` and `Cell::part` assert this same answer at a call site.
395 for run in [Row::new("r").containment(), Cell::new("c").containment()] {
396 assert_eq!(run, Containment::Inlines);
397 assert_eq!(run.level(), Some(Level::Run));
398 assert!(run.level() < Containment::Blocks.level());
399 }
400 }
401
402 #[test]
403 fn most_of_the_vocabulary_declares_nothing_and_is_text() {
404 // The default is what makes this cheap: a leaf writes no impl body.
405 assert_eq!(Tag::badge("t").containment(), Containment::Text);
406 assert_eq!(
407 Act::new("go", Action::get("/")).containment(),
408 Containment::Text
409 );
410 assert_eq!(Figure::new("7", "tasks").containment(), Containment::Text);
411 assert_eq!(Meter::new(1, 2).containment(), Containment::Text);
412 }
413
414 #[test]
415 fn a_field_holds_its_options_only_when_it_has_any() {
416 // The one element whose containment depends on its own state, which is
417 // why the trait takes `&self` rather than being an associated const.
418 let plain = Field::new(layout::FieldKind::Text, "name", "Name");
419 assert_eq!(plain.containment(), Containment::Text);
420
421 let choosing = Field::select("priority", "Priority", vec![Choice::plain("high")]);
422 assert_eq!(choosing.containment(), Containment::Collection(Of::Choices));
423 assert!(choosing.kind.offers_options());
424 }
425
426 #[test]
427 fn a_region_holds_leaves_as_well_as_blocks() {
428 // Going down the ladder is what containment is for, so a block holding
429 // a leaf needs no permission and gets no assertion. `Cell::part` and
430 // `Row::part` guard the one direction that has to be guarded, which is
431 // a run reaching up at a block; there is no upward violation a slot can
432 // commit, so `Slot::with` checks nothing.
433 let pane = Slot::new("facts", RegionKind::Pane)
434 .with(Node::section("Facts"))
435 .with(Node::text("Two files"))
436 .with(Node::Token(Tag::badge("beta")));
437
438 assert_eq!(pane.containment(), Containment::Blocks);
439 for node in &pane.body {
440 assert!(node.containment().level() <= pane.containment().level());
441 }
442 }
443
444 #[test]
445 fn a_bespoke_region_is_opaque_rather_than_an_exception() {
446 // Under the enumeration this was a hole in the rule. Here it answers
447 // the same question every other element answers, and the answer is
448 // "the app fills it".
449 let bespoke = Node::Region(Slot::bespoke("canvas", "editor"));
450 assert_eq!(bespoke.containment(), Containment::Opaque);
451 // Off the ladder, so it can neither be reached into nor reach out.
452 assert_eq!(bespoke.containment().level(), None);
453 }
454 }
455