Skip to main content

makeover_layout/
column.rs

1// Names this module's prose links to, resolved for rustdoc.
2#[allow(unused_imports)]
3use crate::{Field, FieldKind, Fill, Region, RowPart};
4
5/// How much room a placement asks for.
6///
7/// A column says it, and so does a [`Field`]. An intent, so the actual floor
8/// stays with `makeover-geometry`. goingson's task table spells these as
9/// `minmax(200px, 1fr)`, `140px` and content-sized; only the first three words
10/// of that survive deferral.
11/// `#[non_exhaustive]`, for the reason [`Fill`] and [`FieldKind`] are: a
12/// renderer matches on this and a vocabulary that grows must not break every
13/// renderer when it does.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15#[non_exhaustive]
16pub enum Width {
17    /// Takes what it needs and no more.
18    Content,
19    /// A fixed share, the same at every width.
20    Fixed,
21    /// Absorbs whatever is left over.
22    ///
23    /// **Several fills divide what is left equally.** Stated because it would
24    /// otherwise be undefined and each renderer would invent something, and
25    /// stated this way because equal division is the only sharing rule that
26    /// answers to "Any width, one answer" without a tiebreak: allocating in
27    /// declaration order makes the result depend on the order the description
28    /// was written in, which is a fact about the source file and not about the
29    /// screen. It documents what both renderers already do — CSS grid gives
30    /// `1fr 1fr`, ratatui gives each a `Constraint::Fill(1)` — rather than
31    /// changing anything.
32    ///
33    /// So a row of fills is a legal thing to describe, and there is no rule
34    /// against it.
35    Fill,
36}
37
38/// What a member is worth when there is not room for all of them.
39///
40/// Written for table columns and no longer only theirs. Three shapes ask the
41/// same question and this answers all three: a table too narrow for its
42/// columns, a row too narrow for its parts (see [`RowPart::priority`]), and a
43/// group of regions sharing one run of room -- goingson's tab strip and the
44/// [`Region::Band`] beside it, which is the case wiki `layout-room-and-fallback`
45/// was ruled on. It is what any member of a group is worth, not a table
46/// concept, and [`Fallback::Shed`] is what reads it.
47///
48/// The doc below is the column argument, which is where the type was measured;
49/// the sentence that gave it away is [`Priority::Essential`]'s, which was
50/// already written about a row.
51///
52/// Ordered: [`Priority::Optional`] drops first, [`Priority::Essential`] never
53/// drops. This replaces addressing columns by position, which is what both
54/// webview apps do today and is a live bug rather than only verbosity. goingson
55/// hides mobile columns with `nth-child(n+5)` against a seven-column table, so
56/// inserting a column silently hides the wrong one.
57/// `#[non_exhaustive]`, same reasoning as [`Width`]. Note the ordering is the
58/// whole point of the type, so a new tier has to be declared in its place in
59/// the sequence rather than appended.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
61#[non_exhaustive]
62pub enum Priority {
63    /// Dropped first.
64    Optional,
65    /// Dropped once the optional members are gone.
66    Secondary,
67    /// Never dropped. Without it the group does not identify itself.
68    Essential,
69}
70
71/// What a group does when it runs out of room.
72///
73/// Authored, and required: the field carrying this has no `Default` and a group
74/// cannot be described without saying what it does when it runs out of room.
75/// Max ruled on that: more intentionality from layout designers is
76/// acceptable so long as the constraints are solvable, because the goal is
77/// enabling good layouts rather than rescuing bad ones. A default here would be
78/// the crate guessing, and the guess would be silently wrong on the screens
79/// that matter.
80///
81/// Relief resolves inside-out. A group asks its children to fall back before
82/// falling back itself, or an outer group collapses while an inner one still
83/// had slack.
84///
85/// # No `Swap`
86///
87/// An authored alternate group for the tight case is deliberately out of the
88/// first cut. It doubles the description for that group and the two halves can
89/// drift, which is the failure this vocabulary exists to end. Add it when a
90/// site proves it needs one.
91///
92/// `#[non_exhaustive]`, [`Width`]'s reasoning. Unlike [`Priority`] there is no
93/// order to preserve, so a member can be appended.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
95#[non_exhaustive]
96pub enum Fallback {
97    /// One row becomes two. Every member stays, in the order described.
98    Wrap,
99    /// A row becomes a column. Every member stays, full width.
100    Stack,
101    /// Members drop by [`Priority`], down to [`Priority::Essential`].
102    ///
103    /// What a narrow table already does with its columns, applied to a group.
104    /// What drops is gone from the screen, so this is right when the dropped
105    /// members are facts the reader can do without and wrong when they are the
106    /// only way to act.
107    Shed,
108    /// The members [`Shed`](Self::Shed) would drop move into one overflow
109    /// control instead.
110    ///
111    /// The answer when a group holds actions. A control is not a fact: dropping
112    /// it does not cost the reader a detail, it costs them the only way to act,
113    /// which is [`RowPart::priority`]'s argument one level up.
114    Menu,
115}
116
117/// The widest a column's [`floor`](Column::floor) may be, in `ch`.
118///
119/// A bound so a webview can name every floor as a class from a fixed ladder:
120/// a container condition cannot read a custom property, so the length a
121/// column hides its contents under has to be written into the stylesheet.
122/// Wider than any column the tree declares, and a column wanting more is a
123/// [`Width::Fill`] with this as its floor.
124pub const MIN_CEILING: u16 = 64;
125
126/// The floor an undeclared [`Width::Fill`] column takes, in `ch`.
127const FILL_FLOOR: u16 = 16;
128
129/// One column of a table.
130///
131/// Described once. The grid track, the cell order and the drop behaviour are
132/// all derived from this, rather than being three hand-written encodings that
133/// must agree and are never checked against each other.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub struct Column<'a> {
136    /// The heading, and the name the cell is addressed by.
137    pub name: &'a str,
138    /// How much room it asks for.
139    pub width: Width,
140    /// What it is worth when room runs out.
141    pub priority: Priority,
142    /// What the column holds, which is what carries its look.
143    pub kind: ColumnKind,
144    /// The narrowest the column may be before it drops, in `ch`.
145    ///
146    /// wiki `frontend-unification`: collapse points derive from declared
147    /// minimums only, so every host narrows at the same declared width rather
148    /// than at its own breakpoints. `ch` because every host has a direct
149    /// answer for it: a terminal cell is one, an immediate-mode painter
150    /// measures one off its face, and a webview draws one as nine sixteenths
151    /// of the base, a figure at a table's text size with room to spare. Not
152    /// the CSS `ch`
153    /// itself, because a container condition resolves font-relative units
154    /// against a different font than the cell's, and the column would hide at
155    /// one width and be sized at another.
156    ///
157    /// `None` derives it from the kind and the heading, which is what most
158    /// columns want. Read it through [`floor`](Self::floor), never directly:
159    /// that is where the derivation, the rounding and the ceiling live, and a
160    /// host reading this field would narrow somewhere the other two do not.
161    pub min: Option<u16>,
162    /// Whether the user can reorder the table by this column.
163    ///
164    /// What reordering *calls* is not here — that is an address, and this
165    /// crate names none — so a host pairs this with the route the way it pairs
166    /// a row's parts with the row's activation. This says the affordance
167    /// exists, which is what a renderer needs to draw a header a user can
168    /// press rather than a heading they cannot.
169    pub sortable: bool,
170    /// Which way the table is ordered by this column, if it is.
171    ///
172    /// `None` on every column but the one in force. A renderer draws the caret
173    /// from this and a webview sets `aria-sort`, which is why it is per column
174    /// rather than a single fact on the table: the host idiom is a property of
175    /// the header cell.
176    ///
177    /// Independent of [`sortable`](Self::sortable) rather than implied by it,
178    /// because both combinations mean something. A column sorted and not
179    /// sortable is a list ordered by a key the user cannot change, which is a
180    /// real thing to describe and a caret worth drawing.
181    pub sorted: Option<Sort>,
182}
183
184/// What a column holds.
185///
186/// wiki `table-model`: the kind is what carries a column's look, and four of
187/// the reference table's six rules were per kind rather than per table. A
188/// description states what the column is; each renderer decides what that
189/// looks like in its host, from the facts below rather than from the name.
190///
191/// `#[non_exhaustive]`, [`Width`]'s reasoning.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
193#[non_exhaustive]
194pub enum ColumnKind {
195    /// Prose. Wraps. What a column is when it says nothing else.
196    #[default]
197    Text,
198    /// A name a machine made: a fingerprint, a hash, a slug. Monospace, and
199    /// breaks mid-string rather than widening the table.
200    Identifier,
201    /// A point in time. Never wraps, so a date cannot print over its
202    /// neighbour, and its figures line up down the column.
203    Date,
204    /// A quantity. Figures line up and the column aligns to its end, so
205    /// magnitudes compare by length.
206    Number,
207    /// Source text, one row per line. Monospace and never wrapped. A table
208    /// holding one is read as code, so its rows are not striped or spaced as
209    /// records.
210    Code,
211    /// A state the row is in, drawn as a chip rather than as coloured text.
212    Status,
213    /// The row's controls. Aligns to its end and shrinks to what it holds.
214    Actions,
215}
216
217impl ColumnKind {
218    /// The narrowest a column of this kind reads at, in `ch`, when it declares
219    /// nothing narrower.
220    ///
221    /// A date is ten characters in the form every host writes, and a number
222    /// cut short is a different number, so the kinds that cannot truncate
223    /// floor at what they hold. Prose and identifiers wrap or break, so theirs
224    /// is only enough to be recognisable.
225    #[must_use]
226    pub const fn floor(self) -> u16 {
227        match self {
228            Self::Text | Self::Code | Self::Status | Self::Actions => 8,
229            Self::Identifier => 12,
230            Self::Date => 10,
231            Self::Number => 6,
232        }
233    }
234
235    /// Which kinds keep their floor rather than truncating when room runs out.
236    ///
237    /// **Deprecated in 0.50.1, and answered by nothing.** A floor is a floor:
238    /// [`Column::floor`] is documented as the narrowest a column may be *before
239    /// it drops*, which is what `makeover-tui` counts in cells and
240    /// `makeover-immediate` budgets in points. A kind-keyed exemption meant
241    /// `makeover-webview` alone drew three of the seven kinds under a floor it
242    /// had just computed for them, because nothing set their shrink to zero and
243    /// its blanking condition reaches only droppable columns. Measured at 420,
244    /// MNW's scan pipeline drew `yara`, `clamav`, `urlhaus` and `metadefender`
245    /// as `y`, `c`, `u`, `m` while the `Number` columns beside them stood at
246    /// full width.
247    ///
248    /// It was also the wrong axis. Shrink is distributed in proportion to
249    /// basis, and an undeclared basis comes from the heading's length, so
250    /// between two columns of one priority the one with the shorter heading
251    /// reached illegibility first. A column's minimum legible width is not a
252    /// fact about its label.
253    ///
254    /// What varies by kind is what happens *above* the floor, which
255    /// [`wraps`](Self::wraps) already says. What decides whether a column gives
256    /// up room at all is its [`Priority`](crate::Priority), which is the
257    /// description's own word for it. `makeover-webview` 0.94.0 keys the floor
258    /// on priority, and no renderer calls this.
259    ///
260    /// Kept only because deleting it is a breaking change to a crate carrying
261    /// `links`, so it would cascade the whole suite for a predicate with no
262    /// callers. Delete it at the next breaking bump.
263    #[must_use]
264    #[deprecated(
265        since = "0.50.1",
266        note = "a floor binds for every kind; priority decides whether a column gives up room"
267    )]
268    pub const fn holds_minimum(self) -> bool {
269        matches!(
270            self,
271            Self::Date | Self::Number | Self::Status | Self::Actions
272        )
273    }
274
275    /// Whether the column aligns to its end rather than its start.
276    #[must_use]
277    pub const fn aligns_end(self) -> bool {
278        matches!(self, Self::Number | Self::Actions)
279    }
280
281    /// Whether the column's text may wrap onto a second line.
282    ///
283    /// An identifier wraps by breaking mid-string, which is still a wrap: the
284    /// alternative is a fingerprint forcing the table wider than its pane.
285    #[must_use]
286    pub const fn wraps(self) -> bool {
287        matches!(self, Self::Text | Self::Identifier)
288    }
289
290    /// Whether the column is set in the monospace face.
291    #[must_use]
292    pub const fn monospace(self) -> bool {
293        matches!(self, Self::Identifier | Self::Code)
294    }
295
296    /// Whether figures in the column take equal widths.
297    #[must_use]
298    pub const fn tabular(self) -> bool {
299        matches!(self, Self::Date | Self::Number)
300    }
301}
302
303/// Which way a column is ordered.
304///
305/// Two, because there is no third. "Unsorted" is [`Column::sorted`] being
306/// `None`, and folding it in here would be the same absence said twice.
307#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
308pub enum Sort {
309    /// Smallest, earliest or first alphabetically at the top.
310    Ascending,
311    /// The other way.
312    Descending,
313}
314
315impl Sort {
316    /// The other direction, for a header that flips when pressed.
317    #[must_use]
318    pub const fn reversed(self) -> Self {
319        match self {
320            Self::Ascending => Self::Descending,
321            Self::Descending => Self::Ascending,
322        }
323    }
324
325    /// What a webview writes into `aria-sort`.
326    ///
327    /// Named here rather than in the webview renderer because a terminal and an
328    /// immediate-mode painter both want the same two words for a caret's label,
329    /// and three renderers picking their own is the drift this crate ends.
330    #[must_use]
331    pub const fn as_str(self) -> &'static str {
332        match self {
333            Self::Ascending => "ascending",
334            Self::Descending => "descending",
335        }
336    }
337
338    /// The caret a renderer draws for this direction.
339    ///
340    /// Here for [`as_str`](Self::as_str)'s reason, said about a glyph rather
341    /// than a word: three renderers picking their own is the drift this crate
342    /// ends. They had picked their own — two on the solid triangles and
343    /// `makeover-webview` on the arrows U+2191/U+2193 — and agreeing by
344    /// coincidence in three files is not agreement.
345    ///
346    /// The reason generalizes past this pair and is the house rule now —
347    /// prefer the bolder, simpler glyph over the thinner or more complicated
348    /// one. A third spelling is not open for re-argument.
349    ///
350    /// **Bare, with no spacing.** Where the gap goes is each renderer's
351    /// business: `makeover-tui` and `makeover-immediate` carry a leading space
352    /// inside their `TableStyle` string and a webview emits its own in
353    /// `content`, so folding a space in here would make one of the two wrong.
354    ///
355    /// Neither face the web apps self-host carries these — IBM Plex Mono has one
356    /// glyph in the whole geometric-shapes block and Lato has none — so a
357    /// browser falls back per glyph until the in-house face ships with them
358    /// drawn in (wiki `typography-standard`). Cosmetic
359    /// drift in one renderer, not a reason to spell it three ways.
360    #[must_use]
361    pub const fn glyph(self) -> &'static str {
362        match self {
363            Self::Ascending => "\u{25B2}",
364            Self::Descending => "\u{25BC}",
365        }
366    }
367}
368
369impl<'a> Column<'a> {
370    /// A column that absorbs slack and never drops.
371    ///
372    /// [`Priority::Essential`], and the default is the whole of the argument.
373    /// A table that states no priorities used to empty itself wherever a
374    /// renderer narrows -- head and cells together, every column having
375    /// inherited a droppable rank -- which is what goingson's task table did.
376    /// Undeclared now means kept, so a column goes only when someone demotes
377    /// it, and the remaining failure is a cramped table rather than a blank
378    /// one. That is the same judgment [`kept_at`](Self::kept_at)'s renderers
379    /// make about a rank they have not learned: of the two ways to be wrong,
380    /// showing a column that should have gone is the one a reader can see and
381    /// work around.
382    #[must_use]
383    pub const fn new(name: &'a str) -> Self {
384        Self {
385            name,
386            width: Width::Fill,
387            priority: Priority::Essential,
388            kind: ColumnKind::Text,
389            min: None,
390            sortable: false,
391            sorted: None,
392        }
393    }
394
395    /// The same column, declaring the narrowest it may be before it drops.
396    #[must_use]
397    pub const fn min(mut self, ch: u16) -> Self {
398        self.min = Some(ch);
399        self
400    }
401
402    /// The narrowest this column may be before it drops, in `ch`.
403    ///
404    /// What every host narrows by: a column is kept at a cutoff only while the
405    /// floors of every column kept there fit. The declared [`min`](Self::min),
406    /// or else the widest of the kind's [`floor`](ColumnKind::floor), the
407    /// heading with two for its capitals and two more for a caret, and sixteen
408    /// for a [`Width::Fill`] column. Rounded up to an even count and held under
409    /// [`MIN_CEILING`], so a webview can carry the whole range as a fixed ladder
410    /// of classes and the other two hosts round the same way.
411    #[must_use]
412    pub const fn floor(&self) -> u16 {
413        let raw = match self.min {
414            Some(ch) => ch,
415            None => {
416                let caret = if self.sortable || self.sorted.is_some() {
417                    2
418                } else {
419                    0
420                };
421                // Bytes, not characters: const, and a heading is a short word
422                // in every table in the tree. A multi-byte heading reads a
423                // little wide, which errs toward keeping it legible. Two more
424                // for the heading's capitals and tracking, which run wider than
425                // the figures a `ch` is measured off: `AMOUNT` at six ch is cut.
426                let heading = if self.name.len() > MIN_CEILING as usize {
427                    MIN_CEILING
428                } else {
429                    self.name.len() as u16 + 2 + caret
430                };
431                let kind = self.kind.floor();
432                // A column that takes the slack is the one holding prose, and
433                // floored at its kind it wraps a title onto three lines while
434                // the columns worth less still stand at full width.
435                let fill = if matches!(self.width, Width::Fill) {
436                    FILL_FLOOR
437                } else {
438                    0
439                };
440                let wider = if heading > kind { heading } else { kind };
441                if fill > wider { fill } else { wider }
442            }
443        };
444        let even = raw + raw % 2;
445        if even < 2 {
446            2
447        } else if even > MIN_CEILING {
448            MIN_CEILING
449        } else {
450            even
451        }
452    }
453
454    /// Whether this column survives at the given cutoff.
455    ///
456    /// A renderer narrows by raising the cutoff, and never by counting
457    /// positions.
458    #[must_use]
459    pub const fn kept_at(&self, cutoff: Priority) -> bool {
460        (self.priority as u8) >= (cutoff as u8)
461    }
462}
463
464/// What a table cell holds.
465///
466/// [`RowPart`] for tables, and it exists for the same reason: a part that
467/// carries a control is not text, and a renderer with one class for the whole
468/// cell paints it as though it were: a button in a cell inherits the cell's
469/// content colour, which is the drift [`RowPart::intent`] prevents for rows.
470///
471/// Four members, and the count is what quasi's `Cell` was measured to carry: a
472/// value, tokens, actions and a link. Nothing was added past what something
473/// holds.
474///
475/// `#[non_exhaustive]` for [`RowPart`]'s reason: growth here must not be a
476/// lockstep event across three renderers.
477///
478/// # No hover-reveal
479///
480/// This enum never gets one. A cell's actions are shown at rest in every
481/// consumer measured, and a member nothing uses is one three renderers owe an
482/// answer for.
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
484#[non_exhaustive]
485pub enum CellPart {
486    /// The cell's own text.
487    Value,
488    /// Small labelled things in the cell: a status badge, a chip.
489    Tokens,
490    /// Controls that act on what the row is about.
491    Actions,
492    /// The cell's value, where the value is itself a link.
493    Link,
494}
495
496impl CellPart {
497    /// The content intent the part takes.
498    ///
499    /// One part is text and three are not, so three answer with the intent
500    /// inheriting already gives. That is [`RowPart::intent`]'s shape with the
501    /// text side narrower: a cell's secondary and muted readings are the
502    /// column's business, not the cell's.
503    #[must_use]
504    pub const fn intent(self) -> &'static str {
505        match self {
506            Self::Value => "content",
507            // A token carries its own tone, and a part-level intent underneath
508            // it would fight the token sitting on it.
509            Self::Tokens => "content",
510            // Actions carry controls rather than text.
511            Self::Actions => "content",
512            // A link in a cell is its row's title, so it reads in the ink the
513            // values beside it do.
514            Self::Link => "content",
515        }
516    }
517}