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