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 /// Not every setting egui_extras has becomes a field here. A sticky heading
112 /// is what `TableBuilder::header` does and there is no version that does
113 /// not, so the knob 0.12.0 briefly carried for it offered a choice this
114 /// renderer cannot make. This one and [`resizable`](Self::resizable) are the
115 /// two that pass that test.
116 pub striped: bool,
117 /// Whether the user can drag the divider between two columns.
118 ///
119 /// The second knob that is not a metric, and it passes the same test
120 /// `sticky_header` failed: egui_extras offers both settings and a renderer
121 /// can honestly make either choice. Off by default for `striped`'s reason:
122 /// the description has no word for it, so a default that turned it on would
123 /// be this renderer adding a claim the other two cannot make.
124 ///
125 /// It does not fight the narrowing. A drag moves a track for the frames it
126 /// is held; [`cutoff_for`] still decides which columns exist, off the widths
127 /// the app declared in [`Sizing`], so a resize can never drop a column.
128 pub resizable: bool,
129}
130
131impl Default for TableStyle {
132 fn default() -> Self {
133 Self {
134 header_height: 20.0,
135 row_height: 18.0,
136 // The pair audiofiles already draws, so a sorted column points the
137 // same way here as it does in a terminal.
138 ascending: " \u{25B2}",
139 descending: " \u{25BC}",
140 striped: false,
141 resizable: false,
142 }
143 }
144}
145
146/// The body's own facts for this frame: how many rows, which are selected, and
147/// which one to bring into view.
148///
149/// Held apart from [`TableStyle`] because none of it is style and none of it
150/// survives the frame: a row count changes when a folder does, a selection when
151/// the user clicks, and a scroll request exists for exactly one frame. Held
152/// apart from the [`Column`] slice because none of it is description either.
153/// The description says what a table *is*, and this says what it holds right
154/// now.
155///
156/// Both of the optional fields are here rather than left to the app because
157/// egui_extras answers them on a handle the app never sees: `set_selected` is a
158/// method on the row, and `scroll_to_row` a method on the builder, and this
159/// crate owns both. That is the same reason [`cell`] exists.
160#[derive(Default)]
161pub struct Body<'a> {
162 /// How many rows to draw.
163 pub rows: usize,
164 /// Whether a row is selected, by index.
165 ///
166 /// A predicate rather than a set, so an app whose selection is a range, a
167 /// bitmap or a single index does not have to build a collection to be asked.
168 /// `None` is a table no row of which is selected, which is not the same
169 /// claim as a predicate that always answers false and costs nothing to make.
170 pub selected: Option<&'a dyn Fn(usize) -> bool>,
171 /// A row to bring into view this frame.
172 ///
173 /// Set it from a request the app then clears, the way a keyboard cursor
174 /// moving off-screen raises one: held rather than taken, it would fight
175 /// every scroll the user makes with the mouse.
176 pub scroll_to: Option<usize>,
177}
178
179impl std::fmt::Debug for Body<'_> {
180 // Hand-written because `selected` is a closure and `#[derive(Debug)]` will
181 // not have it. What is worth printing is whether one was supplied.
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("Body")
184 .field("rows", &self.rows)
185 .field("selected", &self.selected.is_some())
186 .field("scroll_to", &self.scroll_to)
187 .finish()
188 }
189}
190
191/// The colour a cell of this part takes.
192///
193/// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on
194/// `content`: a part this renderer has not learned draws as text, which is a
195/// cell rendering plainly rather than a build that stops. Grep this when
196/// adopting a new `makeover-layout`.
197#[must_use]
198pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
199 match part {
200 // A token paints its own background and carries its own tone. What is
201 // set here is what shows between them, not what paints them.
202 Some(CellPart::Tokens) => palette.content_muted,
203 // The drift `CellPart` exists to end: a control in a cell inheriting the
204 // cell's text colour. Both of these take the action intent instead.
205 Some(CellPart::Actions | CellPart::Link) => palette.action,
206 _ => palette.content,
207 }
208}
209
210/// Draw a cell's contents with the tone its part takes.
211///
212/// The app calls this inside its own cell closure, wrapping whatever it draws.
213/// A scoping function rather than a parameter on [`table`], for the reason
214/// [`frame`](crate::frame) is one: the part is a property of the cell, the cell
215/// does not exist until the closure runs, and immediate mode has no cascade to
216/// carry the answer down on its own. This is the cascade, for one scope.
217///
218/// ```no_run
219/// # use makeover_layout::CellPart;
220/// # let palette: makeover_immediate::Palette = unimplemented!();
221/// # let ui: &mut egui::Ui = unimplemented!();
222/// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| {
223/// ui.label("opens the item");
224/// });
225/// ```
226pub fn cell<R>(
227 ui: &mut Ui,
228 part: Option<CellPart>,
229 palette: &Palette,
230 add_contents: impl FnOnce(&mut Ui) -> R,
231) -> R {
232 let restore = ui.visuals().override_text_color;
233 ui.visuals_mut().override_text_color = Some(part_color(part, palette));
234 let out = add_contents(ui);
235 ui.visuals_mut().override_text_color = restore;
236 out
237}
238
239/// The heading, with the caret if the table is ordered by this column.
240///
241/// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable)
242/// still gets its caret. Both combinations mean something, which is why the
243/// description holds the two fields apart: a list ordered by a key the user
244/// cannot change is a real thing, and the caret is how it says so.
245#[must_use]
246pub fn heading(column: &Column<'_>, style: &TableStyle) -> String {
247 match column.sorted {
248 Some(Sort::Ascending) => format!("{}{}", column.name, style.ascending),
249 Some(Sort::Descending) => format!("{}{}", column.name, style.descending),
250 None => column.name.to_owned(),
251 }
252}
253
254/// How wide a column asks to be at its narrowest, in points.
255fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
256 // Every arm is the declared length, including `Content`: nothing can be
257 // measured before the app's closure has drawn it. See the module header on
258 // what immediate mode costs the narrowing.
259 sizing.length_for(column.name)
260}
261
262/// Whether the columns kept at `cutoff` fit in `width`.
263fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool {
264 columns
265 .iter()
266 .filter(|c| c.kept_at(cutoff))
267 .map(|c| min_width(c, sizing))
268 .sum::<f32>()
269 <= width
270}
271
272/// The weakest cutoff whose columns fit in `width`.
273///
274/// Raised until the layout fits, and never past [`Priority::Essential`]: the
275/// essential columns are what makes a row identify itself, so a window too
276/// narrow for them gets them squeezed rather than dropped. Nothing here counts
277/// positions, so which column drops is a property of the column.
278#[must_use]
279pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority {
280 for cutoff in CUTOFFS {
281 if fits(columns, sizing, cutoff, width) {
282 return cutoff;
283 }
284 }
285 Priority::Essential
286}
287
288/// The track for one column.
289fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
290 match column.width {
291 // The one place immediate mode beats the terminal: egui_extras measures
292 // this and remembers it between frames, where `makeover-tui` has to walk
293 // the cells itself.
294 Width::Content => Track::auto(),
295 Width::Fixed => Track::exact(sizing.length_for(column.name)),
296 // Includes a width added to the description since this renderer was
297 // built. Taking the slack above a floor is the behaviour that makes no
298 // claim, which is the same fallback the webview renderer's `auto` track
299 // is chosen to be.
300 _ => Track::remainder().at_least(sizing.length_for(column.name)),
301 }
302}
303
304/// A described table, narrowed for the width available.
305///
306/// `draw` is called once per cell of each kept column, in column order, for each
307/// of [`Body::rows`] rows. Taking a closure rather than a slice of contents is
308/// what keeps the app's own data borrowed one cell at a time, which is
309/// [`group`](crate::group)'s reasoning and immediate mode's habit.
310///
311/// `body` is borrowed immutably and `draw` is `FnMut`, which is the split a
312/// caller has to plan for: a selection read by [`Body::selected`] cannot be the
313/// same value `draw` mutates. Snapshot it before the call. That is not this
314/// crate imposing anything. It is the borrow the app already takes when it
315/// clones its row list to hand egui a closure.
316///
317/// Returns the sortable column whose heading was pressed this frame, if any. The
318/// app owns the ordering, so this reports the press and changes nothing: what a
319/// press *calls* is an address, and the description names none. That is
320/// [`Column::sortable`]'s own documented split.
321///
322/// A heading is only pressable when its column says
323/// [`sortable`](Column::sortable). A column sorted by a key the user cannot
324/// change still draws its caret and does not answer.
325pub fn table<'a>(
326 ui: &mut Ui,
327 columns: &'a [Column<'a>],
328 body: &Body<'_>,
329 sizing: &Sizing<'_>,
330 palette: &Palette,
331 style: &TableStyle,
332 mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
333) -> Option<&'a Column<'a>> {
334 let cutoff = cutoff_for(columns, sizing, ui.available_width());
335 let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect();
336
337 // egui_extras panics on a table with no tracks, and a description whose
338 // every column dropped is reachable: `kept_at` keeps the essential ones, and
339 // a table described with none at all has nothing to keep.
340 if kept.is_empty() {
341 return None;
342 }
343
344 let mut builder = TableBuilder::new(ui)
345 .striped(style.striped)
346 .resizable(style.resizable)
347 // Not a knob, because there is no second honest answer: a cell's
348 // contents sit on the row's centre line. CSS says `vertical-align:
349 // middle` and a terminal row is one line tall, so a field offering the
350 // choice would be offering one only this renderer could take. egui's own
351 // default is top-aligned, which is why it has to be said at all.
352 .cell_layout(egui::Layout::left_to_right(egui::Align::Center));
353 for column in &kept {
354 builder = builder.column(track(column, sizing));
355 }
356 if let Some(row) = body.scroll_to {
357 builder = builder.scroll_to_row(row, None);
358 }
359
360 // Written through a Cell rather than returned, because egui_extras hands the
361 // header and the body their own closures and neither can return a value past
362 // the other.
363 let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
364
365 builder
366 .header(style.header_height, |mut header| {
367 for column in &kept {
368 header.col(|ui| {
369 if press(ui, column, palette, style) {
370 pressed.set(Some(column));
371 }
372 });
373 }
374 })
375 .body(|table_body| {
376 table_body.rows(style.row_height, body.rows, |mut row| {
377 let index = row.index();
378 if let Some(selected) = body.selected {
379 // Before the cells, and on the row rather than on any of
380 // them: a selection marks the whole row, and a renderer that
381 // tinted each cell would leave the gaps between them
382 // unpainted.
383 row.set_selected(selected(index));
384 }
385 for column in &kept {
386 row.col(|ui| draw(ui, column, index));
387 }
388 });
389 });
390
391 pressed.get()
392}
393
394/// One heading, and whether it was pressed.
395fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
396 let text = RichText::new(heading(column, style)).strong();
397 if !column.sortable {
398 // Muted, and not sensed. A heading a user cannot press must not look
399 // like one they can, which is the affordance `Column::sortable` exists
400 // to carry.
401 ui.label(text.color(palette.content_muted));
402 return false;
403 }
404 let tone = if column.sorted.is_some() {
405 palette.content
406 } else {
407 palette.content_muted
408 };
409 let response: Response = ui
410 .add(egui::Label::new(text.color(tone)).sense(Sense::click()))
411 .on_hover_cursor(egui::CursorIcon::PointingHand);
412 response.clicked()
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418 use egui::Color32;
419
420 fn palette() -> Palette {
421 Palette {
422 page: Color32::from_rgb(1, 1, 1),
423 raised: Color32::from_rgb(2, 2, 2),
424 overlay: Color32::from_rgb(3, 3, 3),
425 well: Color32::from_rgb(4, 4, 4),
426 sunken: Color32::from_rgb(5, 5, 5),
427 bevel_light: Color32::WHITE,
428 bevel_dark: Color32::BLACK,
429 elevation: Color32::from_black_alpha(46),
430 content: Color32::from_rgb(6, 6, 6),
431 content_secondary: Color32::from_rgb(56, 56, 56),
432 content_muted: Color32::from_rgb(7, 7, 7),
433 action: Color32::from_rgb(8, 8, 8),
434 danger: Color32::from_rgb(9, 9, 9),
435 success: Color32::from_rgb(10, 10, 10),
436 warning: Color32::from_rgb(11, 11, 11),
437 info: Color32::from_rgb(12, 12, 12),
438 }
439 }
440
441 fn columns() -> Vec<Column<'static>> {
442 vec![
443 Column {
444 name: "name",
445 width: Width::Fill,
446 priority: Priority::Essential,
447 sortable: true,
448 sorted: Some(Sort::Ascending),
449 },
450 Column {
451 name: "size",
452 width: Width::Fixed,
453 priority: Priority::Secondary,
454 sortable: true,
455 sorted: None,
456 },
457 Column {
458 name: "note",
459 width: Width::Content,
460 priority: Priority::Optional,
461 sortable: false,
462 sorted: None,
463 },
464 ]
465 }
466
467 fn sizing() -> Sizing<'static> {
468 Sizing {
469 lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)],
470 fallback: 40.0,
471 }
472 }
473
474 #[test]
475 fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() {
476 let (cols, sz) = (columns(), sizing());
477 assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
478 assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
479 assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential);
480 // Narrower than the essential column, which stays anyway.
481 assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
482 }
483
484 #[test]
485 fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
486 // The goingson bug, as a test. `nth-child(n+5)` against a seven-column
487 // table hides whatever lands at position five, so inserting a column
488 // moves the cut onto a different column with nothing edited.
489 //
490 // Asserted at a fixed cutoff, because that is where the two ways of
491 // addressing a column disagree. A narrower budget SHOULD drop more; what
492 // must not change is which ones, for a given cutoff.
493 let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
494 cols.iter()
495 .filter(|c| !c.kept_at(cutoff))
496 .map(|c| c.name.to_owned())
497 .collect()
498 };
499 let before = columns();
500 let mut after = vec![Column {
501 name: "mark",
502 width: Width::Fixed,
503 priority: Priority::Essential,
504 sortable: false,
505 sorted: None,
506 }];
507 after.extend(columns());
508
509 for cutoff in CUTOFFS {
510 assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff));
511 }
512 assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]);
513 }
514
515 #[test]
516 fn the_two_renderers_narrow_a_description_the_same_way() {
517 // The cutoff ladder is duplicated in `makeover-tui` because neither
518 // crate depends on the other, and duplication is what drifts. This is
519 // the assertion that would catch it: the ladder is the description's
520 // order, weakest first, and a tier added upstream belongs in both.
521 assert_eq!(CUTOFFS.len(), 3);
522 assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1]));
523 assert_eq!(CUTOFFS[0], Priority::Optional);
524 assert_eq!(CUTOFFS[2], Priority::Essential);
525 }
526
527 #[test]
528 fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() {
529 // The split the module header names. The track defers to egui_extras,
530 // which can measure; the narrowing cannot wait for that and uses the
531 // declared floor. Both readings of the same column, and both honest.
532 let cols = columns();
533 let sz = sizing();
534 let note = &cols[2];
535 assert!(matches!(note.width, Width::Content));
536 assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON);
537 // 120 + 60 + 80 is 260, so 300 fits and 250 does not.
538 assert!(fits(&cols, &sz, Priority::Optional, 300.0));
539 assert!(!fits(&cols, &sz, Priority::Optional, 250.0));
540 }
541
542 #[test]
543 fn a_column_with_no_length_of_its_own_takes_the_fallback() {
544 let column = Column {
545 name: "unlisted",
546 width: Width::Fixed,
547 priority: Priority::Essential,
548 sortable: false,
549 sorted: None,
550 };
551 assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON);
552 }
553
554 #[test]
555 fn the_parts_a_cell_can_be_are_coloured_apart() {
556 // The drift `CellPart` exists to end: one colour for a whole cell paints
557 // a control as though it were text.
558 let p = palette();
559 assert_eq!(part_color(Some(CellPart::Value), &p), p.content);
560 assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted);
561 assert_eq!(part_color(Some(CellPart::Actions), &p), p.action);
562 assert_eq!(part_color(Some(CellPart::Link), &p), p.action);
563 assert_ne!(part_color(Some(CellPart::Link), &p), p.content);
564 // A cell mixing parts says nothing, and takes the text colour.
565 assert_eq!(part_color(None, &p), p.content);
566 }
567
568 #[test]
569 fn the_ordered_column_draws_a_caret_and_the_others_do_not() {
570 let style = TableStyle::default();
571 let cols = columns();
572 assert_eq!(heading(&cols[0], &style), "name \u{25B2}");
573 assert_eq!(heading(&cols[1], &style), "size");
574 assert_eq!(heading(&cols[2], &style), "note");
575 }
576
577 #[test]
578 fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
579 // A list ordered by a key the user cannot change is a real thing to
580 // describe, which is why the description holds the two fields apart.
581 let column = Column {
582 name: "rank",
583 width: Width::Content,
584 priority: Priority::Essential,
585 sortable: false,
586 sorted: Some(Sort::Descending),
587 };
588 assert_eq!(heading(&column, &TableStyle::default()), "rank \u{25BC}");
589 }
590
591 #[test]
592 fn the_carets_match_the_terminal_renderers() {
593 // Two crates, one glyph pair, and no dependency between them to enforce
594 // it. A description sorted ascending must not point up in a window and
595 // down in a terminal.
596 let style = TableStyle::default();
597 assert_eq!(style.ascending, " \u{25B2}");
598 assert_eq!(style.descending, " \u{25BC}");
599 }
600
601 #[test]
602 fn striping_is_off_because_the_description_has_no_word_for_it() {
603 // egui_extras offers it and the other two renderers cannot say it. A
604 // default that turned it on would be this renderer adding a claim.
605 assert!(!TableStyle::default().striped);
606 // Same test, same answer, and the reason `sticky_header` failed it: that
607 // one had no second setting to offer.
608 assert!(!TableStyle::default().resizable);
609 }
610
611 #[test]
612 fn a_body_claims_nothing_until_it_is_asked_to() {
613 // The default is a table of no rows, no selection and no scroll
614 // request. All three absences are the honest reading of an app that has
615 // not said otherwise, which is why they are `Option` and not a
616 // predicate that always answers false.
617 let body = Body::default();
618 assert_eq!(body.rows, 0);
619 assert!(body.selected.is_none());
620 assert!(body.scroll_to.is_none());
621 }
622
623 #[test]
624 fn a_selection_is_asked_per_row_and_not_collected() {
625 // A predicate, so an app whose selection is a range or a single index
626 // does not build a set to be asked. Exercised the way `table` asks it:
627 // once per row index, in order.
628 let selected = |index: usize| index.is_multiple_of(2);
629 let body = Body {
630 rows: 4,
631 selected: Some(&selected),
632 scroll_to: None,
633 };
634 let f = body.selected.expect("a predicate was supplied");
635 assert_eq!(
636 (0..body.rows).map(f).collect::<Vec<_>>(),
637 vec![true, false, true, false]
638 );
639 }
640
641 #[test]
642 fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() {
643 // `resizable` lets the user move a divider, and `cutoff_for` must not
644 // hear about it: a drag that could drop a column would make the
645 // narrowing a thing the user does by accident rather than a property of
646 // the description. That `cutoff_for` takes no `TableStyle` at all is the
647 // structural half of the guarantee; this is the behavioural half, and it
648 // is what would fail if a measured width were ever threaded in beside
649 // the declared one.
650 let (cols, sz) = (columns(), sizing());
651 assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional);
652 assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary);
653 }
654}