Skip to main content

makeover_immediate/
table.rs

1//! Columns, narrowing, cell parts and the sort caret, over `egui_extras`.
2//!
3//! `makeover-webview`'s `list` module and `makeover-tui`'s `table` in the shape
4//! immediate mode allows. It owns the same four things: which columns exist, how
5//! wide they are, which ones survive a narrow viewport, and what each part of a
6//! cell is. It does not own what goes in a cell, which here is not a policy but
7//! a fact of the mode: a cell's contents are drawn by the app's own closure, the
8//! way [`group`](crate::group) already takes one per field.
9//!
10//! # Why `egui_extras` and not egui
11//!
12//! egui itself has no table. [`egui::Grid`] gives no per-column sizing, no
13//! sticky header and no scroll sync, which is why audiofiles reached for
14//! `egui_extras::TableBuilder` rather than building on `Grid`. Writing a third
15//! answer here would be reimplementing that crate worse, so this is a mapping
16//! layer over it.
17//!
18//! It is the first dependency this crate has taken beyond egui itself, and it
19//! moves in lockstep with egui's own version, which is the cost worth naming.
20//!
21//! # What immediate mode costs the narrowing
22//!
23//! The terminal renderer measures a [`Width::Content`] column from its cells,
24//! because it holds every cell before it draws any. Here the cells do not exist
25//! until the app's closure runs, so nothing can be measured before the layout is
26//! decided.
27//!
28//! That splits the answer in two, and both halves are honest:
29//!
30//! - **Sizing** hands a content column to
31//!   [`egui_extras::Column::auto`], which measures it and holds the result
32//!   between frames. This is better than the terminal gets, not worse.
33//! - **Narrowing** cannot wait for that, so it budgets a content column at the
34//!   floor the app declared in [`Sizing`]. A column that turns out wider than
35//!   its floor is still drawn; it is the *decision to drop* that uses the
36//!   declared number, and a floor is what the app already has to supply for its
37//!   fill columns.
38//!
39//! # Why positions are the bug
40//!
41//! Carried from the other two renderers, because the mistake is not a CSS
42//! mistake and not a terminal one. goingson hides its mobile columns with
43//! `nth-child(n+5)` against a seven-column table; insert a column left of the
44//! cut and the wrong one disappears, silently. A renderer narrows by raising a
45//! cutoff and never by counting.
46
47use crate::Palette;
48use egui::{Response, RichText, Sense, Ui};
49use egui_extras::{Column as Track, TableBuilder};
50use makeover_layout::{CellPart, Column, Priority, Sort, Width};
51
52/// The cutoffs, weakest first.
53///
54/// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
55/// here in its place in the sequence, or a table will never narrow to it. Grep
56/// this when adopting a new `makeover-layout`; `makeover-tui` carries the same
57/// list for the same reason, and the two have to agree or a description narrows
58/// differently in a window than in a terminal.
59const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential];
60
61/// The lengths the description deferred, in points.
62///
63/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because
64/// a magnitude is an answer for one renderer and the description is read by
65/// three. The other two renderers hold this same type over CSS lengths and over
66/// terminal cells.
67#[derive(Debug, Clone, Copy, Default)]
68pub struct Sizing<'a> {
69    /// `(column name, points)`. The track for a [`Width::Fixed`] column, the
70    /// floor for a [`Width::Fill`] one, and the narrowing budget for a
71    /// [`Width::Content`] one.
72    pub lengths: &'a [(&'a str, f32)],
73    /// Used for a column with no entry above.
74    pub fallback: f32,
75}
76
77impl Sizing<'_> {
78    /// The length for a named column.
79    fn length_for(&self, name: &str) -> f32 {
80        self.lengths
81            .iter()
82            .find(|(column, _)| *column == name)
83            .map_or(self.fallback, |(_, length)| *length)
84    }
85}
86
87/// The tones and metrics a table draws with.
88///
89/// Metrics only, and the tones come from [`Palette`]. That is the division this
90/// crate already draws: [`FieldStyle`](crate::FieldStyle) carries gaps and a
91/// marker while the colours stay in the palette, and a table's colours are the
92/// palette's `content`, `content_muted` and `action` rather than six new ones.
93/// `makeover-tui` splits it the other way round because its palette carries no
94/// text tones at all.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct TableStyle {
97    /// The height of the heading row.
98    pub header_height: f32,
99    /// The height of a body row.
100    pub row_height: f32,
101    /// The caret drawn after the heading of an ascending column.
102    ///
103    /// Defaults to [`Sort::glyph`], which is where the spelling lives now:
104    /// three renderers holding the same literal agreed by coincidence. Bare,
105    /// with no leading space -- the gap is [`heading`]'s, written once for all
106    /// three states rather than baked into two strings and forgotten in the
107    /// third.
108    pub ascending: &'static str,
109    /// Drawn after the heading of a descending column.
110    pub descending: &'static str,
111    /// Whether alternate rows take a different background.
112    ///
113    /// egui_extras' own striping, off by default: the description has no word
114    /// for it, and a renderer that turned it on would be adding a claim the
115    /// other two cannot make.
116    ///
117    /// Not every setting egui_extras has becomes a field here. A sticky heading
118    /// is what `TableBuilder::header` does and there is no version that does
119    /// not, so a knob for it would offer a choice this renderer cannot make.
120    /// This one and [`resizable`](Self::resizable) are the two that pass that
121    /// test.
122    pub striped: bool,
123    /// Whether the user can drag the divider between two columns.
124    ///
125    /// The second knob that is not a metric, and it passes the same test
126    /// `sticky_header` failed: egui_extras offers both settings and a renderer
127    /// can honestly make either choice. Off by default for `striped`'s reason:
128    /// the description has no word for it, so a default that turned it on would
129    /// be this renderer adding a claim the other two cannot make.
130    ///
131    /// It does not fight the narrowing. A drag moves a track for the frames it
132    /// is held; [`cutoff_for`] still decides which columns exist, off the widths
133    /// the app declared in [`Sizing`], so a resize can never drop a column.
134    pub resizable: bool,
135}
136
137impl Default for TableStyle {
138    fn default() -> Self {
139        Self {
140            header_height: 20.0,
141            row_height: 18.0,
142            ascending: Sort::Ascending.glyph(),
143            descending: Sort::Descending.glyph(),
144            striped: false,
145            resizable: false,
146        }
147    }
148}
149
150/// The body's own facts for this frame: how many rows, which are selected, and
151/// which one to bring into view.
152///
153/// Held apart from [`TableStyle`] because none of it is style and none of it
154/// survives the frame: a row count changes when a folder does, a selection when
155/// the user clicks, and a scroll request exists for exactly one frame. Held
156/// apart from the [`Column`] slice because none of it is description either.
157/// The description says what a table *is*, and this says what it holds right
158/// now.
159///
160/// Both of the optional fields are here rather than left to the app because
161/// egui_extras answers them on a handle the app never sees: `set_selected` is a
162/// method on the row, and `scroll_to_row` a method on the builder, and this
163/// crate owns both. That is the same reason [`cell`] exists.
164#[derive(Default)]
165pub struct Body<'a> {
166    /// How many rows to draw.
167    pub rows: usize,
168    /// Whether a row is selected, by index.
169    ///
170    /// A predicate rather than a set, so an app whose selection is a range, a
171    /// bitmap or a single index does not have to build a collection to be asked.
172    /// `None` is a table no row of which is selected, which is not the same
173    /// claim as a predicate that always answers false and costs nothing to make.
174    pub selected: Option<&'a dyn Fn(usize) -> bool>,
175    /// A row to bring into view this frame.
176    ///
177    /// Set it from a request the app then clears, the way a keyboard cursor
178    /// moving off-screen raises one: held rather than taken, it would fight
179    /// every scroll the user makes with the mouse.
180    pub scroll_to: Option<usize>,
181}
182
183impl std::fmt::Debug for Body<'_> {
184    // Hand-written because `selected` is a closure and `#[derive(Debug)]` will
185    // not have it. What is worth printing is whether one was supplied.
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        f.debug_struct("Body")
188            .field("rows", &self.rows)
189            .field("selected", &self.selected.is_some())
190            .field("scroll_to", &self.scroll_to)
191            .finish()
192    }
193}
194
195/// The colour a cell of this part takes.
196///
197/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
198/// `content`: a part this renderer has not learned draws as text, which is a
199/// cell rendering plainly rather than a build that stops. Grep this when
200/// adopting a new `makeover-layout`.
201#[must_use]
202pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
203    match part {
204        // A token paints its own background and carries its own tone. What is
205        // set here is what shows between them, not what paints them.
206        Some(CellPart::Tokens) => palette.content_muted,
207        // The drift `CellPart` exists to end: a control in a cell inheriting the
208        // cell's text colour. Both of these take the action intent instead.
209        Some(CellPart::Actions | CellPart::Link) => palette.action,
210        _ => palette.content,
211    }
212}
213
214/// Draw a cell's contents with the tone its part takes.
215///
216/// The app calls this inside its own cell closure, wrapping whatever it draws.
217/// A scoping function rather than a parameter on [`table`], for the reason
218/// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
219/// does not exist until the closure runs, and immediate mode has no cascade to
220/// carry the answer down on its own. This is the cascade, for one scope.
221///
222/// ```no_run
223/// # use makeover_layout::CellPart;
224/// # let palette: makeover_immediate::Palette = unimplemented!();
225/// # let ui: &mut egui::Ui = unimplemented!();
226/// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
227///     ui.label("opens the item");
228/// });
229/// ```
230pub fn cell<R>(
231    ui: &mut Ui,
232    part: Option<CellPart>,
233    palette: &Palette,
234    add_contents: impl FnOnce(&mut Ui) -> R,
235) -> R {
236    let restore = ui.visuals().override_text_color;
237    ui.visuals_mut().override_text_color = Some(part_color(part, palette));
238    let out = add_contents(ui);
239    ui.visuals_mut().override_text_color = restore;
240    out
241}
242
243/// The heading, with the caret if the table is ordered by this column.
244///
245/// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
246/// still gets its caret. Both combinations mean something, which is why the
247/// description holds the two fields apart: a list ordered by a key the user
248/// cannot change is a real thing, and the caret is how it says so.
249#[must_use]
250pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
251    let caret = match column.sorted {
252        Some(Sort::Ascending) => style.ascending,
253        Some(Sort::Descending) => style.descending,
254        // Sortable and not sorted draws the idle mark, in the ascending
255        // spelling because that is the direction a first press takes. What
256        // separates it from the column in force is the tone, which is
257        // [`press`]'s to pick.
258        None if column.sortable => style.ascending,
259        None => return column.name.to_owned(),
260    };
261    format!("{} {caret}", column.name)
262}
263
264/// How wide a column asks to be at its narrowest, in points.
265fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
266    // Every arm is the declared length, including `Content`: nothing can be
267    // measured before the app's closure has drawn it. See the module header on
268    // what immediate mode costs the narrowing.
269    sizing.length_for(column.name)
270}
271
272/// Whether the columns kept at `cutoff` fit in `width`.
273fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool {
274    columns
275        .iter()
276        .filter(|c| c.kept_at(cutoff))
277        .map(|c| min_width(c, sizing))
278        .sum::<f32>()
279        <= width
280}
281
282/// The weakest cutoff whose columns fit in `width`.
283///
284/// Raised until the layout fits, and never past [`Priority::Essential`]: the
285/// essential columns are what makes a row identify itself, so a window too
286/// narrow for them gets them squeezed rather than dropped. Nothing here counts
287/// positions, so which column drops is a property of the column.
288#[must_use]
289pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority {
290    for cutoff in CUTOFFS {
291        if fits(columns, sizing, cutoff, width) {
292            return cutoff;
293        }
294    }
295    Priority::Essential
296}
297
298/// The track for one column.
299fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
300    match column.width {
301        // The one place immediate mode beats the terminal: egui_extras measures
302        // this and remembers it between frames, where `makeover-tui` has to walk
303        // the cells itself.
304        Width::Content => Track::auto(),
305        Width::Fixed => Track::exact(sizing.length_for(column.name)),
306        // Includes a width added to the description since this renderer was
307        // built. Taking the slack above a floor is the behaviour that makes no
308        // claim, which is the same fallback the webview renderer's `auto` track
309        // is chosen to be.
310        _ => Track::remainder().at_least(sizing.length_for(column.name)),
311    }
312}
313
314/// A described table, narrowed for the width available.
315///
316/// `draw` is called once per cell of each kept column, in column order, for each
317/// of [`Body::rows`] rows. Taking a closure rather than a slice of contents is
318/// what keeps the app's own data borrowed one cell at a time, which is
319/// [`group`](crate::group)'s reasoning and immediate mode's habit.
320///
321/// `body` is borrowed immutably and `draw` is `FnMut`, which is the split a
322/// caller has to plan for: a selection read by [`Body::selected`] cannot be the
323/// same value `draw` mutates. Snapshot it before the call. That is not this
324/// crate imposing anything. It is the borrow the app already takes when it
325/// clones its row list to hand egui a closure.
326///
327/// Returns the sortable column whose heading was pressed this frame, if any. The
328/// app owns the ordering, so this reports the press and changes nothing: what a
329/// press *calls* is an address, and the description names none. That is
330/// [`Column::sortable`]'s own documented split.
331///
332/// A heading is only pressable when its column says
333/// [`sortable`](Column::sortable). A column sorted by a key the user cannot
334/// change still draws its caret and does not answer.
335pub fn table<'a>(
336    ui: &mut Ui,
337    columns: &'a [Column<'a>],
338    body: &Body<'_>,
339    sizing: &Sizing<'_>,
340    palette: &Palette,
341    style: &TableStyle,
342    mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
343) -> Option<&'a Column<'a>> {
344    let cutoff = cutoff_for(columns, sizing, ui.available_width());
345    let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
346
347    // egui_extras panics on a table with no tracks, and a description whose
348    // every column dropped is reachable: `kept_at` keeps the essential ones, and
349    // a table described with none at all has nothing to keep.
350    if kept.is_empty() {
351        return None;
352    }
353
354    let mut builder = TableBuilder::new(ui)
355        .striped(style.striped)
356        .resizable(style.resizable)
357        // Not a knob, because there is no second honest answer: a cell's
358        // contents sit on the row's centre line. CSS says `vertical-align:
359        // middle` and a terminal row is one line tall, so a field offering the
360        // choice would be offering one only this renderer could take. egui's own
361        // default is top-aligned, which is why it has to be said at all.
362        .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
363    for column in &kept {
364        builder = builder.column(track(column, sizing));
365    }
366    if let Some(row) = body.scroll_to {
367        builder = builder.scroll_to_row(row, None);
368    }
369
370    // Written through a Cell rather than returned, because egui_extras hands the
371    // header and the body their own closures and neither can return a value past
372    // the other.
373    let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
374
375    builder
376        .header(style.header_height, |mut header| {
377            for column in &kept {
378                header.col(|ui| {
379                    placed(ui, column, |ui| {
380                        if press(ui, column, palette, style) {
381                            pressed.set(Some(column));
382                        }
383                    });
384                });
385            }
386        })
387        .body(|table_body| {
388            table_body.rows(style.row_height, body.rows, |mut row| {
389                let index = row.index();
390                if let Some(selected) = body.selected {
391                    // Before the cells, and on the row rather than on any of
392                    // them: a selection marks the whole row, and a renderer that
393                    // tinted each cell would leave the gaps between them
394                    // unpainted.
395                    row.set_selected(selected(index));
396                }
397                for column in &kept {
398                    row.col(|ui| placed(ui, column, |ui| draw(ui, column, index)));
399                }
400            });
401        });
402
403    pressed.get()
404}
405
406/// A cell laid out the way its column's kind says, wiki `table-model`.
407///
408/// Alignment is the kind fact this renderer acts on. A number or actions
409/// column runs right to left, on the row's centre line like every cell, and
410/// its heading does the same so the label sits over its figures. The face and
411/// the figures a kind names are the caller's, who draws the cell's contents.
412fn placed(ui: &mut Ui, column: &Column<'_>, add: impl FnOnce(&mut Ui)) {
413    if column.kind.aligns_end() {
414        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), add);
415    } else {
416        add(ui);
417    }
418}
419
420/// What one heading is drawn in.
421///
422/// Three states, three tones (wiki `three-tone-convention`). The column in force
423/// is the emphasised thing; a column offering to reorder is inactive but usable,
424/// because it answers a press; a column that is not a control at all is inert.
425///
426/// The middle one may not take `content_muted`, which is what
427/// [`State::Disabled`](makeover_layout::State::Disabled) resolves to: a heading
428/// the user can press would be claiming it will not answer.
429fn heading_color(column: &Column<'_>, palette: &Palette) -> egui::Color32 {
430    match (column.sorted, column.sortable) {
431        (Some(_), _) => palette.content,
432        (None, true) => palette.content_secondary,
433        (None, false) => palette.content_muted,
434    }
435}
436
437/// One heading, and whether it was pressed.
438fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
439    let text = RichText::new(heading(column, style)).color(heading_color(column, palette));
440    if !column.sortable {
441        // Not sensed. A heading a user cannot press must not look like one they
442        // can, which is the affordance `Column::sortable` exists to carry, and
443        // the tone above is half of saying so.
444        ui.label(text.strong());
445        return false;
446    }
447    let response: Response = ui
448        .add(egui::Label::new(text.strong()).sense(Sense::click()))
449        .on_hover_cursor(egui::CursorIcon::PointingHand);
450    // Announced as the control it is, rather than as the `Label` it is drawn
451    // with. egui maps a `Label` to `Role::Label` whatever it senses, so until
452    // 2026-08-22 a screen reader was told this was static text and a user who
453    // could not see the pointer change had no way to know the table sorts.
454    // The same argument the comment above makes about affordance, made about
455    // the half of the interface that is not pixels.
456    //
457    // The name is the column's own, not `heading`'s: the caret is a rendering of
458    // `Column::sorted`, and reading a triangle aloud after every heading is
459    // noise. Which column is in force is a fact a client should get from the
460    // sort state, and egui has nowhere to put that yet -- worth revisiting if it
461    // grows a sort field on `WidgetInfo`.
462    response.widget_info(|| {
463        egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), column.name)
464    });
465    response.clicked()
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use makeover_layout::ColumnKind;
472
473    /// What the accessibility tree says a heading row drew.
474    ///
475    /// egui builds it from the `WidgetInfo` each widget reports, so this is
476    /// what a screen reader would be handed rather than a second opinion.
477    fn announced(draw: impl FnMut(&mut Ui)) -> Vec<(egui::accesskit::Role, String)> {
478        let ctx = egui::Context::default();
479        ctx.enable_accesskit();
480        let mut draw = draw;
481        let input = || egui::RawInput {
482            screen_rect: Some(egui::Rect::from_min_size(
483                egui::Pos2::ZERO,
484                egui::vec2(800.0, 600.0),
485            )),
486            ..Default::default()
487        };
488        let _ = ctx.run_ui(input(), &mut draw);
489        let out = ctx.run_ui(input(), &mut draw);
490        out.platform_output
491            .accesskit_update
492            .expect("accesskit is on")
493            .nodes
494            .iter()
495            .map(|(_, node)| {
496                (
497                    node.role(),
498                    node.label()
499                        .or_else(|| node.value())
500                        .unwrap_or_default()
501                        .to_owned(),
502                )
503            })
504            .collect()
505    }
506
507    #[test]
508    fn a_sortable_heading_is_announced_as_something_you_press() {
509        let column = Column {
510            name: "Name",
511            width: Width::Fill,
512            priority: Priority::Essential,
513            kind: ColumnKind::Text,
514            sortable: true,
515            sorted: Some(Sort::Ascending),
516        };
517        let p = palette();
518        let drawn = announced(|ui| {
519            press(ui, &column, &p, &TableStyle::default());
520        });
521
522        // The name is the column's, with no caret in it: the glyph renders
523        // `Column::sorted` and is not part of what the control is called.
524        assert!(
525            drawn
526                .iter()
527                .any(|(role, name)| *role == egui::accesskit::Role::Button && name == "Name"),
528            "{drawn:?}"
529        );
530    }
531
532    #[test]
533    fn a_heading_that_is_not_a_control_is_not_announced_as_one() {
534        let column = Column {
535            name: "Tags",
536            width: Width::Fixed,
537            priority: Priority::Optional,
538            kind: ColumnKind::Text,
539            sortable: false,
540            sorted: None,
541        };
542        let p = palette();
543        let drawn = announced(|ui| {
544            press(ui, &column, &p, &TableStyle::default());
545        });
546
547        assert!(
548            !drawn
549                .iter()
550                .any(|(role, _)| *role == egui::accesskit::Role::Button),
551            "a heading with no sort answers nothing and must not claim to: {drawn:?}"
552        );
553    }
554    use egui::Color32;
555
556    fn palette() -> Palette {
557        Palette {
558            page: Color32::from_rgb(1, 1, 1),
559            raised: Color32::from_rgb(2, 2, 2),
560            overlay: Color32::from_rgb(3, 3, 3),
561            well: Color32::from_rgb(4, 4, 4),
562            sunken: Color32::from_rgb(5, 5, 5),
563            bevel_light: Color32::WHITE,
564            bevel_dark: Color32::BLACK,
565            elevation: Color32::from_black_alpha(46),
566            content: Color32::from_rgb(6, 6, 6),
567            content_secondary: Color32::from_rgb(56, 56, 56),
568            content_muted: Color32::from_rgb(7, 7, 7),
569            action: Color32::from_rgb(8, 8, 8),
570            danger: Color32::from_rgb(9, 9, 9),
571            success: Color32::from_rgb(10, 10, 10),
572            warning: Color32::from_rgb(11, 11, 11),
573            info: Color32::from_rgb(12, 12, 12),
574        }
575    }
576
577    fn columns() -> Vec<Column<'static>> {
578        vec![
579            Column {
580                name: "name",
581                width: Width::Fill,
582                priority: Priority::Essential,
583                kind: ColumnKind::Text,
584                sortable: true,
585                sorted: Some(Sort::Ascending),
586            },
587            Column {
588                name: "size",
589                width: Width::Fixed,
590                priority: Priority::Secondary,
591                kind: ColumnKind::Text,
592                sortable: true,
593                sorted: None,
594            },
595            Column {
596                name: "note",
597                width: Width::Content,
598                priority: Priority::Optional,
599                kind: ColumnKind::Text,
600                sortable: false,
601                sorted: None,
602            },
603        ]
604    }
605
606    fn sizing() -> Sizing<'static> {
607        Sizing {
608            lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
609            fallback: 40.0,
610        }
611    }
612
613    #[test]
614    fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
615        let (cols, sz) = (columns(), sizing());
616        assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
617        assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
618        assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential);
619        // Narrower than the essential column, which stays anyway.
620        assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
621    }
622
623    #[test]
624    fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
625        // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
626        // table hides whatever lands at position five, so inserting a column
627        // moves the cut onto a different column with nothing edited.
628        //
629        // Asserted at a fixed cutoff, because that is where the two ways of
630        // addressing a column disagree. A narrower budget SHOULD drop more; what
631        // must not change is which ones, for a given cutoff.
632        let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
633            cols.iter()
634                .filter(|c| !c.kept_at(cutoff))
635                .map(|c| c.name.to_owned())
636                .collect()
637        };
638        let before = columns();
639        let mut after = vec![Column {
640            name: "mark",
641            width: Width::Fixed,
642            priority: Priority::Essential,
643            kind: ColumnKind::Text,
644            sortable: false,
645            sorted: None,
646        }];
647        after.extend(columns());
648
649        for cutoff in CUTOFFS {
650            assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
651        }
652        assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
653    }
654
655    #[test]
656    fn the_two_renderers_narrow_a_description_the_same_way() {
657        // The cutoff ladder is duplicated in `makeover-tui` because neither
658        // crate depends on the other, and duplication is what drifts. This is
659        // the assertion that would catch it: the ladder is the description's
660        // order, weakest first, and a tier added upstream belongs in both.
661        assert_eq!(CUTOFFS.len(), 3);
662        assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
663        assert_eq!(CUTOFFS[0], Priority::Optional);
664        assert_eq!(CUTOFFS[2], Priority::Essential);
665    }
666
667    #[test]
668    fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
669        // The split the module header names. The track defers to egui_extras,
670        // which can measure; the narrowing cannot wait for that and uses the
671        // declared floor. Both readings of the same column, and both honest.
672        let cols = columns();
673        let sz = sizing();
674        let note = &cols[2];
675        assert!(matches!(note.width, Width::Content));
676        assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON);
677        // 120 + 60 + 80 is 260, so 300 fits and 250 does not.
678        assert!(fits(&cols, &sz, Priority::Optional, 300.0));
679        assert!(!fits(&cols, &sz, Priority::Optional, 250.0));
680    }
681
682    #[test]
683    fn a_column_with_no_length_of_its_own_takes_the_fallback() {
684        let column = Column {
685            name: "unlisted",
686            width: Width::Fixed,
687            priority: Priority::Essential,
688            kind: ColumnKind::Text,
689            sortable: false,
690            sorted: None,
691        };
692        assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON);
693    }
694
695    #[test]
696    fn the_parts_a_cell_can_be_are_coloured_apart() {
697        // The drift `CellPart` exists to end: one colour for a whole cell paints
698        // a control as though it were text.
699        let p = palette();
700        assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
701        assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
702        assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
703        assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
704        assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
705        // A cell mixing parts says nothing, and takes the text colour.
706        assert_eq!(part_color(None, &p), p.content);
707    }
708
709    #[test]
710    fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() {
711        let style = TableStyle::default();
712        let cols = columns();
713        assert_eq!(heading(&cols[0], &style), "name \u{25B2}");
714        // Sortable and idle. It draws the mark a first press would give, which
715        // is what stops the press from widening the column and shifting the
716        // ones after it.
717        assert_eq!(heading(&cols[1], &style), "size \u{25B2}");
718        // Not a control. Nothing to mark.
719        assert_eq!(heading(&cols[2], &style), "note");
720    }
721
722    #[test]
723    fn the_three_states_of_a_heading_are_three_tones() {
724        // wiki `three-tone-convention`. The middle state may not take
725        // content_muted, which is what `State::Disabled` resolves to: a heading
726        // the user can press would claim it will not answer. The arm that keeps
727        // muted is the one where it is true.
728        let p = palette();
729        let cols = columns();
730        assert_eq!(heading_color(&cols[0], &p), p.content);
731        assert_eq!(heading_color(&cols[1], &p), p.content_secondary);
732        assert_eq!(heading_color(&cols[2], &p), p.content_muted);
733    }
734
735    #[test]
736    fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
737        // A list ordered by a key the user cannot change is a real thing to
738        // describe, which is why the description holds the two fields apart.
739        let column = Column {
740            name: "rank",
741            width: Width::Content,
742            priority: Priority::Essential,
743            kind: ColumnKind::Text,
744            sortable: false,
745            sorted: Some(Sort::Descending),
746        };
747        assert_eq!(heading(&column, &TableStyle::default()), "rank \u{25BC}");
748    }
749
750    #[test]
751    fn the_carets_match_the_terminal_renderers() {
752        // Two crates, one glyph pair, and no dependency between them to enforce
753        // it. A description sorted ascending must not point up in a window and
754        // down in a terminal.
755        // Composition rather than agreement since makeover-layout 0.27.5: both
756        // read `Sort::glyph`, so a fourth spelling cannot appear in one crate.
757        let style = TableStyle::default();
758        assert_eq!(style.ascending, Sort::Ascending.glyph());
759        assert_eq!(style.descending, Sort::Descending.glyph());
760        // Bare. The gap is `heading`'s, so a consumer swapping the glyph for an
761        // ASCII one does not have to remember to bring a space with it.
762        assert_eq!(style.ascending.trim(), style.ascending);
763    }
764
765    #[test]
766    fn striping_is_off_because_the_description_has_no_word_for_it() {
767        // egui_extras offers it and the other two renderers cannot say it. A
768        // default that turned it on would be this renderer adding a claim.
769        assert!(!TableStyle::default().striped);
770        // Same test, same answer, and the reason `sticky_header` failed it: that
771        // one had no second setting to offer.
772        assert!(!TableStyle::default().resizable);
773    }
774
775    #[test]
776    fn a_body_claims_nothing_until_it_is_asked_to() {
777        // The default is a table of no rows, no selection and no scroll
778        // request. All three absences are the honest reading of an app that has
779        // not said otherwise, which is why they are `Option` and not a
780        // predicate that always answers false.
781        let body = Body::default();
782        assert_eq!(body.rows, 0);
783        assert!(body.selected.is_none());
784        assert!(body.scroll_to.is_none());
785    }
786
787    #[test]
788    fn a_selection_is_asked_per_row_and_not_collected() {
789        // A predicate, so an app whose selection is a range or a single index
790        // does not build a set to be asked. Exercised the way `table` asks it:
791        // once per row index, in order.
792        let selected = |index: usize| index.is_multiple_of(2);
793        let body = Body {
794            rows: 4,
795            selected: Some(&selected),
796            scroll_to: None,
797        };
798        let f = body.selected.expect("a predicate was supplied");
799        assert_eq!(
800            (0..body.rows).map(f).collect::<Vec<_>>(),
801            vec![true, false, true, false]
802        );
803    }
804
805    #[test]
806    fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() {
807        // `resizable` lets the user move a divider, and `cutoff_for` must not
808        // hear about it: a drag that could drop a column would make the
809        // narrowing a thing the user does by accident rather than a property of
810        // the description. That `cutoff_for` takes no `TableStyle` at all is the
811        // structural half of the guarantee; this is the behavioural half, and it
812        // is what would fail if a measured width were ever threaded in beside
813        // the declared one.
814        let (cols, sz) = (columns(), sizing());
815        assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
816        assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
817    }
818}