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
71impl Priority {
72    /// Every cutoff, weakest first: the order a narrowing renderer raises
73    /// through.
74    ///
75    /// The one list of tiers. A renderer walks this rather than writing its
76    /// own, so a tier added here is one every renderer narrows to.
77    pub const RAISING: &'static [Self] = &[Self::Optional, Self::Secondary, Self::Essential];
78}
79
80/// What the columns kept at a cutoff ask for.
81///
82/// Counted in `ch` and in columns, and never in a host's unit: each renderer
83/// turns it into its own with [`Budget::width`], which is the one gap model
84/// every native renderer budgets by.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
86#[non_exhaustive]
87pub struct Budget {
88    /// How many columns are kept.
89    pub columns: u32,
90    /// Their [`floor`](Column::floor)s, summed, in `ch`.
91    pub floors: u32,
92}
93
94impl Budget {
95    /// The width these columns need, at `ch` per `ch` and `gap` between
96    /// adjacent columns and at each edge.
97    ///
98    /// `floors * ch + gap * (columns + 1)`. A terminal passes one cell per `ch`
99    /// and its column spacing; an immediate-mode painter passes the width of a
100    /// figure and its cell padding on both sides. One formula, so a table
101    /// drops a column at the same declared width in both.
102    #[must_use]
103    pub fn width(self, ch: f32, gap: f32) -> f32 {
104        self.floors as f32 * ch + gap * (self.columns + 1) as f32
105    }
106}
107
108/// What the columns kept at `cutoff` ask for.
109#[must_use]
110pub fn budget(columns: &[Column<'_>], cutoff: Priority) -> Budget {
111    columns.iter().filter(|column| column.kept_at(cutoff)).fold(
112        Budget::default(),
113        |budget, column| Budget {
114            columns: budget.columns + 1,
115            floors: budget.floors + u32::from(column.floor()),
116        },
117    )
118}
119
120/// The weakest cutoff whose kept columns `fits` accepts.
121///
122/// Raised through [`Priority::RAISING`] until the columns fit, and never past
123/// [`Priority::Essential`]: the essential columns are what makes a row identify
124/// itself, so a window too narrow for them gets them squeezed rather than
125/// dropped. Nothing here counts positions, so which column drops is a property
126/// of the column.
127#[must_use]
128pub fn cutoff_for(columns: &[Column<'_>], fits: impl Fn(Budget) -> bool) -> Priority {
129    Priority::RAISING
130        .iter()
131        .copied()
132        .find(|&cutoff| fits(budget(columns, cutoff)))
133        .unwrap_or(Priority::Essential)
134}
135
136/// The lengths a description deferred, in a renderer's own unit.
137///
138/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude,
139/// because a magnitude is an answer for one renderer. Each renderer holds this
140/// over its own unit (cells, points, CSS lengths) and looks a column up by
141/// name, because an app's columns are not all one size.
142#[derive(Debug, Clone, Copy, PartialEq, Default)]
143#[non_exhaustive]
144pub struct Sizing<'a, L> {
145    /// `(column name, length)`: the track for a [`Width::Fixed`] column and the
146    /// floor for a [`Width::Fill`] one.
147    pub lengths: &'a [(&'a str, L)],
148    /// Used for a column with no entry above.
149    pub fallback: L,
150}
151
152impl<'a, L: Copy> Sizing<'a, L> {
153    /// These lengths, and `fallback` for a column not among them.
154    #[must_use]
155    pub const fn new(lengths: &'a [(&'a str, L)], fallback: L) -> Self {
156        Self { lengths, fallback }
157    }
158
159    /// The length for a named column.
160    #[must_use]
161    pub fn length_for(&self, name: &str) -> L {
162        self.lengths
163            .iter()
164            .find(|(column, _)| *column == name)
165            .map_or(self.fallback, |(_, length)| *length)
166    }
167}
168
169/// What a group does when it runs out of room.
170///
171/// Authored, and required: the field carrying this has no `Default` and a group
172/// cannot be described without saying what it does when it runs out of room.
173/// More intentionality from layout designers is acceptable so long as the constraints are solvable, because the goal is
174/// enabling good layouts rather than rescuing bad ones. A default here would be
175/// the crate guessing, and the guess would be silently wrong on the screens
176/// that matter.
177///
178/// Relief resolves inside-out. A group asks its children to fall back before
179/// falling back itself, or an outer group collapses while an inner one still
180/// had slack.
181///
182/// # No `Swap`
183///
184/// An authored alternate group for the tight case is deliberately out of the
185/// first cut. It doubles the description for that group and the two halves can
186/// drift, which is the failure this vocabulary exists to end. Add it when a
187/// site proves it needs one.
188///
189/// `#[non_exhaustive]`, [`Width`]'s reasoning. Unlike [`Priority`] there is no
190/// order to preserve, so a member can be appended.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
192#[non_exhaustive]
193pub enum Fallback {
194    /// One row becomes two. Every member stays, in the order described.
195    Wrap,
196    /// A row becomes a column. Every member stays, full width.
197    Stack,
198    /// Members drop by [`Priority`], down to [`Priority::Essential`].
199    ///
200    /// What a narrow table already does with its columns, applied to a group.
201    /// What drops is gone from the screen, so this is right when the dropped
202    /// members are facts the reader can do without and wrong when they are the
203    /// only way to act.
204    Shed,
205    /// The members [`Shed`](Self::Shed) would drop move into one overflow
206    /// control instead.
207    ///
208    /// The answer when a group holds actions. A control is not a fact: dropping
209    /// it does not cost the reader a detail, it costs them the only way to act,
210    /// which is [`RowPart::priority`]'s argument one level up.
211    Menu,
212}
213
214/// The widest a column's [`floor`](Column::floor) may be, in `ch`.
215///
216/// A bound so a webview can name every floor as a class from a fixed ladder:
217/// a container condition cannot read a custom property, so the length a
218/// column hides its contents under has to be written into the stylesheet.
219/// Wider than any column the tree declares, and a column wanting more is a
220/// [`Width::Fill`] with this as its floor.
221pub const MIN_CEILING: u16 = 64;
222
223/// The floor an undeclared [`Width::Fill`] column takes, in `ch`.
224const FILL_FLOOR: u16 = 16;
225
226/// One column of a table.
227///
228/// Described once. The grid track, the cell order and the drop behaviour are
229/// all derived from this, rather than being three hand-written encodings that
230/// must agree and are never checked against each other.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
232#[non_exhaustive]
233pub struct Column<'a> {
234    /// The heading, and the name the cell is addressed by.
235    pub name: &'a str,
236    /// How much room it asks for.
237    pub width: Width,
238    /// What it is worth when room runs out.
239    pub priority: Priority,
240    /// What the column holds, which is what carries its look.
241    pub kind: ColumnKind,
242    /// The narrowest the column may be before it drops, in `ch`.
243    ///
244    /// wiki `frontend-unification`: collapse points derive from declared
245    /// minimums only, so every host narrows at the same declared width rather
246    /// than at its own breakpoints. `ch` because every host has a direct
247    /// answer for it: a terminal cell is one, an immediate-mode painter
248    /// measures one off its face, and a webview draws one as nine sixteenths
249    /// of the base, a figure at a table's text size with room to spare. Not
250    /// the CSS `ch`
251    /// itself, because a container condition resolves font-relative units
252    /// against a different font than the cell's, and the column would hide at
253    /// one width and be sized at another.
254    ///
255    /// `None` derives it from the kind and the heading, which is what most
256    /// columns want. Read it through [`floor`](Self::floor), never directly:
257    /// that is where the derivation, the rounding and the ceiling live, and a
258    /// host reading this field would narrow somewhere the other two do not.
259    pub min: Option<u16>,
260    /// Whether the user can reorder the table by this column.
261    ///
262    /// What reordering *calls* is not here (that is an address, and this
263    /// crate names none) so a host pairs this with the route the way it pairs
264    /// a row's parts with the row's activation. This says the affordance
265    /// exists, which is what a renderer needs to draw a header a user can
266    /// press rather than a heading they cannot.
267    pub sortable: bool,
268    /// Which way the table is ordered by this column, if it is.
269    ///
270    /// `None` on every column but the one in force. A renderer draws the caret
271    /// from this and a webview sets `aria-sort`, which is why it is per column
272    /// rather than a single fact on the table: the host idiom is a property of
273    /// the header cell.
274    ///
275    /// Independent of [`sortable`](Self::sortable) rather than implied by it,
276    /// because both combinations mean something. A column sorted and not
277    /// sortable is a list ordered by a key the user cannot change, which is a
278    /// real thing to describe and a caret worth drawing.
279    pub sorted: Option<Sort>,
280}
281
282/// What a column holds.
283///
284/// wiki `table-model`: the kind is what carries a column's look, and four of
285/// the reference table's six rules were per kind rather than per table. A
286/// description states what the column is; each renderer decides what that
287/// looks like in its host, from the facts below rather than from the name.
288///
289/// `#[non_exhaustive]`, [`Width`]'s reasoning.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
291#[non_exhaustive]
292pub enum ColumnKind {
293    /// Prose. Wraps. What a column is when it says nothing else.
294    #[default]
295    Text,
296    /// A name a machine made: a fingerprint, a hash, a slug. Monospace, and
297    /// breaks mid-string rather than widening the table.
298    Identifier,
299    /// A point in time. Never wraps, so a date cannot print over its
300    /// neighbour, and its figures line up down the column.
301    Date,
302    /// A quantity. Figures line up and the column aligns to its end, so
303    /// magnitudes compare by length.
304    Number,
305    /// Source text, one row per line. Monospace and never wrapped. A table
306    /// holding one is read as code, so its rows are not striped or spaced as
307    /// records.
308    Code,
309    /// A state the row is in, drawn as a chip rather than as coloured text.
310    Status,
311    /// The row's controls. Aligns to its end and shrinks to what it holds.
312    Actions,
313}
314
315impl ColumnKind {
316    /// The narrowest a column of this kind reads at, in `ch`, when it declares
317    /// nothing narrower.
318    ///
319    /// A date is ten characters in the form every host writes, and a number
320    /// cut short is a different number, so the kinds that cannot truncate
321    /// floor at what they hold. Prose and identifiers wrap or break, so theirs
322    /// is only enough to be recognisable.
323    #[must_use]
324    pub const fn floor(self) -> u16 {
325        match self {
326            Self::Text | Self::Code | Self::Status | Self::Actions => 8,
327            Self::Identifier => 12,
328            Self::Date => 10,
329            Self::Number => 6,
330        }
331    }
332
333    /// Whether the column aligns to its end rather than its start.
334    #[must_use]
335    pub const fn aligns_end(self) -> bool {
336        matches!(self, Self::Number | Self::Actions)
337    }
338
339    /// Whether the column's text may wrap onto a second line.
340    ///
341    /// An identifier wraps by breaking mid-string, which is still a wrap: the
342    /// alternative is a fingerprint forcing the table wider than its pane.
343    #[must_use]
344    pub const fn wraps(self) -> bool {
345        matches!(self, Self::Text | Self::Identifier)
346    }
347
348    /// Whether the column is set in the monospace face.
349    #[must_use]
350    pub const fn monospace(self) -> bool {
351        matches!(self, Self::Identifier | Self::Code)
352    }
353
354    /// Whether figures in the column take equal widths.
355    #[must_use]
356    pub const fn tabular(self) -> bool {
357        matches!(self, Self::Date | Self::Number)
358    }
359}
360
361/// Which way a column is ordered.
362///
363/// Two, because there is no third. "Unsorted" is [`Column::sorted`] being
364/// `None`, and folding it in here would be the same absence said twice.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
366#[non_exhaustive]
367pub enum Sort {
368    /// Smallest, earliest or first alphabetically at the top.
369    Ascending,
370    /// The other way.
371    Descending,
372}
373
374impl Sort {
375    /// The other direction, for a header that flips when pressed.
376    #[must_use]
377    pub const fn reversed(self) -> Self {
378        match self {
379            Self::Ascending => Self::Descending,
380            Self::Descending => Self::Ascending,
381        }
382    }
383
384    /// What a webview writes into `aria-sort`.
385    ///
386    /// Named here rather than in the webview renderer because a terminal and an
387    /// immediate-mode painter both want the same two words for a caret's label,
388    /// and three renderers picking their own is the drift this crate ends.
389    #[must_use]
390    pub const fn as_str(self) -> &'static str {
391        match self {
392            Self::Ascending => "ascending",
393            Self::Descending => "descending",
394        }
395    }
396
397    /// The caret a renderer draws for this direction.
398    ///
399    /// Here for [`as_str`](Self::as_str)'s reason, said about a glyph rather
400    /// than a word: three renderers picking their own is the drift this crate
401    /// ends. They had picked their own (two on the solid triangles and
402    /// `makeover-webview` on the arrows U+2191/U+2193) and agreeing by
403    /// coincidence in three files is not agreement.
404    ///
405    /// The reason generalizes past this pair and is the house rule now,
406    /// prefer the bolder, simpler glyph over the thinner or more complicated
407    /// one. A third spelling is not open for re-argument.
408    ///
409    /// **Bare, with no spacing.** Where the gap goes is each renderer's
410    /// business: `makeover-tui` and `makeover-immediate` carry a leading space
411    /// inside their `TableStyle` string and a webview emits its own in
412    /// `content`, so folding a space in here would make one of the two wrong.
413    ///
414    /// Neither face the web apps self-host carries these (IBM Plex Mono has one
415    /// glyph in the whole geometric-shapes block and Lato has none) so a
416    /// browser falls back per glyph until the in-house face ships with them
417    /// drawn in (wiki `typography-standard`). Cosmetic
418    /// drift in one renderer, not a reason to spell it three ways.
419    #[must_use]
420    pub const fn glyph(self) -> &'static str {
421        match self {
422            Self::Ascending => "\u{25B2}",
423            Self::Descending => "\u{25BC}",
424        }
425    }
426}
427
428impl<'a> Column<'a> {
429    /// A column that absorbs slack and never drops.
430    ///
431    /// [`Priority::Essential`], and the default is the whole of the argument.
432    /// A table that states no priorities used to empty itself wherever a
433    /// renderer narrows (head and cells together, every column having
434    /// inherited a droppable rank) which is what goingson's task table did.
435    /// Undeclared now means kept, so a column goes only when someone demotes
436    /// it, and the remaining failure is a cramped table rather than a blank
437    /// one. That is the same judgment [`kept_at`](Self::kept_at)'s renderers
438    /// make about a rank they have not learned: of the two ways to be wrong,
439    /// showing a column that should have gone is the one a reader can see and
440    /// work around.
441    #[must_use]
442    pub const fn new(name: &'a str) -> Self {
443        Self {
444            name,
445            width: Width::Fill,
446            priority: Priority::Essential,
447            kind: ColumnKind::Text,
448            min: None,
449            sortable: false,
450            sorted: None,
451        }
452    }
453
454    /// The same column, declaring the narrowest it may be before it drops.
455    #[must_use]
456    pub const fn min(mut self, ch: u16) -> Self {
457        self.min = Some(ch);
458        self
459    }
460
461    /// The same column, asking for this much room.
462    #[must_use]
463    pub const fn width(mut self, width: Width) -> Self {
464        self.width = width;
465        self
466    }
467
468    /// The same column, worth this much when room runs out.
469    #[must_use]
470    pub const fn priority(mut self, priority: Priority) -> Self {
471        self.priority = priority;
472        self
473    }
474
475    /// The same column, holding this kind of value.
476    #[must_use]
477    pub const fn kind(mut self, kind: ColumnKind) -> Self {
478        self.kind = kind;
479        self
480    }
481
482    /// The same column, reorderable by the user or not.
483    #[must_use]
484    pub const fn sortable(mut self, sortable: bool) -> Self {
485        self.sortable = sortable;
486        self
487    }
488
489    /// The same column, the one the table is ordered by, in this direction.
490    #[must_use]
491    pub const fn sorted(mut self, sort: Sort) -> Self {
492        self.sorted = Some(sort);
493        self
494    }
495
496    /// The narrowest this column may be before it drops, in `ch`.
497    ///
498    /// What every host narrows by: a column is kept at a cutoff only while the
499    /// floors of every column kept there fit. The declared [`min`](Self::min),
500    /// or else the widest of the kind's [`floor`](ColumnKind::floor), the
501    /// heading with two for its capitals and two more for a caret, and sixteen
502    /// for a [`Width::Fill`] column. Rounded up to an even count and held under
503    /// [`MIN_CEILING`], so a webview can carry the whole range as a fixed ladder
504    /// of classes and the other two hosts round the same way.
505    #[must_use]
506    pub const fn floor(&self) -> u16 {
507        let raw = match self.min {
508            Some(ch) => ch,
509            None => {
510                let caret = if self.sortable || self.sorted.is_some() {
511                    2
512                } else {
513                    0
514                };
515                // Bytes, not characters: const, and a heading is a short word
516                // in every table in the tree. A multi-byte heading reads a
517                // little wide, which errs toward keeping it legible. Two more
518                // for the heading's capitals and tracking, which run wider than
519                // the figures a `ch` is measured off: `AMOUNT` at six ch is cut.
520                let heading = if self.name.len() > MIN_CEILING as usize {
521                    MIN_CEILING
522                } else {
523                    self.name.len() as u16 + 2 + caret
524                };
525                let kind = self.kind.floor();
526                // A column that takes the slack is the one holding prose, and
527                // floored at its kind it wraps a title onto three lines while
528                // the columns worth less still stand at full width.
529                let fill = if matches!(self.width, Width::Fill) {
530                    FILL_FLOOR
531                } else {
532                    0
533                };
534                let wider = if heading > kind { heading } else { kind };
535                if fill > wider { fill } else { wider }
536            }
537        };
538        // Clamped before rounding, so a declared `min(u16::MAX)` cannot
539        // overflow on its way to being held under the ceiling.
540        let raw = if raw > MIN_CEILING { MIN_CEILING } else { raw };
541        let even = raw + raw % 2;
542        if even < 2 {
543            2
544        } else if even > MIN_CEILING {
545            MIN_CEILING
546        } else {
547            even
548        }
549    }
550
551    /// Whether this column survives at the given cutoff.
552    ///
553    /// A renderer narrows by raising the cutoff, and never by counting
554    /// positions.
555    #[must_use]
556    pub const fn kept_at(&self, cutoff: Priority) -> bool {
557        (self.priority as u8) >= (cutoff as u8)
558    }
559}
560
561/// What a table cell holds.
562///
563/// [`RowPart`] for tables, and it exists for the same reason: a part that
564/// carries a control is not text, and a renderer with one class for the whole
565/// cell paints it as though it were: a button in a cell inherits the cell's
566/// content colour, which is the drift [`RowPart::intent`] prevents for rows.
567///
568/// Four members, and the count is what quasi's `Cell` was measured to carry: a
569/// value, tokens, actions and a link. Nothing was added past what something
570/// holds.
571///
572/// `#[non_exhaustive]` for [`RowPart`]'s reason: growth here must not be a
573/// lockstep event across three renderers.
574///
575/// # No hover-reveal
576///
577/// This enum never gets one. A cell's actions are shown at rest in every
578/// consumer measured, and a member nothing uses is one three renderers owe an
579/// answer for.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
581#[non_exhaustive]
582pub enum CellPart {
583    /// The cell's own text.
584    Value,
585    /// Small labelled things in the cell: a status badge, a chip.
586    Tokens,
587    /// Controls that act on what the row is about.
588    Actions,
589    /// The cell's value, where the value is itself a link.
590    Link,
591}
592
593impl CellPart {
594    /// The content intent the part takes.
595    ///
596    /// One part is text and three are not, so three answer with the intent
597    /// inheriting already gives. That is [`RowPart::intent`]'s shape with the
598    /// text side narrower: a cell's secondary and muted readings are the
599    /// column's business, not the cell's.
600    #[must_use]
601    pub const fn intent(self) -> &'static str {
602        match self {
603            Self::Value => "content",
604            // A token carries its own tone, and a part-level intent underneath
605            // it would fight the token sitting on it.
606            Self::Tokens => "content",
607            // Actions carry controls rather than text.
608            Self::Actions => "content",
609            // A link in a cell is its row's title, so it reads in the ink the
610            // values beside it do.
611            Self::Link => "content",
612        }
613    }
614}