Skip to main content

max / makeover-immediate

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