//! What each element of a description may hold. //! //! //! //! # Why this exists //! //! Before this, composability was a per-pairing enumeration: every //! primitive-inside-container pairing was a member, three renderer arms and a //! release. `RowPart::Tokens` put a tag in a row, `Cell::tokens` put the same //! tag in a cell, `RowPart::Proportion` put a meter in a row, and a meter in a //! cell was simply not sayable. Three of the seven pairings in that matrix were //! filled in two days, which is the measurement that decided this: the rate was //! rising, not falling. //! //! The rule the enumeration was defending -- "a row holds no nodes, the door //! through which a description becomes a templating language" -- was a rider on //! a different 2026-08-08 decision (the one that shipped `RowPart::Tokens`), //! promoted to a constitution by later sessions and cited six times in //! `screen.rs` as standing law. It also did not do what it claimed: //! //! 1. It bounded one seam rather than the tree. `Node::Region(Slot)` holding //! `Vec` holding `Node::Region(Slot)` is unbounded nesting and has //! always been accepted. //! 2. It was enforced by the absence of a type rather than by a type, so //! nothing stopped [`Cell`](crate::Cell) growing a member per release until //! it was a node under another name -- which it was already doing, going //! from a `String` to four fields in one release. //! 3. The thing worth bounding is what a constrained renderer must be able to //! draw, not how deep the tree goes. The two got conflated. //! //! So the bound moves from a doc comment to a property every element declares //! and one test checks. The 2026-08-08 rider was never checkable; this is. //! //! # The bound //! //! Three rules, all asserted by `the_containment_ladder_only_goes_down`: //! //! - **An inline run may not reach blocks or collections.** Depth below a line //! is one, so a run is always drawable on one wrapped line. That is the //! constrained-consumer test: a terminal can draw any run in a cell without //! knowing what is in it. //! - **A collection's element type is fixed and non-recursive.** A [`Cell`] is //! never a [`Row`]. The container names what it holds, once. //! - **Blocks may contain blocks.** Already true, already accepted, and a //! nested region is a nested rect on every host. //! //! # What this is not //! //! Not a type-erased tree. Every element stays a closed enum, so a renderer //! still matches exhaustively to pick a drawing and the compiler still says //! when a member is added. A `Vec>` would take that away and //! cost `Clone` and `PartialEq` besides. [`Element`] is a query surface over a //! closed set, which is what `makeover_layout::Intent` already is. use crate::screen::{ Act, Cell, Cells, Choice, Column, Field, Figure, Meter, Node, Prose, RegionKind, Row, Slot, Tag, }; /// What an element may hold. /// /// The default is [`Text`](Self::Text), because most of the vocabulary is a /// label. [`Heading`](Node::Heading), [`Text`](Node::Text), [`Token`](Node::Token) /// and the rest declare nothing and get it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Containment { /// Prose, and only prose. /// /// [`Prose`] rather than `String`, so the markdown answer that /// `df246b95` settled for a row part comes along free everywhere else: a /// leaf says which of the two kinds of string it is, and a renderer that /// can draw markdown does. Text, /// A run of things on a line, holding no blocks. Inlines, /// Other blocks, regions included. Blocks, /// A homogeneous sequence whose element type the container fixes. Collection(Of), /// The app fills it, so what is finally there is not the description's to /// know. /// /// What [`RegionKind::Bespoke`](crate::RegionKind::Bespoke) always was. /// Under the enumeration it was an exception to a rule; here it is an /// answer to the same question every other element answers. /// /// Not the same as holding nothing, which is what this said until `Slot` /// was migrated and the renderer was read against it: a bespoke region /// draws the blocks the description put in it and *then* the host's fill, /// which is a deliberate arrangement -- a heading the description owns /// above a canvas it does not. So the claim is about knowledge rather than /// about emptiness. A renderer cannot answer "what is in this region" from /// the description alone, which is the property every consumer of this /// actually needs, and it is why the answer is off the ladder rather than /// on top of it. Opaque, } /// The element type a [`Containment::Collection`] holds. /// /// One variant per element type rather than the five the review sketched, /// because the tree has two kinds of row: a list holds [`Row`], and a table /// holds [`Cells`], which is a row of [`Cell`]. Folding those into one variant /// would make "the element type is fixed by the container" false on the first /// container anyone checked. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Of { /// A list's rows. [`Row`]. Rows, /// A table's rows. [`Cells`]. TableRows, /// A table row's cells. [`Cell`]. Cells, /// A form's questions. [`Field`]. Fields, /// A stats strip's figures. [`Figure`]. Figures, /// A control's options. [`Choice`]. Choices, } impl Of { /// What one element of this collection may itself hold. /// /// The recursion check reads this rather than reaching for the element's /// own [`Element`] impl, because an `Of` names a type and not a value and /// there is nothing to call the trait on. #[must_use] pub fn element_containment(self) -> Containment { match self { // A row is a run: parts on a line, none of them a block. Self::Rows | Self::TableRows | Self::Cells => Containment::Inlines, // A field holds its options when it has any, and text otherwise. // The looser of the two is what the bound has to hold against. Self::Fields => Containment::Collection(Of::Choices), Self::Figures | Self::Choices => Containment::Text, } } } /// How far down the containment ladder a rung sits. /// /// [`Opaque`](Containment::Opaque) is off the ladder rather than on top of it: /// the description stops there, so there is nothing below it to bound and /// nothing it can reach. Giving it a rank would make it either a block that /// must not appear in a run, which is right by accident, or a leaf, which is /// wrong. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Level { /// [`Containment::Text`]. Leaf, /// [`Containment::Inlines`]. Run, /// [`Containment::Blocks`] and [`Containment::Collection`]. Block, } impl Containment { /// Where this sits on the ladder, if it is on it. #[must_use] pub fn level(self) -> Option { match self { Self::Text => Some(Level::Leaf), Self::Inlines => Some(Level::Run), Self::Blocks | Self::Collection(_) => Some(Level::Block), Self::Opaque => None, } } } /// What an element of a description may hold. /// /// Implemented for every element rather than for a bucket of them, which is the /// difference between this and the two-tier shape the review also costed. Two /// tiers leave [`Node::List`], [`Node::Table`] and [`Node::Form`] in neither, /// so all three become special-cased members with hardcoded children and the /// enumeration problem moves up a level. [`Containment::Collection`] states /// outright what that shape has to special-case. pub trait Element { /// What this may hold. Most of the vocabulary is a label and takes the /// default. fn containment(&self) -> Containment { Containment::Text } } impl Element for Node { fn containment(&self) -> Containment { match self { // Labels. The default, spelled out here only because a match has to // be exhaustive. Self::Heading { .. } | Self::Text { .. } | Self::Rich { .. } | Self::Token(_) => { Containment::Text } // Text that goes somewhere. The address is not content, the same // way an act's is not. Self::Link { .. } => Containment::Text, // One figure on a line. The strip is the collection; this is not. Self::Figure(_) => Containment::Text, // An act is a label and an address. The address is not content. Self::Act(_) => Containment::Text, // A notice is a sentence with a tone. It was never allowed to hold // anything and this does not change that. Self::Notice { .. } => Containment::Text, // A sentence and, sometimes, the way out. Two things on a line. Self::StandIn { .. } => Containment::Inlines, // One field, so this is the field's own answer rather than a // collection of one. Self::Field(field) => field.containment(), Self::Form { .. } => Containment::Collection(Of::Fields), Self::List { .. } => Containment::Collection(Of::Rows), // The columns are the table's schema and not its content, the way // `Screen::discovery` is metadata rather than a region. What a // table *holds* is rows. Self::Table { .. } => Containment::Collection(Of::TableRows), Self::Select { .. } => Containment::Collection(Of::Choices), Self::Meter(meter) => meter.containment(), Self::Stats { .. } => Containment::Collection(Of::Figures), Self::Region(slot) => slot.containment(), } } } impl Element for Slot { fn containment(&self) -> Containment { // The one place `Opaque` is reached, and the reason it exists. A // bespoke region is filled by the host, so what is finally in it is not // answerable from the description -- not that the description put // nothing there, which is a different claim and the wrong one. Every // other region holds blocks, regions included, which is the nesting // that was always accepted and that the row rule never touched. if matches!(self.kind, RegionKind::Bespoke { .. }) { Containment::Opaque } else { Containment::Blocks } } } impl Element for Row { fn containment(&self) -> Containment { Containment::Inlines } } impl Element for Cells { fn containment(&self) -> Containment { Containment::Collection(Of::Cells) } } impl Element for Cell { fn containment(&self) -> Containment { Containment::Inlines } } impl Element for Field { fn containment(&self) -> Containment { if self.kind.offers_options() { Containment::Collection(Of::Choices) } else { Containment::Text } } } // The leaves. Each takes the default, and each says so by name rather than by // silence, because "nobody wrote an impl" and "this holds a label" look the // same from outside and only one of them is a decision. impl Element for Act {} impl Element for Tag {} impl Element for Figure {} impl Element for Meter {} impl Element for Choice {} impl Element for Column {} impl Element for Prose {} #[cfg(test)] mod tests { use super::*; use crate::layout; use crate::screen::Action; /// Every element type, as a value, so the ladder test can ask each one. /// /// A list rather than a derive because the question is about the vocabulary /// and not about any one screen: adding a `Node` member and not adding it /// here is caught by the exhaustive match in [`Element for Node`], which is /// where a new member has to be answered for anyway. fn every_node() -> Vec { vec![ Node::page("t"), Node::text("t"), Node::rich("*t*"), Node::Act(Act::new("go", Action::get("/"))), Node::Token(Tag::badge("tag")), Node::Notice { kind: layout::Notice::Banner, tone: layout::Tone::Neutral, text: "t".into(), }, Node::StandIn { state: layout::Readiness::Empty, message: "nothing yet".into(), act: None, }, Node::Field(Box::new(Field::new( layout::FieldKind::Text, "name", "Name", ))), Node::Form { action: Action::post("/"), submit: "Save".into(), fields: Vec::new(), }, Node::List { rows: Vec::new(), more: None, }, Node::Table { columns: Vec::new(), rows: Vec::new(), }, Node::Select { kind: layout::Selector::Tabs, options: Vec::new(), chosen: None, action: None, }, Node::Meter(Meter::new(1, 2)), Node::Stats { figures: Vec::new(), }, Node::Region(Slot::new("r", RegionKind::Pane)), ] } #[test] fn the_containment_ladder_only_goes_down() { // Rule 1. An inline run may not reach blocks or collections, so depth // below a line is one and a run is always drawable on one wrapped line. // This is the constrained-consumer test: a terminal draws a cell // without knowing what is in it. for node in every_node() { if node.containment() != Containment::Text { continue; } // A node that may sit in a run is a leaf, by construction. Nothing // to assert beyond the classification itself being total. assert_eq!(node.containment().level(), Some(Level::Leaf)); } // Rule 2. A collection's element type is fixed and its element sits // strictly below a collection: never another collection, never blocks. for of in [ Of::Rows, Of::TableRows, Of::Cells, Of::Fields, Of::Figures, Of::Choices, ] { let inner = of.element_containment(); // Fields are the one collection whose element is itself a // collection, and it is the terminating one: a choice is a label. if let Containment::Collection(nested) = inner { assert_eq!( nested.element_containment(), Containment::Text, "{of:?} nests {nested:?}, which must terminate in text" ); assert_ne!(nested, of, "{of:?} holds itself"); continue; } assert!( matches!(inner, Containment::Text | Containment::Inlines), "{of:?} holds {inner:?}, which is not below a collection" ); } // Rule 3. Blocks may contain blocks, and a region is the one that does. let region = Node::Region(Slot::new("r", RegionKind::Pane)); assert_eq!(region.containment(), Containment::Blocks); } #[test] fn a_run_holds_no_blocks() { // The rule stated the other way round, over the members that are runs. // A row and a cell hold parts on a line; neither may hold a list. Both // are runs in fact and not only by declaration since `Row` moved: // `Row::part` and `Cell::part` assert this same answer at a call site. for run in [Row::new("r").containment(), Cell::new("c").containment()] { assert_eq!(run, Containment::Inlines); assert_eq!(run.level(), Some(Level::Run)); assert!(run.level() < Containment::Blocks.level()); } } #[test] fn most_of_the_vocabulary_declares_nothing_and_is_text() { // The default is what makes this cheap: a leaf writes no impl body. assert_eq!(Tag::badge("t").containment(), Containment::Text); assert_eq!( Act::new("go", Action::get("/")).containment(), Containment::Text ); assert_eq!(Figure::new("7", "tasks").containment(), Containment::Text); assert_eq!(Meter::new(1, 2).containment(), Containment::Text); } #[test] fn a_field_holds_its_options_only_when_it_has_any() { // The one element whose containment depends on its own state, which is // why the trait takes `&self` rather than being an associated const. let plain = Field::new(layout::FieldKind::Text, "name", "Name"); assert_eq!(plain.containment(), Containment::Text); let choosing = Field::select("priority", "Priority", vec![Choice::plain("high")]); assert_eq!(choosing.containment(), Containment::Collection(Of::Choices)); assert!(choosing.kind.offers_options()); } #[test] fn a_region_holds_leaves_as_well_as_blocks() { // Going down the ladder is what containment is for, so a block holding // a leaf needs no permission and gets no assertion. `Cell::part` and // `Row::part` guard the one direction that has to be guarded, which is // a run reaching up at a block; there is no upward violation a slot can // commit, so `Slot::with` checks nothing. let pane = Slot::new("facts", RegionKind::Pane) .with(Node::section("Facts")) .with(Node::text("Two files")) .with(Node::Token(Tag::badge("beta"))); assert_eq!(pane.containment(), Containment::Blocks); for node in &pane.body { assert!(node.containment().level() <= pane.containment().level()); } } #[test] fn a_bespoke_region_is_opaque_rather_than_an_exception() { // Under the enumeration this was a hole in the rule. Here it answers // the same question every other element answers, and the answer is // "the app fills it". let bespoke = Node::Region(Slot::bespoke("canvas", "editor")); assert_eq!(bespoke.containment(), Containment::Opaque); // Off the ladder, so it can neither be reached into nor reach out. assert_eq!(bespoke.containment().level(), None); } }