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::{FieldKind, Fill, Region, RowPart};
4
5/// How much room a placement asks for.
6///
7/// A column says it, and so does a form 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`](ColumnRoom::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/// The lengths a description deferred, in a renderer's own unit.
109///
110/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude,
111/// because a magnitude is an answer for one renderer. Each renderer holds this
112/// over its own unit (cells, points, CSS lengths) and looks a column up by
113/// name, because an app's columns are not all one size.
114#[derive(Debug, Clone, Copy, PartialEq, Default)]
115#[non_exhaustive]
116pub struct Sizing<'a, L> {
117    /// `(column name, length)`: the track for a [`Width::Fixed`] column and the
118    /// floor for a [`Width::Fill`] one.
119    pub lengths: &'a [(&'a str, L)],
120    /// Used for a column with no entry above.
121    pub fallback: L,
122}
123
124impl<'a, L: Copy> Sizing<'a, L> {
125    /// These lengths, and `fallback` for a column not among them.
126    #[must_use]
127    pub const fn new(lengths: &'a [(&'a str, L)], fallback: L) -> Self {
128        Self { lengths, fallback }
129    }
130
131    /// The length for a named column.
132    #[must_use]
133    pub fn length_for(&self, name: &str) -> L {
134        self.lengths
135            .iter()
136            .find(|(column, _)| *column == name)
137            .map_or(self.fallback, |(_, length)| *length)
138    }
139}
140
141/// What a group does when it runs out of room.
142///
143/// Authored, and required: the field carrying this has no `Default` and a group
144/// cannot be described without saying what it does when it runs out of room.
145/// More intentionality from layout designers is acceptable so long as the constraints are solvable, because the goal is
146/// enabling good layouts rather than rescuing bad ones. A default here would be
147/// the crate guessing, and the guess would be silently wrong on the screens
148/// that matter.
149///
150/// Relief resolves inside-out. A group asks its children to fall back before
151/// falling back itself, or an outer group collapses while an inner one still
152/// had slack.
153///
154/// # No `Swap`
155///
156/// An authored alternate group for the tight case is deliberately out of the
157/// first cut. It doubles the description for that group and the two halves can
158/// drift, which is the failure this vocabulary exists to end. Add it when a
159/// site proves it needs one.
160///
161/// `#[non_exhaustive]`, [`Width`]'s reasoning. Unlike [`Priority`] there is no
162/// order to preserve, so a member can be appended.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
164#[non_exhaustive]
165pub enum Fallback {
166    /// One row becomes two. Every member stays, in the order described.
167    Wrap,
168    /// A row becomes a column. Every member stays, full width.
169    Stack,
170    /// Members drop by [`Priority`], down to [`Priority::Essential`].
171    ///
172    /// What a narrow table already does with its columns, applied to a group.
173    /// What drops is gone from the screen, so this is right when the dropped
174    /// members are facts the reader can do without and wrong when they are the
175    /// only way to act.
176    Shed,
177    /// The members [`Shed`](Self::Shed) would drop move into one overflow
178    /// control instead.
179    ///
180    /// The answer when a group holds actions. A control is not a fact: dropping
181    /// it does not cost the reader a detail, it costs them the only way to act,
182    /// which is [`RowPart::priority`]'s argument one level up.
183    Menu,
184}
185
186/// The widest a column's [`floor`](ColumnRoom::floor) may be, in `ch`.
187///
188/// A bound so a webview can name every floor as a class from a fixed ladder:
189/// a container condition cannot read a custom property, so the length a
190/// column hides its contents under has to be written into the stylesheet.
191/// Wider than any column the tree declares, and a column wanting more is a
192/// [`Width::Fill`] with this as its floor.
193pub const MIN_CEILING: u16 = 64;
194
195/// The floor an undeclared [`Width::Fill`] column takes, in `ch`.
196const FILL_FLOOR: u16 = 16;
197
198/// The number of characters in a heading, for a `ch`-based floor.
199///
200/// `str::chars().count()` is not const and [`ColumnRoom::floor`] is, so this counts
201/// UTF-8 code points by hand. `len()` counts bytes, which over-reserves a
202/// multi-byte heading: a Cyrillic `Статус` reads as 12 `ch` rather than 6.
203const fn char_len(s: &str) -> usize {
204    let bytes = s.as_bytes();
205    let mut chars = 0;
206    let mut i = 0;
207    while i < bytes.len() {
208        chars += 1;
209        let byte = bytes[i];
210        i += if byte < 0x80 {
211            1
212        } else if byte < 0xE0 {
213            2
214        } else if byte < 0xF0 {
215            3
216        } else {
217            4
218        };
219    }
220    chars
221}
222
223/// What a column holds.
224///
225/// wiki `table-model`: the kind is what carries a column's look, and four of
226/// the reference table's six rules were per kind rather than per table. A
227/// description states what the column is; each renderer decides what that
228/// looks like in its host, from the facts below rather than from the name.
229///
230/// `#[non_exhaustive]`, [`Width`]'s reasoning.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
232#[non_exhaustive]
233pub enum ColumnKind {
234    /// Prose. Wraps. What a column is when it says nothing else.
235    #[default]
236    Text,
237    /// A name a machine made: a fingerprint, a hash, a slug. Monospace, and
238    /// breaks mid-string rather than widening the table.
239    Identifier,
240    /// A point in time. Never wraps, so a date cannot print over its
241    /// neighbour, and its figures line up down the column.
242    Date,
243    /// A quantity. Figures line up and the column aligns to its end, so
244    /// magnitudes compare by length.
245    Number,
246    /// Source text, one row per line. Monospace and never wrapped. A table
247    /// holding one is read as code, so its rows are not striped or spaced as
248    /// records.
249    Code,
250    /// A state the row is in, drawn as a chip rather than as coloured text.
251    Status,
252    /// The row's controls. Aligns to its end and shrinks to what it holds.
253    Actions,
254}
255
256impl ColumnKind {
257    /// The narrowest a column of this kind reads at, in `ch`, when it declares
258    /// nothing narrower.
259    ///
260    /// A date is ten characters in the form every host writes, and a number
261    /// cut short is a different number, so the kinds that cannot truncate
262    /// floor at what they hold. Prose and identifiers wrap or break, so theirs
263    /// is only enough to be recognisable.
264    #[must_use]
265    pub const fn floor(self) -> u16 {
266        match self {
267            Self::Text | Self::Code | Self::Status | Self::Actions => 8,
268            Self::Identifier => 12,
269            Self::Date => 10,
270            Self::Number => 6,
271        }
272    }
273
274    /// Whether the column aligns to its end rather than its start.
275    #[must_use]
276    pub const fn aligns_end(self) -> bool {
277        matches!(self, Self::Number | Self::Actions)
278    }
279
280    /// Whether the column's text may wrap onto a second line.
281    ///
282    /// An identifier wraps by breaking mid-string, which is still a wrap: the
283    /// alternative is a fingerprint forcing the table wider than its pane.
284    #[must_use]
285    pub const fn wraps(self) -> bool {
286        matches!(self, Self::Text | Self::Identifier)
287    }
288
289    /// Whether the column is set in the monospace face.
290    #[must_use]
291    pub const fn monospace(self) -> bool {
292        matches!(self, Self::Identifier | Self::Code)
293    }
294
295    /// Whether figures in the column take equal widths.
296    #[must_use]
297    pub const fn tabular(self) -> bool {
298        matches!(self, Self::Date | Self::Number)
299    }
300}
301
302/// Which way a column is ordered.
303///
304/// Two, because there is no third. "Unsorted" is a column with no direction
305/// at all, and folding it in here would be the same absence said twice.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
307#[non_exhaustive]
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
369/// What one column asks of the sizer: the room it needs and what it is worth.
370///
371/// A layout record and nothing else. It holds the heading only because the
372/// heading is what a column must at least fit, and it says whether a sort caret
373/// sits beside it for the same reason; what the column holds, how it is
374/// ordered and what pressing it does are its describer's. Every renderer sizes
375/// a table from these, so the floor and the cutoff are one rule on every host
376/// whatever type the columns were described in.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
378pub struct ColumnRoom<'a> {
379    /// The heading the column must at least fit.
380    pub heading: &'a str,
381    /// How much room it asks for.
382    pub width: Width,
383    /// What it is worth when room runs out.
384    pub priority: Priority,
385    /// What it holds, which carries a floor of its own.
386    pub kind: ColumnKind,
387    /// The narrowest it may be before it drops, in `ch`, when declared.
388    ///
389    /// wiki `frontend-unification`: collapse points derive from declared
390    /// minimums only, so every host narrows at the same declared width rather
391    /// than at its own breakpoints. `ch` because every host has a direct
392    /// answer for it: a terminal cell is one, an immediate-mode painter
393    /// measures one off its face, and a webview draws one as nine sixteenths
394    /// of the base, a figure at a table's text size with room to spare. Not
395    /// the CSS `ch` itself, because a container condition resolves
396    /// font-relative units against a different font than the cell's, and the
397    /// column would hide at one width and be sized at another.
398    ///
399    /// `None` derives it from the kind and the heading, which is what most
400    /// columns want. Read it through [`floor`](Self::floor), never directly:
401    /// that is where the derivation, the rounding and the ceiling live, and a
402    /// host reading this field would narrow somewhere the other two do not.
403    pub min: Option<u16>,
404    /// Whether a sort caret sits beside the heading: the column is sortable,
405    /// or is the one the table is ordered by.
406    pub caret: bool,
407}
408
409impl<'a> ColumnRoom<'a> {
410    /// A text column under `heading` that fills and is essential, the defaults
411    /// a column takes when it says nothing.
412    #[must_use]
413    pub const fn new(heading: &'a str) -> Self {
414        Self {
415            heading,
416            width: Width::Fill,
417            priority: Priority::Essential,
418            kind: ColumnKind::Text,
419            min: None,
420            caret: false,
421        }
422    }
423
424    /// The narrowest this column may be before it drops, in `ch`.
425    ///
426    /// What every host narrows by: a column is kept at a cutoff only while the
427    /// floors of every column kept there fit. The declared [`min`](Self::min),
428    /// or else the widest of the kind's [`floor`](ColumnKind::floor), the
429    /// heading with two for its capitals and two more for a caret, and sixteen
430    /// for a [`Width::Fill`] column. Rounded up to an even count and held under
431    /// [`MIN_CEILING`], so a webview can carry the whole range as a fixed ladder
432    /// of classes and the other two hosts round the same way.
433    #[must_use]
434    pub const fn floor(&self) -> u16 {
435        let raw = match self.min {
436            Some(ch) => ch,
437            None => {
438                let caret = if self.caret { 2 } else { 0 };
439                // Characters, not bytes: `ch` is the width of a glyph, so a
440                // multi-byte heading counted by `len()` over-reserves (`Статус`
441                // reads as 12 ch rather than 6) and a non-Latin table drops
442                // columns sooner than the same table in another locale. Two more
443                // for the heading's capitals and tracking, which run wider than
444                // the figures a `ch` is measured off: `AMOUNT` at six ch is cut.
445                let chars = char_len(self.heading);
446                let heading = if chars > MIN_CEILING as usize {
447                    MIN_CEILING
448                } else {
449                    chars as u16 + 2 + caret
450                };
451                let kind = self.kind.floor();
452                // A column that takes the slack is the one holding prose, and
453                // floored at its kind it wraps a title onto three lines while
454                // the columns worth less still stand at full width.
455                let fill = if matches!(self.width, Width::Fill) {
456                    FILL_FLOOR
457                } else {
458                    0
459                };
460                let wider = if heading > kind { heading } else { kind };
461                if fill > wider { fill } else { wider }
462            }
463        };
464        // Clamped before rounding, so a declared `min(u16::MAX)` cannot
465        // overflow on its way to being held under the ceiling.
466        let raw = if raw > MIN_CEILING { MIN_CEILING } else { raw };
467        let even = raw + raw % 2;
468        if even < 2 {
469            2
470        } else if even > MIN_CEILING {
471            MIN_CEILING
472        } else {
473            even
474        }
475    }
476
477    /// Whether this column is kept at `cutoff`.
478    ///
479    /// A renderer narrows by raising the cutoff, and never by counting
480    /// positions.
481    #[must_use]
482    pub const fn kept_at(&self, cutoff: Priority) -> bool {
483        (self.priority as u8) >= (cutoff as u8)
484    }
485
486    /// What the columns kept at `cutoff` ask for.
487    #[must_use]
488    pub fn budget(rooms: &[Self], cutoff: Priority) -> Budget {
489        rooms
490            .iter()
491            .filter(|room| room.kept_at(cutoff))
492            .fold(Budget::default(), |budget, room| Budget {
493                columns: budget.columns + 1,
494                floors: budget.floors + u32::from(room.floor()),
495            })
496    }
497
498    /// The weakest cutoff whose kept columns `fits` accepts.
499    ///
500    /// Raised through [`Priority::RAISING`] until the columns fit, and never
501    /// past the strongest priority any column declares: the essential columns
502    /// are what makes a row identify itself, so a window too narrow for them
503    /// gets them squeezed rather than dropped. A table with no essential column
504    /// therefore never rises to [`Priority::Essential`], where the budget would
505    /// keep no column at all. Nothing here counts positions, so which column
506    /// drops is a property of the column.
507    #[must_use]
508    pub fn cutoff_for(rooms: &[Self], fits: impl Fn(Budget) -> bool) -> Priority {
509        let strongest = rooms
510            .iter()
511            .map(|room| room.priority)
512            .max()
513            .unwrap_or(Priority::Essential);
514        Priority::RAISING
515            .iter()
516            .copied()
517            .take_while(|&cutoff| cutoff <= strongest)
518            .find(|&cutoff| fits(Self::budget(rooms, cutoff)))
519            .unwrap_or(strongest)
520    }
521}
522
523/// What a table cell holds.
524///
525/// [`RowPart`] for tables, and it exists for the same reason: a part that
526/// carries a control is not text, and a renderer with one class for the whole
527/// cell paints it as though it were: a button in a cell inherits the cell's
528/// content colour, which is the drift [`RowPart::intent`] prevents for rows.
529///
530/// Four members, and the count is what quasi's `Cell` was measured to carry: a
531/// value, tokens, actions and a link. Nothing was added past what something
532/// holds.
533///
534/// `#[non_exhaustive]` for [`RowPart`]'s reason: growth here must not be a
535/// lockstep event across three renderers.
536///
537/// # No hover-reveal
538///
539/// This enum never gets one. A cell's actions are shown at rest in every
540/// consumer measured, and a member nothing uses is one three renderers owe an
541/// answer for.
542#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
543#[non_exhaustive]
544pub enum CellPart {
545    /// The cell's own text.
546    Value,
547    /// Small labelled things in the cell: a status badge, a chip.
548    Tokens,
549    /// Controls that act on what the row is about.
550    Actions,
551    /// The cell's value, where the value is itself a link.
552    Link,
553}
554
555impl CellPart {
556    /// The content intent the part takes.
557    ///
558    /// One part is text and three are not, so three answer with the intent
559    /// inheriting already gives. That is [`RowPart::intent`]'s shape with the
560    /// text side narrower: a cell's secondary and muted readings are the
561    /// column's business, not the cell's.
562    #[must_use]
563    pub const fn intent(self) -> &'static str {
564        match self {
565            Self::Value => "content",
566            // A token carries its own tone, and a part-level intent underneath
567            // it would fight the token sitting on it.
568            Self::Tokens => "content",
569            // Actions carry controls rather than text.
570            Self::Actions => "content",
571            // A link in a cell is its row's title, so it reads in the ink the
572            // values beside it do.
573            Self::Link => "content",
574        }
575    }
576}