Skip to main content

max / quasi

19.1 KB · 467 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 // A picture holds nothing. Its alt text and caption are its own
202 // fields rather than content it contains, the same way a figure's
203 // caption is: they describe the leaf, and nothing can be nested
204 // under them. A gallery is a widget assembled out of several of
205 // these, which is a container's answer and not this one's.
206 Self::Image(_) => Containment::Text,
207 // An act is a label and an address. The address is not content.
208 Self::Act(_) => Containment::Text,
209 // A notice is a sentence with a tone. It was never allowed to hold
210 // anything and this does not change that.
211 Self::Notice { .. } => Containment::Text,
212 // A sentence and, sometimes, the way out. Two things on a line.
213 Self::StandIn { .. } => Containment::Inlines,
214 // One field, so this is the field's own answer rather than a
215 // collection of one.
216 Self::Field(field) => field.containment(),
217 Self::Form { .. } => Containment::Collection(Of::Fields),
218 Self::List { .. } => Containment::Collection(Of::Rows),
219 // Rows, the same as a list. What a timeline changes is *where* each
220 // row goes, and a placement is no more content than a table's
221 // columns are: it says how the collection is arranged, not what is
222 // in it. So this is `Of::Rows` rather than a kind of its own, and a
223 // walker that already handles a list needs nothing new.
224 Self::Timeline { .. } => Containment::Collection(Of::Rows),
225 // The columns are the table's schema and not its content, the way
226 // `Screen::discovery` is metadata rather than a region. What a
227 // table *holds* is rows.
228 Self::Table { .. } => Containment::Collection(Of::TableRows),
229 Self::Select { .. } => Containment::Collection(Of::Choices),
230 Self::Meter(meter) => meter.containment(),
231 Self::Stats { .. } => Containment::Collection(Of::Figures),
232 Self::Region(slot) => slot.containment(),
233 }
234 }
235 }
236
237 impl Element for Slot {
238 fn containment(&self) -> Containment {
239 // The one place `Opaque` is reached, and the reason it exists. A
240 // bespoke region is filled by the host, so what is finally in it is not
241 // answerable from the description -- not that the description put
242 // nothing there, which is a different claim and the wrong one. Every
243 // other region holds blocks, regions included, which is the nesting
244 // that was always accepted and that the row rule never touched.
245 if matches!(self.kind, RegionKind::Bespoke { .. }) {
246 Containment::Opaque
247 } else {
248 Containment::Blocks
249 }
250 }
251 }
252
253 impl Element for Row {
254 fn containment(&self) -> Containment {
255 Containment::Inlines
256 }
257 }
258
259 impl Element for Cells {
260 fn containment(&self) -> Containment {
261 Containment::Collection(Of::Cells)
262 }
263 }
264
265 impl Element for Cell {
266 fn containment(&self) -> Containment {
267 Containment::Inlines
268 }
269 }
270
271 impl Element for Field {
272 fn containment(&self) -> Containment {
273 if self.kind.offers_options() {
274 Containment::Collection(Of::Choices)
275 } else {
276 Containment::Text
277 }
278 }
279 }
280
281 // The leaves. Each takes the default, and each says so by name rather than by
282 // silence, because "nobody wrote an impl" and "this holds a label" look the
283 // same from outside and only one of them is a decision.
284 impl Element for Act {}
285 impl Element for Tag {}
286 impl Element for Figure {}
287 impl Element for Meter {}
288 impl Element for Choice {}
289 impl Element for Column {}
290 impl Element for Prose {}
291
292 #[cfg(test)]
293 mod tests {
294 use super::*;
295 use crate::layout;
296 use crate::screen::Action;
297
298 /// Every element type, as a value, so the ladder test can ask each one.
299 ///
300 /// A list rather than a derive because the question is about the vocabulary
301 /// and not about any one screen: adding a `Node` member and not adding it
302 /// here is caught by the exhaustive match in [`Element for Node`], which is
303 /// where a new member has to be answered for anyway.
304 fn every_node() -> Vec<Node> {
305 vec![
306 Node::page("t"),
307 Node::text("t"),
308 Node::rich("*t*"),
309 Node::Act(Act::new("go", Action::get("/"))),
310 Node::Token(Tag::badge("tag")),
311 Node::Notice {
312 kind: layout::Notice::Banner,
313 tone: layout::Tone::Neutral,
314 text: "t".into(),
315 },
316 Node::StandIn {
317 state: layout::Readiness::Empty,
318 message: "nothing yet".into(),
319 act: None,
320 },
321 Node::Field(Box::new(Field::new(
322 layout::FieldKind::Text,
323 "name",
324 "Name",
325 ))),
326 Node::Form {
327 action: Action::post("/"),
328 submit: "Save".into(),
329 fields: Vec::new(),
330 },
331 Node::List {
332 rows: Vec::new(),
333 more: None,
334 },
335 Node::Table {
336 columns: Vec::new(),
337 rows: Vec::new(),
338 },
339 Node::Select {
340 kind: layout::Selector::Tabs,
341 options: Vec::new(),
342 chosen: None,
343 action: None,
344 },
345 Node::Meter(Meter::new(1, 2)),
346 Node::Stats {
347 figures: Vec::new(),
348 },
349 Node::Region(Slot::new("r", RegionKind::Pane)),
350 ]
351 }
352
353 #[test]
354 fn the_containment_ladder_only_goes_down() {
355 // Rule 1. An inline run may not reach blocks or collections, so depth
356 // below a line is one and a run is always drawable on one wrapped line.
357 // This is the constrained-consumer test: a terminal draws a cell
358 // without knowing what is in it.
359 for node in every_node() {
360 if node.containment() != Containment::Text {
361 continue;
362 }
363 // A node that may sit in a run is a leaf, by construction. Nothing
364 // to assert beyond the classification itself being total.
365 assert_eq!(node.containment().level(), Some(Level::Leaf));
366 }
367
368 // Rule 2. A collection's element type is fixed and its element sits
369 // strictly below a collection: never another collection, never blocks.
370 for of in [
371 Of::Rows,
372 Of::TableRows,
373 Of::Cells,
374 Of::Fields,
375 Of::Figures,
376 Of::Choices,
377 ] {
378 let inner = of.element_containment();
379 // Fields are the one collection whose element is itself a
380 // collection, and it is the terminating one: a choice is a label.
381 if let Containment::Collection(nested) = inner {
382 assert_eq!(
383 nested.element_containment(),
384 Containment::Text,
385 "{of:?} nests {nested:?}, which must terminate in text"
386 );
387 assert_ne!(nested, of, "{of:?} holds itself");
388 continue;
389 }
390 assert!(
391 matches!(inner, Containment::Text | Containment::Inlines),
392 "{of:?} holds {inner:?}, which is not below a collection"
393 );
394 }
395
396 // Rule 3. Blocks may contain blocks, and a region is the one that does.
397 let region = Node::Region(Slot::new("r", RegionKind::Pane));
398 assert_eq!(region.containment(), Containment::Blocks);
399 }
400
401 #[test]
402 fn a_run_holds_no_blocks() {
403 // The rule stated the other way round, over the members that are runs.
404 // A row and a cell hold parts on a line; neither may hold a list. Both
405 // are runs in fact and not only by declaration since `Row` moved:
406 // `Row::part` and `Cell::part` assert this same answer at a call site.
407 for run in [Row::new("r").containment(), Cell::new("c").containment()] {
408 assert_eq!(run, Containment::Inlines);
409 assert_eq!(run.level(), Some(Level::Run));
410 assert!(run.level() < Containment::Blocks.level());
411 }
412 }
413
414 #[test]
415 fn most_of_the_vocabulary_declares_nothing_and_is_text() {
416 // The default is what makes this cheap: a leaf writes no impl body.
417 assert_eq!(Tag::badge("t").containment(), Containment::Text);
418 assert_eq!(
419 Act::new("go", Action::get("/")).containment(),
420 Containment::Text
421 );
422 assert_eq!(Figure::new("7", "tasks").containment(), Containment::Text);
423 assert_eq!(Meter::new(1, 2).containment(), Containment::Text);
424 }
425
426 #[test]
427 fn a_field_holds_its_options_only_when_it_has_any() {
428 // The one element whose containment depends on its own state, which is
429 // why the trait takes `&self` rather than being an associated const.
430 let plain = Field::new(layout::FieldKind::Text, "name", "Name");
431 assert_eq!(plain.containment(), Containment::Text);
432
433 let choosing = Field::select("priority", "Priority", vec![Choice::plain("high")]);
434 assert_eq!(choosing.containment(), Containment::Collection(Of::Choices));
435 assert!(choosing.kind.offers_options());
436 }
437
438 #[test]
439 fn a_region_holds_leaves_as_well_as_blocks() {
440 // Going down the ladder is what containment is for, so a block holding
441 // a leaf needs no permission and gets no assertion. `Cell::part` and
442 // `Row::part` guard the one direction that has to be guarded, which is
443 // a run reaching up at a block; there is no upward violation a slot can
444 // commit, so `Slot::with` checks nothing.
445 let pane = Slot::new("facts", RegionKind::Pane)
446 .with(Node::section("Facts"))
447 .with(Node::text("Two files"))
448 .with(Node::Token(Tag::badge("beta")));
449
450 assert_eq!(pane.containment(), Containment::Blocks);
451 for node in &pane.body {
452 assert!(node.containment().level() <= pane.containment().level());
453 }
454 }
455
456 #[test]
457 fn a_bespoke_region_is_opaque_rather_than_an_exception() {
458 // Under the enumeration this was a hole in the rule. Here it answers
459 // the same question every other element answers, and the answer is
460 // "the app fills it".
461 let bespoke = Node::Region(Slot::bespoke("canvas", "editor"));
462 assert_eq!(bespoke.containment(), Containment::Opaque);
463 // Off the ladder, so it can neither be reached into nor reach out.
464 assert_eq!(bespoke.containment().level(), None);
465 }
466 }
467