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    /// Drawn after the heading of an ascending column.
102    pub ascending: &'static str,
103    /// Drawn after the heading of a descending column.
104    pub descending: &'static str,
105    /// Whether alternate rows take a different background.
106    ///
107    /// egui_extras' own striping, off by default: the description has no word
108    /// for it, and a renderer that turned it on would be adding a claim the
109    /// other two cannot make.
110    ///
111    /// The only knob here that is not a metric, and it is the only one because
112    /// egui_extras already answers the rest. A sticky heading, for one: it is
113    /// what `TableBuilder::header` does and there is no version that does not,
114    /// so a field offering the choice would be offering one this renderer cannot
115    /// make.
116    pub striped: bool,
117}
118
119impl Default for TableStyle {
120    fn default() -> Self {
121        Self {
122            header_height: 20.0,
123            row_height: 18.0,
124            // The pair audiofiles already draws, so a sorted column points the
125            // same way here as it does in a terminal.
126            ascending: " \u{25B2}",
127            descending: " \u{25BC}",
128            striped: false,
129        }
130    }
131}
132
133/// The colour a cell of this part takes.
134///
135/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
136/// `content`: a part this renderer has not learned draws as text, which is a
137/// cell rendering plainly rather than a build that stops. Grep this when
138/// adopting a new `makeover-layout`.
139#[must_use]
140pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
141    match part {
142        // A token paints its own background and carries its own tone. What is
143        // set here is what shows between them, not what paints them.
144        Some(CellPart::Tokens) => palette.content_muted,
145        // The drift `CellPart` exists to end: a control in a cell inheriting the
146        // cell's text colour. Both of these take the action intent instead.
147        Some(CellPart::Actions | CellPart::Link) => palette.action,
148        _ => palette.content,
149    }
150}
151
152/// Draw a cell's contents with the tone its part takes.
153///
154/// The app calls this inside its own cell closure, wrapping whatever it draws.
155/// A scoping function rather than a parameter on [`table`], for the reason
156/// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
157/// does not exist until the closure runs, and immediate mode has no cascade to
158/// carry the answer down on its own. This is the cascade, for one scope.
159///
160/// ```no_run
161/// # use makeover_layout::CellPart;
162/// # let palette: makeover_immediate::Palette = unimplemented!();
163/// # let ui: &mut egui::Ui = unimplemented!();
164/// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
165///     ui.label("opens the item");
166/// });
167/// ```
168pub fn cell<R>(
169    ui: &mut Ui,
170    part: Option<CellPart>,
171    palette: &Palette,
172    add_contents: impl FnOnce(&mut Ui) -> R,
173) -> R {
174    let restore = ui.visuals().override_text_color;
175    ui.visuals_mut().override_text_color = Some(part_color(part, palette));
176    let out = add_contents(ui);
177    ui.visuals_mut().override_text_color = restore;
178    out
179}
180
181/// The heading, with the caret if the table is ordered by this column.
182///
183/// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
184/// still gets its caret. Both combinations mean something, which is why the
185/// description holds the two fields apart: a list ordered by a key the user
186/// cannot change is a real thing, and the caret is how it says so.
187#[must_use]
188pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
189    match column.sorted {
190        Some(Sort::Ascending) => format!("{}{}", column.name, style.ascending),
191        Some(Sort::Descending) => format!("{}{}", column.name, style.descending),
192        None => column.name.to_owned(),
193    }
194}
195
196/// How wide a column asks to be at its narrowest, in points.
197fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
198    // Every arm is the declared length, including `Content`: nothing can be
199    // measured before the app's closure has drawn it. See the module header on
200    // what immediate mode costs the narrowing.
201    sizing.length_for(column.name)
202}
203
204/// Whether the columns kept at `cutoff` fit in `width`.
205fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool {
206    columns
207        .iter()
208        .filter(|c| c.kept_at(cutoff))
209        .map(|c| min_width(c, sizing))
210        .sum::<f32>()
211        <= width
212}
213
214/// The weakest cutoff whose columns fit in `width`.
215///
216/// Raised until the layout fits, and never past [`Priority::Essential`]: the
217/// essential columns are what makes a row identify itself, so a window too
218/// narrow for them gets them squeezed rather than dropped. Nothing here counts
219/// positions, so which column drops is a property of the column.
220#[must_use]
221pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority {
222    for cutoff in CUTOFFS {
223        if fits(columns, sizing, cutoff, width) {
224            return cutoff;
225        }
226    }
227    Priority::Essential
228}
229
230/// The track for one column.
231fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
232    match column.width {
233        // The one place immediate mode beats the terminal: egui_extras measures
234        // this and remembers it between frames, where `makeover-tui` has to walk
235        // the cells itself.
236        Width::Content => Track::auto(),
237        Width::Fixed => Track::exact(sizing.length_for(column.name)),
238        // Includes a width added to the description since this renderer was
239        // built. Taking the slack above a floor is the behaviour that makes no
240        // claim, which is the same fallback the webview renderer's `auto` track
241        // is chosen to be.
242        _ => Track::remainder().at_least(sizing.length_for(column.name)),
243    }
244}
245
246/// A described table, narrowed for the width available.
247///
248/// `rows` is a count and `draw` is called once per cell of each kept column, in
249/// column order. Taking a closure rather than a slice of contents is what keeps
250/// the app's own data borrowed one cell at a time, which is
251/// [`group`](crate::group)'s reasoning and immediate mode's habit.
252///
253/// Returns the sortable column whose heading was pressed this frame, if any. The
254/// app owns the ordering, so this reports the press and changes nothing: what a
255/// press *calls* is an address, and the description names none. That is
256/// [`Column::sortable`]'s own documented split.
257///
258/// A heading is only pressable when its column says
259/// [`sortable`](Column::sortable). A column sorted by a key the user cannot
260/// change still draws its caret and does not answer.
261pub fn table<'a>(
262    ui: &mut Ui,
263    columns: &'a [Column<'a>],
264    rows: usize,
265    sizing: &Sizing<'_>,
266    palette: &Palette,
267    style: &TableStyle,
268    mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
269) -> Option<&'a Column<'a>> {
270    let cutoff = cutoff_for(columns, sizing, ui.available_width());
271    let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
272
273    // egui_extras panics on a table with no tracks, and a description whose
274    // every column dropped is reachable: `kept_at` keeps the essential ones, and
275    // a table described with none at all has nothing to keep.
276    if kept.is_empty() {
277        return None;
278    }
279
280    let mut builder = TableBuilder::new(ui).striped(style.striped);
281    for column in &kept {
282        builder = builder.column(track(column, sizing));
283    }
284
285    // Written through a Cell rather than returned, because egui_extras hands the
286    // header and the body their own closures and neither can return a value past
287    // the other.
288    let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
289
290    builder
291        .header(style.header_height, |mut header| {
292            for column in &kept {
293                header.col(|ui| {
294                    if press(ui, column, palette, style) {
295                        pressed.set(Some(column));
296                    }
297                });
298            }
299        })
300        .body(|body| {
301            body.rows(style.row_height, rows, |mut row| {
302                let index = row.index();
303                for column in &kept {
304                    row.col(|ui| draw(ui, column, index));
305                }
306            });
307        });
308
309    pressed.get()
310}
311
312/// One heading, and whether it was pressed.
313fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
314    let text = RichText::new(heading(column, style)).strong();
315    if !column.sortable {
316        // Muted, and not sensed. A heading a user cannot press must not look
317        // like one they can, which is the affordance `Column::sortable` exists
318        // to carry.
319        ui.label(text.color(palette.content_muted));
320        return false;
321    }
322    let tone = if column.sorted.is_some() {
323        palette.content
324    } else {
325        palette.content_muted
326    };
327    let response: Response = ui
328        .add(egui::Label::new(text.color(tone)).sense(Sense::click()))
329        .on_hover_cursor(egui::CursorIcon::PointingHand);
330    response.clicked()
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use egui::Color32;
337
338    fn palette() -> Palette {
339        Palette {
340            page: Color32::from_rgb(1, 1, 1),
341            raised: Color32::from_rgb(2, 2, 2),
342            overlay: Color32::from_rgb(3, 3, 3),
343            well: Color32::from_rgb(4, 4, 4),
344            sunken: Color32::from_rgb(5, 5, 5),
345            bevel_light: Color32::WHITE,
346            bevel_dark: Color32::BLACK,
347            elevation: Color32::from_black_alpha(46),
348            content: Color32::from_rgb(6, 6, 6),
349            content_muted: Color32::from_rgb(7, 7, 7),
350            action: Color32::from_rgb(8, 8, 8),
351            danger: Color32::from_rgb(9, 9, 9),
352        }
353    }
354
355    fn columns() -> Vec<Column<'static>> {
356        vec![
357            Column {
358                name: "name",
359                width: Width::Fill,
360                priority: Priority::Essential,
361                sortable: true,
362                sorted: Some(Sort::Ascending),
363            },
364            Column {
365                name: "size",
366                width: Width::Fixed,
367                priority: Priority::Secondary,
368                sortable: true,
369                sorted: None,
370            },
371            Column {
372                name: "note",
373                width: Width::Content,
374                priority: Priority::Optional,
375                sortable: false,
376                sorted: None,
377            },
378        ]
379    }
380
381    fn sizing() -> Sizing<'static> {
382        Sizing {
383            lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
384            fallback: 40.0,
385        }
386    }
387
388    #[test]
389    fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
390        let (cols, sz) = (columns(), sizing());
391        assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
392        assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
393        assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential);
394        // Narrower than the essential column, which stays anyway.
395        assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
396    }
397
398    #[test]
399    fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
400        // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
401        // table hides whatever lands at position five, so inserting a column
402        // moves the cut onto a different column with nothing edited.
403        //
404        // Asserted at a fixed cutoff, because that is where the two ways of
405        // addressing a column disagree. A narrower budget SHOULD drop more; what
406        // must not change is which ones, for a given cutoff.
407        let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
408            cols.iter()
409                .filter(|c| !c.kept_at(cutoff))
410                .map(|c| c.name.to_owned())
411                .collect()
412        };
413        let before = columns();
414        let mut after = vec![Column {
415            name: "mark",
416            width: Width::Fixed,
417            priority: Priority::Essential,
418            sortable: false,
419            sorted: None,
420        }];
421        after.extend(columns());
422
423        for cutoff in CUTOFFS {
424            assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
425        }
426        assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
427    }
428
429    #[test]
430    fn the_two_renderers_narrow_a_description_the_same_way() {
431        // The cutoff ladder is duplicated in `makeover-tui` because neither
432        // crate depends on the other, and duplication is what drifts. This is
433        // the assertion that would catch it: the ladder is the description's
434        // order, weakest first, and a tier added upstream belongs in both.
435        assert_eq!(CUTOFFS.len(), 3);
436        assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
437        assert_eq!(CUTOFFS[0], Priority::Optional);
438        assert_eq!(CUTOFFS[2], Priority::Essential);
439    }
440
441    #[test]
442    fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
443        // The split the module header names. The track defers to egui_extras,
444        // which can measure; the narrowing cannot wait for that and uses the
445        // declared floor. Both readings of the same column, and both honest.
446        let cols = columns();
447        let sz = sizing();
448        let note = &cols[2];
449        assert!(matches!(note.width, Width::Content));
450        assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON);
451        // 120 + 60 + 80 is 260, so 300 fits and 250 does not.
452        assert!(fits(&cols, &sz, Priority::Optional, 300.0));
453        assert!(!fits(&cols, &sz, Priority::Optional, 250.0));
454    }
455
456    #[test]
457    fn a_column_with_no_length_of_its_own_takes_the_fallback() {
458        let column = Column {
459            name: "unlisted",
460            width: Width::Fixed,
461            priority: Priority::Essential,
462            sortable: false,
463            sorted: None,
464        };
465        assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON);
466    }
467
468    #[test]
469    fn the_parts_a_cell_can_be_are_coloured_apart() {
470        // The drift `CellPart` exists to end: one colour for a whole cell paints
471        // a control as though it were text.
472        let p = palette();
473        assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
474        assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
475        assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
476        assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
477        assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
478        // A cell mixing parts says nothing, and takes the text colour.
479        assert_eq!(part_color(None, &p), p.content);
480    }
481
482    #[test]
483    fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
484        let style = TableStyle::default();
485        let cols = columns();
486        assert_eq!(heading(&cols[0], &style), "name \u{25B2}");
487        assert_eq!(heading(&cols[1], &style), "size");
488        assert_eq!(heading(&cols[2], &style), "note");
489    }
490
491    #[test]
492    fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
493        // A list ordered by a key the user cannot change is a real thing to
494        // describe, which is why the description holds the two fields apart.
495        let column = Column {
496            name: "rank",
497            width: Width::Content,
498            priority: Priority::Essential,
499            sortable: false,
500            sorted: Some(Sort::Descending),
501        };
502        assert_eq!(heading(&column, &TableStyle::default()), "rank \u{25BC}");
503    }
504
505    #[test]
506    fn the_carets_match_the_terminal_renderers() {
507        // Two crates, one glyph pair, and no dependency between them to enforce
508        // it. A description sorted ascending must not point up in a window and
509        // down in a terminal.
510        let style = TableStyle::default();
511        assert_eq!(style.ascending, " \u{25B2}");
512        assert_eq!(style.descending, " \u{25BC}");
513    }
514
515    #[test]
516    fn striping_is_off_because_the_description_has_no_word_for_it() {
517        // egui_extras offers it and the other two renderers cannot say it. A
518        // default that turned it on would be this renderer adding a claim.
519        assert!(!TableStyle::default().striped);
520    }
521}