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 every column at its
34//!   declared [`floor`](Column::floor), in `ch` of the body face. A column that
35//!   turns out wider than its floor is still drawn; it is the *decision to drop*
36//!   that uses the declared number, and it is the number the terminal counts in
37//!   cells and the webview hides a column under, so the three drop together.
38//!
39//! # The table model
40//!
41//! Wiki `table-model`, drawn the way `makeover-webview` draws it, since egui
42//! paints the same pixels a browser does: a raised ground inside a hairline
43//! frame, the header a sunken strip over a `bevel-dark` edge in secondary
44//! capitals, rows 45 points tall with a hairline between them, a stripe on
45//! every second row and a tone under the pointer. A table holding a
46//! [`ColumnKind::Code`] column keeps the frame and the header and drops the
47//! stripe, the hairlines and the tall row.
48//!
49//! The row fills are painted here rather than by egui_extras, for two reasons
50//! the crate cannot be configured past. Its stripe falls on the first row where
51//! the model's falls on the second, and its selection repaints the row's text
52//! in the selection stroke, which loses a failed row's red on exactly the row
53//! the user picked. A selected row takes `row-selected` behind its text and
54//! nothing else.
55//!
56//! # Why positions are the bug
57//!
58//! Carried from the other two renderers, because the mistake is not a CSS
59//! mistake and not a terminal one. goingson hides its mobile columns with
60//! `nth-child(n+5)` against a seven-column table; insert a column left of the
61//! cut and the wrong one disappears, silently. A renderer narrows by raising a
62//! cutoff and never by counting.
63
64use crate::Palette;
65use egui::{Response, RichText, Sense, Ui};
66use egui_extras::{Column as Track, TableBuilder};
67use makeover_layout::{CellPart, Column, ColumnKind, Priority, Sort, Width};
68
69/// The cutoffs, weakest first.
70///
71/// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added
72/// here in its place in the sequence, or a table will never narrow to it. Grep
73/// this when adopting a new `makeover-layout`; `makeover-tui` carries the same
74/// list for the same reason, and the two have to agree or a description narrows
75/// differently in a window than in a terminal.
76const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential];
77
78/// The lengths the description deferred, in points.
79///
80/// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because
81/// a magnitude is an answer for one renderer and the description is read by
82/// three. The other two renderers hold this same type over CSS lengths and over
83/// terminal cells.
84#[derive(Debug, Clone, Copy, Default)]
85pub struct Sizing<'a> {
86    /// `(column name, points)`. The track for a [`Width::Fixed`] column, the
87    /// floor for a [`Width::Fill`] one, and the narrowing budget for a
88    /// [`Width::Content`] one.
89    pub lengths: &'a [(&'a str, f32)],
90    /// Used for a column with no entry above.
91    pub fallback: f32,
92}
93
94impl Sizing<'_> {
95    /// The length for a named column.
96    fn length_for(&self, name: &str) -> f32 {
97        self.lengths
98            .iter()
99            .find(|(column, _)| *column == name)
100            .map_or(self.fallback, |(_, length)| *length)
101    }
102}
103
104/// The tones and metrics a table draws with.
105///
106/// Metrics only, and the tones come from [`Palette`]. That is the division this
107/// crate already draws: [`FieldStyle`](crate::FieldStyle) carries gaps and a
108/// marker while the colours stay in the palette, and a table's colours are the
109/// palette's `content`, `content_muted` and `action` rather than six new ones.
110/// `makeover-tui` splits it the other way round because its palette carries no
111/// text tones at all.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct TableStyle {
114    /// The height of the heading row.
115    pub header_height: f32,
116    /// The height of a body row: the model's 45, `--row-block` at
117    /// `makeover-geometry`'s base.
118    pub row_height: f32,
119    /// The height of a row in a table holding code, which is one line of
120    /// source rather than a record.
121    pub code_row_height: f32,
122    /// The ground between the table's frame and the cells at either end of a
123    /// row: the webview's `gap-group`, which pads both ends of a row.
124    pub edge_padding: f32,
125    /// The caret drawn after the heading of an ascending column.
126    ///
127    /// Defaults to [`Sort::glyph`], which is where the spelling lives now:
128    /// three renderers holding the same literal agreed by coincidence. Bare,
129    /// with no leading space -- the gap is [`heading`]'s, written once for all
130    /// three states rather than baked into two strings and forgotten in the
131    /// third.
132    pub ascending: &'static str,
133    /// Drawn after the heading of a descending column.
134    pub descending: &'static str,
135    /// Whether the user can drag the divider between two columns.
136    ///
137    /// The one knob that is not a metric. Not every setting egui_extras has
138    /// becomes a field here: a sticky heading is what `TableBuilder::header`
139    /// does and there is no version that does not, and the stripe is the table
140    /// model's at every host, so a knob for either would offer a choice this
141    /// renderer cannot make. Off by default: the description has no word for
142    /// resizing, so a default that turned it on would be this renderer adding a
143    /// claim the other two cannot make.
144    ///
145    /// It does not fight the narrowing. A drag moves a track for the frames it
146    /// is held; [`cutoff_for`] still decides which columns exist, off each
147    /// column's declared [`floor`](Column::floor), so a resize can never drop a
148    /// column.
149    pub resizable: bool,
150}
151
152impl Default for TableStyle {
153    fn default() -> Self {
154        Self {
155            header_height: 32.0,
156            row_height: 45.0,
157            code_row_height: 20.0,
158            edge_padding: 12.0,
159            ascending: Sort::Ascending.glyph(),
160            descending: Sort::Descending.glyph(),
161            resizable: false,
162        }
163    }
164}
165
166/// The body's own facts for this frame: how many rows, which are selected, and
167/// which one to bring into view.
168///
169/// Held apart from [`TableStyle`] because none of it is style and none of it
170/// survives the frame: a row count changes when a folder does, a selection when
171/// the user clicks, and a scroll request exists for exactly one frame. Held
172/// apart from the [`Column`] slice because none of it is description either.
173/// The description says what a table *is*, and this says what it holds right
174/// now.
175///
176/// Both of the optional fields are here rather than left to the app because
177/// egui_extras answers them on a handle the app never sees: `set_selected` is a
178/// method on the row, and `scroll_to_row` a method on the builder, and this
179/// crate owns both. That is the same reason [`cell`] exists.
180#[derive(Default)]
181pub struct Body<'a> {
182    /// How many rows to draw.
183    pub rows: usize,
184    /// Whether a row is selected, by index.
185    ///
186    /// A predicate rather than a set, so an app whose selection is a range, a
187    /// bitmap or a single index does not have to build a collection to be asked.
188    /// `None` is a table no row of which is selected, which is not the same
189    /// claim as a predicate that always answers false and costs nothing to make.
190    pub selected: Option<&'a dyn Fn(usize) -> bool>,
191    /// A row to bring into view this frame.
192    ///
193    /// Set it from a request the app then clears, the way a keyboard cursor
194    /// moving off-screen raises one: held rather than taken, it would fight
195    /// every scroll the user makes with the mouse.
196    pub scroll_to: Option<usize>,
197}
198
199impl std::fmt::Debug for Body<'_> {
200    // Hand-written because `selected` is a closure and `#[derive(Debug)]` will
201    // not have it. What is worth printing is whether one was supplied.
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        f.debug_struct("Body")
204            .field("rows", &self.rows)
205            .field("selected", &self.selected.is_some())
206            .field("scroll_to", &self.scroll_to)
207            .finish()
208    }
209}
210
211/// The colour a cell of this part takes.
212///
213/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
214/// `content`: a part this renderer has not learned draws as text, which is a
215/// cell rendering plainly rather than a build that stops. Grep this when
216/// adopting a new `makeover-layout`.
217#[must_use]
218pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
219    match part {
220        // A token paints its own background and carries its own tone. What is
221        // set here is what shows between them, not what paints them.
222        Some(CellPart::Tokens) => palette.content_muted,
223        // The drift `CellPart` exists to end: a control in a cell inheriting the
224        // cell's text colour. Both of these take the action intent instead.
225        Some(CellPart::Actions | CellPart::Link) => palette.action,
226        _ => palette.content,
227    }
228}
229
230/// Draw a cell's contents with the tone its part takes.
231///
232/// The app calls this inside its own cell closure, wrapping whatever it draws.
233/// A scoping function rather than a parameter on [`table`], for the reason
234/// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
235/// does not exist until the closure runs, and immediate mode has no cascade to
236/// carry the answer down on its own. This is the cascade, for one scope.
237///
238/// ```no_run
239/// # use makeover_layout::CellPart;
240/// # let palette: makeover_immediate::Palette = unimplemented!();
241/// # let ui: &mut egui::Ui = unimplemented!();
242/// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
243///     ui.label("opens the item");
244/// });
245/// ```
246pub fn cell<R>(
247    ui: &mut Ui,
248    part: Option<CellPart>,
249    palette: &Palette,
250    add_contents: impl FnOnce(&mut Ui) -> R,
251) -> R {
252    let restore = ui.visuals().override_text_color;
253    ui.visuals_mut().override_text_color = Some(part_color(part, palette));
254    let out = add_contents(ui);
255    ui.visuals_mut().override_text_color = restore;
256    out
257}
258
259/// The heading, in capitals, with the caret if the table is ordered by this
260/// column.
261///
262/// Capitals because the header strip's label is set that way at every host.
263/// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
264/// still gets its caret. Both combinations mean something, which is why the
265/// description holds the two fields apart: a list ordered by a key the user
266/// cannot change is a real thing, and the caret is how it says so.
267#[must_use]
268pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
269    let caret = match column.sorted {
270        Some(Sort::Ascending) => style.ascending,
271        Some(Sort::Descending) => style.descending,
272        // Sortable and not sorted draws the idle mark, in the ascending
273        // spelling because that is the direction a first press takes. What
274        // separates it from the column in force is the tone, which is
275        // [`press`]'s to pick.
276        None if column.sortable => style.ascending,
277        None => return column.name.to_uppercase(),
278    };
279    format!("{} {caret}", column.name.to_uppercase())
280}
281
282/// Whether the columns kept at `cutoff` fit in `width`.
283///
284/// Budgeted at each column's declared [`floor`](Column::floor) in `ch`, turned
285/// into points by `ch`, the width of one figure in the face the table draws
286/// in. Nothing can be measured before the app's closure has drawn a cell, and
287/// nothing needs to be: the floor is the number the terminal counts in cells
288/// and the webview hides a column under, so all three drop at one declared
289/// width.
290fn fits(columns: &[Column<'_>], ch: f32, edge: f32, cutoff: Priority, width: f32) -> bool {
291    columns
292        .iter()
293        .filter(|c| c.kept_at(cutoff))
294        .map(|c| f32::from(c.floor()) * ch + 2.0 * edge)
295        .sum::<f32>()
296        <= width
297}
298
299/// The weakest cutoff whose columns fit in `width`.
300///
301/// Raised until the layout fits, and never past [`Priority::Essential`]: the
302/// essential columns are what makes a row identify itself, so a window too
303/// narrow for them gets them squeezed rather than dropped. Nothing here counts
304/// positions, so which column drops is a property of the column.
305#[must_use]
306pub fn cutoff_for(columns: &[Column<'_>], ch: f32, edge: f32, width: f32) -> Priority {
307    for cutoff in CUTOFFS {
308        if fits(columns, ch, edge, cutoff, width) {
309            return cutoff;
310        }
311    }
312    Priority::Essential
313}
314
315/// The track for one column.
316fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
317    match column.width {
318        // The one place immediate mode beats the terminal: egui_extras measures
319        // this and remembers it between frames, where `makeover-tui` has to walk
320        // the cells itself.
321        Width::Content => Track::auto(),
322        Width::Fixed => Track::exact(sizing.length_for(column.name)),
323        // Includes a width added to the description since this renderer was
324        // built. Taking the slack above a floor is the behaviour that makes no
325        // claim, which is the same fallback the webview renderer's `auto` track
326        // is chosen to be.
327        _ => Track::remainder().at_least(sizing.length_for(column.name)),
328    }
329}
330
331/// A described table, narrowed for the width available.
332///
333/// `draw` is called once per cell of each kept column, in column order, for each
334/// of [`Body::rows`] rows. Taking a closure rather than a slice of contents is
335/// what keeps the app's own data borrowed one cell at a time, which is
336/// [`group`](crate::group)'s reasoning and immediate mode's habit.
337///
338/// `body` is borrowed immutably and `draw` is `FnMut`, which is the split a
339/// caller has to plan for: a selection read by [`Body::selected`] cannot be the
340/// same value `draw` mutates. Snapshot it before the call. That is not this
341/// crate imposing anything. It is the borrow the app already takes when it
342/// clones its row list to hand egui a closure.
343///
344/// Returns the sortable column whose heading was pressed this frame, if any. The
345/// app owns the ordering, so this reports the press and changes nothing: what a
346/// press *calls* is an address, and the description names none. That is
347/// [`Column::sortable`]'s own documented split.
348///
349/// A heading is only pressable when its column says
350/// [`sortable`](Column::sortable). A column sorted by a key the user cannot
351/// change still draws its caret and does not answer.
352pub fn table<'a>(
353    ui: &mut Ui,
354    columns: &'a [Column<'a>],
355    body: &Body<'_>,
356    sizing: &Sizing<'_>,
357    palette: &Palette,
358    style: &TableStyle,
359    mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
360) -> Option<&'a Column<'a>> {
361    // One `ch` in the face the cells are drawn in, which is what a declared
362    // floor counts.
363    let ch = {
364        let font = egui::TextStyle::Body.resolve(ui.style());
365        ui.fonts_mut(|fonts| fonts.glyph_width(&font, '0'))
366    };
367    let cutoff = cutoff_for(columns, ch, style.edge_padding, ui.available_width());
368    let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
369
370    // egui_extras panics on a table with no tracks, and a description whose
371    // every column dropped is reachable: `kept_at` keeps the essential ones, and
372    // a table described with none at all has nothing to keep.
373    if kept.is_empty() {
374        return None;
375    }
376
377    let code = kept.iter().any(|column| column.kind == ColumnKind::Code);
378    let row_height = if code {
379        style.code_row_height
380    } else {
381        style.row_height
382    };
383    // The whole width the table takes, which is the frame's: a cell only knows
384    // its own track. The fills stop a stroke's width inside it, so no row
385    // paints over the frame.
386    let outer = ui.available_rect_before_wrap();
387    let across = outer.x_range();
388    let fills = egui::Rangef::new(across.min + 1.0, across.max - 1.0);
389    let top = ui.cursor().top();
390    // Reserved before the table draws, so the ground lands under it once the
391    // table's height is known.
392    let ground = ui.painter().add(egui::Shape::Noop);
393    // How far down the header and the drawn rows reach, which is the frame's
394    // bottom. Not the scroll area's rect, which is the room the table was
395    // offered rather than the room it took.
396    let reach = std::cell::Cell::new(top);
397    let inner = outer.shrink2(egui::vec2(style.edge_padding, 0.0));
398    // Written through a Cell rather than returned, because egui_extras hands the
399    // header and the body their own closures and neither can return a value past
400    // the other.
401    let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
402
403    let viewport = ui
404        .scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
405            let mut builder = TableBuilder::new(ui)
406                .striped(false)
407                .resizable(style.resizable)
408                // Not a knob, because there is no second honest answer: a cell's
409                // contents sit on the row's centre line. CSS says `vertical-align:
410                // middle` and a terminal row is one line tall, so a field offering the
411                // choice would be offering one only this renderer could take. egui's own
412                // default is top-aligned, which is why it has to be said at all.
413                .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
414            for column in &kept {
415                builder = builder.column(track(column, sizing));
416            }
417            if let Some(row) = body.scroll_to {
418                builder = builder.scroll_to_row(row, None);
419            }
420
421            builder
422                .header(style.header_height, |mut header| {
423                    for (at, column) in kept.iter().enumerate() {
424                        header.col(|ui| {
425                            if at == 0 {
426                                strip(ui, fills, &reach, palette);
427                            }
428                            placed(ui, column, |ui| {
429                                if press(ui, column, palette, style) {
430                                    pressed.set(Some(column));
431                                }
432                            });
433                        });
434                    }
435                })
436                .body(|table_body| {
437                    table_body.rows(row_height, body.rows, |mut row| {
438                        let index = row.index();
439                        let selected = body.selected.is_some_and(|selected| selected(index));
440                        for (at, column) in kept.iter().enumerate() {
441                            row.col(|ui| {
442                                // In the first cell and across the whole row, before
443                                // any cell's contents: a selection marks the row, and
444                                // a fill per cell would leave the gaps unpainted.
445                                if at == 0 {
446                                    let row = Row {
447                                        index,
448                                        selected,
449                                        code,
450                                    };
451                                    ground_row(ui, fills, row, &reach, palette);
452                                }
453                                placed(ui, column, |ui| draw(ui, column, index));
454                            });
455                        }
456                    });
457                })
458                .inner_rect
459        })
460        .inner;
461
462    let bottom = reach.get().min(viewport.bottom());
463    let frame = egui::Rect::from_x_y_ranges(across, top..=bottom);
464    ui.painter().set(
465        ground,
466        egui::epaint::RectShape::filled(frame, 0, palette.raised),
467    );
468    ui.painter().rect_stroke(
469        frame,
470        0,
471        egui::Stroke::new(1.0, palette.row_rule),
472        egui::StrokeKind::Inside,
473    );
474
475    pressed.get()
476}
477
478/// What decides one body row's fill.
479#[derive(Clone, Copy)]
480struct Row {
481    index: usize,
482    selected: bool,
483    code: bool,
484}
485
486/// The rect a row's fill covers: the whole table's width, and the cell's
487/// height with the half of the spacing either side that egui_extras leaves
488/// between rows.
489fn row_rect(ui: &Ui, across: egui::Rangef) -> egui::Rect {
490    let half = 0.5 * ui.spacing().item_spacing.y;
491    let cell = ui.max_rect();
492    egui::Rect::from_x_y_ranges(across, (cell.top() - half)..=(cell.bottom() + half))
493}
494
495/// The header strip: sunken, with the `bevel-dark` edge under it.
496fn strip(ui: &Ui, across: egui::Rangef, reach: &std::cell::Cell<f32>, palette: &Palette) {
497    let rect = row_rect(ui, across);
498    reach.set(reach.get().max(rect.bottom()));
499    ui.painter().rect_filled(rect, 0, palette.sunken);
500    ui.painter().hline(
501        across,
502        rect.bottom(),
503        egui::Stroke::new(1.0, palette.bevel_dark),
504    );
505}
506
507/// One body row's fill and the hairline above it.
508///
509/// Selected, then hovered, then the stripe on every second row, and the first
510/// of those that holds is the fill. A table holding code takes the selection
511/// and the hover and neither the stripe nor the hairline, which break the
512/// reading of source one line to a row.
513fn ground_row(
514    ui: &Ui,
515    across: egui::Rangef,
516    Row {
517        index,
518        selected,
519        code,
520    }: Row,
521    reach: &std::cell::Cell<f32>,
522    palette: &Palette,
523) {
524    let rect = row_rect(ui, across);
525    reach.set(reach.get().max(rect.bottom()));
526    let fill = if selected {
527        Some(palette.row_selected)
528    } else if ui.rect_contains_pointer(rect) {
529        Some(palette.row_hover)
530    } else if !code && index % 2 == 1 {
531        Some(palette.row_stripe)
532    } else {
533        None
534    };
535    if let Some(fill) = fill {
536        ui.painter().rect_filled(rect, 0, fill);
537    }
538    if !code && index > 0 {
539        ui.painter()
540            .hline(across, rect.top(), egui::Stroke::new(1.0, palette.row_rule));
541    }
542}
543
544/// A cell laid out the way its column's kind says, wiki `table-model`.
545///
546/// Alignment is the kind fact this renderer acts on. A number or actions
547/// column runs right to left, on the row's centre line like every cell, and
548/// its heading does the same so the label sits over its figures. The face and
549/// the figures a kind names are the caller's, who draws the cell's contents.
550fn placed(ui: &mut Ui, column: &Column<'_>, add: impl FnOnce(&mut Ui)) {
551    if column.kind.aligns_end() {
552        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), add);
553    } else {
554        add(ui);
555    }
556}
557
558/// What a heading's caret is drawn in.
559///
560/// Three states (wiki `three-tone-convention`), carried by the caret as the
561/// webview carries them: the column in force takes content, a column offering
562/// to reorder takes secondary, and a column that is not a control draws no
563/// caret. The label is the strip's own secondary ink in every state.
564///
565/// The offering state may not take `content_muted`, which is what
566/// [`State::Disabled`](makeover_layout::State::Disabled) resolves to: a heading
567/// the user can press would be claiming it will not answer.
568fn caret_color(column: &Column<'_>, palette: &Palette) -> egui::Color32 {
569    match column.sorted {
570        Some(_) => palette.content,
571        None => palette.content_secondary,
572    }
573}
574
575/// One heading, and whether it was pressed.
576fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
577    // The strip's type: small, strong and tracked, as the webview sets it, with
578    // the caret in its own run so it can take its own tone.
579    let size = egui::TextStyle::Small.resolve(ui.style()).size;
580    let heading = heading(column, style);
581    let label_len = column.name.to_uppercase().len();
582    let mut job = egui::text::LayoutJob::default();
583    RichText::new(&heading[..label_len])
584        .color(palette.content_secondary)
585        .small()
586        .strong()
587        .extra_letter_spacing(0.06 * size)
588        .append_to(
589            &mut job,
590            ui.style(),
591            egui::FontSelection::Default,
592            egui::Align::Center,
593        );
594    if heading.len() > label_len {
595        RichText::new(&heading[label_len..])
596            .color(caret_color(column, palette))
597            .small()
598            .append_to(
599                &mut job,
600                ui.style(),
601                egui::FontSelection::Default,
602                egui::Align::Center,
603            );
604    }
605    let text = job;
606    if !column.sortable {
607        // Not sensed. A heading a user cannot press must not look like one they
608        // can, which is the affordance `Column::sortable` exists to carry, and
609        // the missing caret is half of saying so.
610        ui.label(text);
611        return false;
612    }
613    let response: Response = ui
614        .add(egui::Label::new(text).sense(Sense::click()))
615        .on_hover_cursor(egui::CursorIcon::PointingHand);
616    // Announced as the control it is, rather than as the `Label` it is drawn
617    // with. egui maps a `Label` to `Role::Label` whatever it senses, so until
618    // 2026-08-22 a screen reader was told this was static text and a user who
619    // could not see the pointer change had no way to know the table sorts.
620    // The same argument the comment above makes about affordance, made about
621    // the half of the interface that is not pixels.
622    //
623    // The name is the column's own, not `heading`'s: the caret is a rendering of
624    // `Column::sorted`, and reading a triangle aloud after every heading is
625    // noise. Which column is in force is a fact a client should get from the
626    // sort state, and egui has nowhere to put that yet -- worth revisiting if it
627    // grows a sort field on `WidgetInfo`.
628    response.widget_info(|| {
629        egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), column.name)
630    });
631    response.clicked()
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637    use makeover_layout::ColumnKind;
638
639    /// What the accessibility tree says a heading row drew.
640    ///
641    /// egui builds it from the `WidgetInfo` each widget reports, so this is
642    /// what a screen reader would be handed rather than a second opinion.
643    fn announced(draw: impl FnMut(&mut Ui)) -> Vec<(egui::accesskit::Role, String)> {
644        let ctx = egui::Context::default();
645        ctx.enable_accesskit();
646        let mut draw = draw;
647        let input = || egui::RawInput {
648            screen_rect: Some(egui::Rect::from_min_size(
649                egui::Pos2::ZERO,
650                egui::vec2(800.0, 600.0),
651            )),
652            ..Default::default()
653        };
654        let _ = ctx.run_ui(input(), &mut draw);
655        let out = ctx.run_ui(input(), &mut draw);
656        out.platform_output
657            .accesskit_update
658            .expect("accesskit is on")
659            .nodes
660            .iter()
661            .map(|(_, node)| {
662                (
663                    node.role(),
664                    node.label()
665                        .or_else(|| node.value())
666                        .unwrap_or_default()
667                        .to_owned(),
668                )
669            })
670            .collect()
671    }
672
673    #[test]
674    fn a_sortable_heading_is_announced_as_something_you_press() {
675        let column = Column {
676            name: "Name",
677            width: Width::Fill,
678            priority: Priority::Essential,
679            kind: ColumnKind::Text,
680            min: None,
681            sortable: true,
682            sorted: Some(Sort::Ascending),
683        };
684        let p = palette();
685        let drawn = announced(|ui| {
686            press(ui, &column, &p, &TableStyle::default());
687        });
688
689        // The name is the column's, with no caret in it: the glyph renders
690        // `Column::sorted` and is not part of what the control is called.
691        assert!(
692            drawn
693                .iter()
694                .any(|(role, name)| *role == egui::accesskit::Role::Button && name == "Name"),
695            "{drawn:?}"
696        );
697    }
698
699    #[test]
700    fn a_heading_that_is_not_a_control_is_not_announced_as_one() {
701        let column = Column {
702            name: "Tags",
703            width: Width::Fixed,
704            priority: Priority::Optional,
705            kind: ColumnKind::Text,
706            min: None,
707            sortable: false,
708            sorted: None,
709        };
710        let p = palette();
711        let drawn = announced(|ui| {
712            press(ui, &column, &p, &TableStyle::default());
713        });
714
715        assert!(
716            !drawn
717                .iter()
718                .any(|(role, _)| *role == egui::accesskit::Role::Button),
719            "a heading with no sort answers nothing and must not claim to: {drawn:?}"
720        );
721    }
722    use egui::Color32;
723
724    fn palette() -> Palette {
725        Palette {
726            page: Color32::from_rgb(1, 1, 1),
727            raised: Color32::from_rgb(2, 2, 2),
728            overlay: Color32::from_rgb(3, 3, 3),
729            well: Color32::from_rgb(4, 4, 4),
730            sunken: Color32::from_rgb(5, 5, 5),
731            bevel_light: Color32::WHITE,
732            bevel_dark: Color32::BLACK,
733            elevation: Color32::from_black_alpha(46),
734            content: Color32::from_rgb(6, 6, 6),
735            content_secondary: Color32::from_rgb(56, 56, 56),
736            content_muted: Color32::from_rgb(7, 7, 7),
737            action: Color32::from_rgb(8, 8, 8),
738            danger: Color32::from_rgb(9, 9, 9),
739            success: Color32::from_rgb(10, 10, 10),
740            warning: Color32::from_rgb(11, 11, 11),
741            info: Color32::from_rgb(12, 12, 12),
742            border: Color32::from_rgb(200, 200, 200),
743            info_surface: Color32::from_rgb(201, 201, 201),
744            success_surface: Color32::from_rgb(202, 202, 202),
745            warning_surface: Color32::from_rgb(203, 203, 203),
746            danger_surface: Color32::from_rgb(204, 204, 204),
747            row_stripe: Color32::from_rgb(205, 205, 205),
748            row_hover: Color32::from_rgb(206, 206, 206),
749            row_rule: Color32::from_rgb(207, 207, 207),
750            row_selected: Color32::from_rgb(208, 208, 208),
751        }
752    }
753
754    fn columns() -> Vec<Column<'static>> {
755        vec![
756            Column {
757                name: "name",
758                width: Width::Fill,
759                priority: Priority::Essential,
760                kind: ColumnKind::Text,
761                min: None,
762                sortable: true,
763                sorted: Some(Sort::Ascending),
764            },
765            Column {
766                name: "size",
767                width: Width::Fixed,
768                priority: Priority::Secondary,
769                kind: ColumnKind::Text,
770                min: None,
771                sortable: true,
772                sorted: None,
773            },
774            Column {
775                name: "note",
776                width: Width::Content,
777                priority: Priority::Optional,
778                kind: ColumnKind::Text,
779                min: None,
780                sortable: false,
781                sorted: None,
782            },
783        ]
784    }
785
786    fn sizing() -> Sizing<'static> {
787        Sizing {
788            lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
789            fallback: 40.0,
790        }
791    }
792
793    #[test]
794    fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
795        // Floors of 16ch (the name fills), 8 and 8, at 10 points a ch, and 12
796        // points of edge either side of each: 184, 104 and 104.
797        let cols = columns();
798        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 392.0), Priority::Optional);
799        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 391.0), Priority::Secondary);
800        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 288.0), Priority::Secondary);
801        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 287.0), Priority::Essential);
802        // Narrower than the essential column, which stays anyway.
803        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 10.0), Priority::Essential);
804    }
805
806    #[test]
807    fn the_same_floors_drop_at_the_same_ch_count_as_a_terminal() {
808        // A terminal counts one cell a ch. At one point a ch and no edge, the
809        // cut falls on the floors alone, which is the number both other hosts
810        // read, so a description narrows at one declared width everywhere.
811        let cols = columns();
812        assert_eq!(cutoff_for(&cols, 1.0, 0.0, 32.0), Priority::Optional);
813        assert_eq!(cutoff_for(&cols, 1.0, 0.0, 31.0), Priority::Secondary);
814        assert_eq!(cutoff_for(&cols, 1.0, 0.0, 24.0), Priority::Secondary);
815        assert_eq!(cutoff_for(&cols, 1.0, 0.0, 23.0), Priority::Essential);
816    }
817
818    #[test]
819    fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
820        // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
821        // table hides whatever lands at position five, so inserting a column
822        // moves the cut onto a different column with nothing edited.
823        //
824        // Asserted at a fixed cutoff, because that is where the two ways of
825        // addressing a column disagree. A narrower budget SHOULD drop more; what
826        // must not change is which ones, for a given cutoff.
827        let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
828            cols.iter()
829                .filter(|c| !c.kept_at(cutoff))
830                .map(|c| c.name.to_owned())
831                .collect()
832        };
833        let before = columns();
834        let mut after = vec![Column {
835            name: "mark",
836            width: Width::Fixed,
837            priority: Priority::Essential,
838            kind: ColumnKind::Text,
839            min: None,
840            sortable: false,
841            sorted: None,
842        }];
843        after.extend(columns());
844
845        for cutoff in CUTOFFS {
846            assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
847        }
848        assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
849    }
850
851    #[test]
852    fn the_two_renderers_narrow_a_description_the_same_way() {
853        // The cutoff ladder is duplicated in `makeover-tui` because neither
854        // crate depends on the other, and duplication is what drifts. This is
855        // the assertion that would catch it: the ladder is the description's
856        // order, weakest first, and a tier added upstream belongs in both.
857        assert_eq!(CUTOFFS.len(), 3);
858        assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
859        assert_eq!(CUTOFFS[0], Priority::Optional);
860        assert_eq!(CUTOFFS[2], Priority::Essential);
861    }
862
863    #[test]
864    fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
865        // The split the module header names. The track defers to egui_extras,
866        // which can measure; the narrowing cannot wait for that and uses the
867        // declared floor. Both readings of the same column, and both honest.
868        let cols = columns();
869        let note = &cols[2];
870        assert!(matches!(note.width, Width::Content));
871        assert_eq!(note.floor(), 8, "undeclared, so the kind's floor");
872        // Floors of 16, 8 and 8ch at 10 points, each with 12 points of edge
873        // either side: 392 fits and 391 does not, whatever the cells turn out to
874        // hold.
875        assert!(fits(&cols, 10.0, 12.0, Priority::Optional, 392.0));
876        assert!(!fits(&cols, 10.0, 12.0, Priority::Optional, 391.0));
877    }
878
879    #[test]
880    fn a_column_with_no_length_of_its_own_takes_the_fallback() {
881        let column = Column {
882            name: "unlisted",
883            width: Width::Fixed,
884            priority: Priority::Essential,
885            kind: ColumnKind::Text,
886            min: None,
887            sortable: false,
888            sorted: None,
889        };
890        // The fallback sizes a track and nothing else: which columns exist is
891        // the floor's to decide.
892        assert!((sizing().length_for(column.name) - 40.0).abs() < f32::EPSILON);
893    }
894
895    #[test]
896    fn the_parts_a_cell_can_be_are_coloured_apart() {
897        // The drift `CellPart` exists to end: one colour for a whole cell paints
898        // a control as though it were text.
899        let p = palette();
900        assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
901        assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
902        assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
903        assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
904        assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
905        // A cell mixing parts says nothing, and takes the text colour.
906        assert_eq!(part_color(None, &p), p.content);
907    }
908
909    #[test]
910    fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() {
911        let style = TableStyle::default();
912        let cols = columns();
913        assert_eq!(heading(&cols[0], &style), "NAME \u{25B2}");
914        // Sortable and idle. It draws the mark a first press would give, which
915        // is what stops the press from widening the column and shifting the
916        // ones after it.
917        assert_eq!(heading(&cols[1], &style), "SIZE \u{25B2}");
918        // Not a control. Nothing to mark.
919        assert_eq!(heading(&cols[2], &style), "NOTE");
920    }
921
922    #[test]
923    fn the_three_states_of_a_heading_are_carried_by_its_caret() {
924        // wiki `three-tone-convention`, as the webview draws it: the caret in
925        // force takes content, the idle caret secondary, and a heading that is
926        // not a control has no caret. The offering state may not take
927        // content_muted, which is what `State::Disabled` resolves to.
928        let p = palette();
929        let cols = columns();
930        let style = TableStyle::default();
931        assert_eq!(caret_color(&cols[0], &p), p.content);
932        assert_eq!(caret_color(&cols[1], &p), p.content_secondary);
933        assert_ne!(caret_color(&cols[1], &p), p.content_muted);
934        assert!(
935            !heading(&cols[2], &style).contains(' '),
936            "an inert heading has no caret"
937        );
938    }
939
940    #[test]
941    fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
942        // A list ordered by a key the user cannot change is a real thing to
943        // describe, which is why the description holds the two fields apart.
944        let column = Column {
945            name: "rank",
946            width: Width::Content,
947            priority: Priority::Essential,
948            kind: ColumnKind::Text,
949            min: None,
950            sortable: false,
951            sorted: Some(Sort::Descending),
952        };
953        assert_eq!(heading(&column, &TableStyle::default()), "RANK \u{25BC}");
954    }
955
956    #[test]
957    fn the_carets_match_the_terminal_renderers() {
958        // Two crates, one glyph pair, and no dependency between them to enforce
959        // it. A description sorted ascending must not point up in a window and
960        // down in a terminal.
961        // Composition rather than agreement since makeover-layout 0.27.5: both
962        // read `Sort::glyph`, so a fourth spelling cannot appear in one crate.
963        let style = TableStyle::default();
964        assert_eq!(style.ascending, Sort::Ascending.glyph());
965        assert_eq!(style.descending, Sort::Descending.glyph());
966        // Bare. The gap is `heading`'s, so a consumer swapping the glyph for an
967        // ASCII one does not have to remember to bring a space with it.
968        assert_eq!(style.ascending.trim(), style.ascending);
969    }
970
971    #[test]
972    fn resizing_is_off_because_the_description_has_no_word_for_it() {
973        // egui_extras offers it and the other two renderers cannot say it. A
974        // default that turned it on would be this renderer adding a claim.
975        assert!(!TableStyle::default().resizable);
976    }
977
978    #[test]
979    fn a_record_row_is_the_models_45_and_a_code_row_is_one_line() {
980        let style = TableStyle::default();
981        assert!((style.row_height - 45.0).abs() < f32::EPSILON);
982        assert!(style.code_row_height < style.row_height);
983    }
984
985    #[test]
986    fn a_body_claims_nothing_until_it_is_asked_to() {
987        // The default is a table of no rows, no selection and no scroll
988        // request. All three absences are the honest reading of an app that has
989        // not said otherwise, which is why they are `Option` and not a
990        // predicate that always answers false.
991        let body = Body::default();
992        assert_eq!(body.rows, 0);
993        assert!(body.selected.is_none());
994        assert!(body.scroll_to.is_none());
995    }
996
997    #[test]
998    fn a_selection_is_asked_per_row_and_not_collected() {
999        // A predicate, so an app whose selection is a range or a single index
1000        // does not build a set to be asked. Exercised the way `table` asks it:
1001        // once per row index, in order.
1002        let selected = |index: usize| index.is_multiple_of(2);
1003        let body = Body {
1004            rows: 4,
1005            selected: Some(&selected),
1006            scroll_to: None,
1007        };
1008        let f = body.selected.expect("a predicate was supplied");
1009        assert_eq!(
1010            (0..body.rows).map(f).collect::<Vec<_>>(),
1011            vec![true, false, true, false]
1012        );
1013    }
1014
1015    #[test]
1016    fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() {
1017        // `resizable` lets the user move a divider, and `cutoff_for` must not
1018        // hear about it: a drag that could drop a column would make the
1019        // narrowing a thing the user does by accident rather than a property of
1020        // the description. That `cutoff_for` takes the columns and two numbers,
1021        // and no `TableStyle` or track state, is the structural half of the
1022        // guarantee; this is the behavioural half, and it is what would fail if a
1023        // measured width were ever threaded in beside the declared floor.
1024        let cols = columns();
1025        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 392.0), Priority::Optional);
1026        assert_eq!(cutoff_for(&cols, 10.0, 12.0, 288.0), Priority::Secondary);
1027    }
1028}