Skip to main content

max / quasi

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