//! 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. //! //! 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 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, Bar, Cell, 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 /// 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::Handover`](crate::RegionKind::Handover) and /// [`RegionKind::Ceded`](crate::RegionKind::Ceded) always were. /// 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: an opaque 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. There was a variant per *container* here until /// the 2026-09-05 collapse -- `Rows` for a list and `TableRows` for a table -- /// on the reasoning that the tree had two kinds of row. It had one kind of row /// written twice, so the two variants named the same element type and the rule /// this enum states ("the element type is fixed by the container") was being /// read as a distinction it never made. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum Of { /// The rows of a list, a table or a timeline. [`Row`]. Rows, /// A row's cells. [`Cell`]. Cells, /// A form's questions. [`Field`]. Fields, /// A stats strip's figures. [`Figure`]. Figures, /// A control's options. [`Choice`]. Choices, /// A chart's magnitudes. [`Bar`]. Bars, } 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::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 | Self::Bars => 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::Table`] and [`Node::Form`] in neither, so both 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, // A readout derived from the current time. What it holds is an // instant, and an instant is no more content than an address is: // the words a reader sees are the renderer's, made at the moment it // draws. Self::Since { .. } | Self::Until { .. } | Self::Age { .. } => Containment::Text, // A picture holds nothing. Its alt text and caption are its own // fields rather than content it contains, the same way a figure's // caption is: they describe the leaf, and nothing can be nested // under them. A gallery is a widget assembled out of several of // these, which is a container's answer and not this one's. Self::Image(_) => Containment::Text, // An act is a label and an address. The address is not content. // A leaf when it sits in a line and a block when it owns one, which // is the whole reason `inline` is a flag on the member rather than // two members. `19d7602d`. Self::Code { inline, .. } => { if *inline { Containment::Text } else { Containment::Blocks } } 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), // Rows, the same as a table. What a timeline changes is *where* each // row goes, and a placement is no more content than a table's // columns are: it says how the collection is arranged, not what is // in it. So this is `Of::Rows` rather than a kind of its own, and a // walker that already handles a list needs nothing new. Self::Timeline { .. } => 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 -- the same rows a list holds, which is the // 2026-09-05 ruling stated in this enum. Self::Table { .. } => Containment::Collection(Of::Rows), Self::Meter(meter) => meter.containment(), // A strip of figures' answer, for its reason: what a chart holds is // a run of leaves that are not nodes, so a walker meets them as a // collection and not as content it can descend into. Self::Chart { .. } => Containment::Collection(Of::Bars), Self::Stats { .. } => Containment::Collection(Of::Figures), Self::Region(slot) => slot.containment(), // The second place `Opaque` is reached, and for its stated reason // rather than by analogy: a canvas holds markup this crate did not // write, so what is finally in it is not answerable from the // description. The nodes it also carries are the same arrangement a // opaque region has -- what the description owns, and then what it // does not -- which is why the answer is the same one. Self::Canvas(_) => Containment::Opaque, } } } impl Element for Slot { fn containment(&self) -> Containment { // The one place `Opaque` is reached, and the reason it exists. A // opaque 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::Handover { .. } | RegionKind::Ceded { .. } ) { Containment::Opaque } else { Containment::Blocks } } } impl Element for Row { fn containment(&self) -> Containment { Containment::Inlines } } impl Element for Cell { fn containment(&self) -> Containment { Containment::Inlines } } impl Element for Field { fn containment(&self) -> Containment { // A theme picker holds rows the same way, and they are text-only the // way a `Choice` is, so the bound this feeds is the same one. if self.kind.offers_options() || self.kind.offers_themes() { 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 Bar {} 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, Canvas}; /// 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::since(std::time::SystemTime::UNIX_EPOCH), Node::until(std::time::SystemTime::UNIX_EPOCH), Node::age(std::time::SystemTime::UNIX_EPOCH), Node::Notice { kind: layout::Notice::Banner, tone: layout::Tone::Neutral, text: "t".into(), act: None, }, Node::StandIn { marks: crate::stage::Marks::none(), state: layout::Readiness::Empty, message: "nothing yet".into(), act: None, }, Node::Field(Box::new(Field::new( layout::FieldKind::Text, "name", "Name", ))), Node::Form { marks: crate::stage::Marks::none(), action: Action::post("/"), submit: "Save".into(), fields: Vec::new(), }, Node::Table { marks: crate::stage::Marks::none(), columns: Vec::new(), rows: Vec::new(), more: None, }, Node::Meter(Meter::new(1, 2)), Node::Stats { marks: crate::stage::Marks::none(), 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::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 placed in pane.body.iter() { assert!(placed.node.containment().level() <= pane.containment().level()); } } #[test] fn a_canvas_is_opaque_for_the_reason_an_opaque_region_is() { // `48a6e9e5`. The markup is not the description's to know, and the // nodes it also carries are the same arrangement an opaque region has: // what the description owns, and then what it does not. let canvas = Node::Canvas(Box::new( Canvas::new("

x

").with(Node::text("platform")), )); assert_eq!(canvas.containment(), Containment::Opaque); } #[test] fn an_opaque_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 handover = Node::Region(Slot::handover("canvas", "editor")); assert_eq!(handover.containment(), Containment::Opaque); // Off the ladder, so it can neither be reached into nor reach out. assert_eq!(handover.containment().level(), None); } }