//! Columns, narrowing, cell parts and the sort caret, over `egui_extras`. //! //! `makeover-webview`'s `list` module and `makeover-tui`'s `table` in the shape //! immediate mode allows. It owns the same four things: which columns exist, how //! wide they are, which ones survive a narrow viewport, and what each part of a //! cell is. It does not own what goes in a cell, which here is not a policy but //! a fact of the mode: a cell's contents are drawn by the app's own closure, the //! way [`group`](crate::group) already takes one per field. //! //! # Why `egui_extras` and not egui //! //! egui itself has no table. [`egui::Grid`] gives no per-column sizing, no //! sticky header and no scroll sync, which is why audiofiles reached for //! `egui_extras::TableBuilder` rather than building on `Grid`. Writing a third //! answer here would be reimplementing that crate worse, so this is a mapping //! layer over it. //! //! It is the first dependency this crate has taken beyond egui itself, and it //! moves in lockstep with egui's own version, which is the cost worth naming. //! //! # What immediate mode costs the narrowing //! //! The terminal renderer measures a [`Width::Content`] column from its cells, //! because it holds every cell before it draws any. Here the cells do not exist //! until the app's closure runs, so nothing can be measured before the layout is //! decided. //! //! That splits the answer in two, and both halves are honest: //! //! - **Sizing** hands a content column to //! [`egui_extras::Column::auto`], which measures it and holds the result //! between frames. This is better than the terminal gets, not worse. //! - **Narrowing** cannot wait for that, so it budgets a content column at the //! floor the app declared in [`Sizing`]. A column that turns out wider than //! its floor is still drawn; it is the *decision to drop* that uses the //! declared number, and a floor is what the app already has to supply for its //! fill columns. //! //! # Why positions are the bug //! //! Carried from the other two renderers, because the mistake is not a CSS //! mistake and not a terminal one. goingson hides its mobile columns with //! `nth-child(n+5)` against a seven-column table; insert a column left of the //! cut and the wrong one disappears, silently. A renderer narrows by raising a //! cutoff and never by counting. use crate::Palette; use egui::{Response, RichText, Sense, Ui}; use egui_extras::{Column as Track, TableBuilder}; use makeover_layout::{CellPart, Column, Priority, Sort, Width}; /// The cutoffs, weakest first. /// /// [`Priority`] is `#[non_exhaustive]` and a tier added upstream has to be added /// here in its place in the sequence, or a table will never narrow to it. Grep /// this when adopting a new `makeover-layout`; `makeover-tui` carries the same /// list for the same reason, and the two have to agree or a description narrows /// differently in a window than in a terminal. const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential]; /// The lengths the description deferred, in points. /// /// [`Width`] says `Content`, `Fixed` or `Fill` and carries no magnitude, because /// a magnitude is an answer for one renderer and the description is read by /// three. The other two renderers hold this same type over CSS lengths and over /// terminal cells. #[derive(Debug, Clone, Copy, Default)] pub struct Sizing<'a> { /// `(column name, points)`. The track for a [`Width::Fixed`] column, the /// floor for a [`Width::Fill`] one, and the narrowing budget for a /// [`Width::Content`] one. pub lengths: &'a [(&'a str, f32)], /// Used for a column with no entry above. pub fallback: f32, } impl Sizing<'_> { /// The length for a named column. fn length_for(&self, name: &str) -> f32 { self.lengths .iter() .find(|(column, _)| *column == name) .map_or(self.fallback, |(_, length)| *length) } } /// The tones and metrics a table draws with. /// /// Metrics only, and the tones come from [`Palette`]. That is the division this /// crate already draws: [`FieldStyle`](crate::FieldStyle) carries gaps and a /// marker while the colours stay in the palette, and a table's colours are the /// palette's `content`, `content_muted` and `action` rather than six new ones. /// `makeover-tui` splits it the other way round because its palette carries no /// text tones at all. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TableStyle { /// The height of the heading row. pub header_height: f32, /// The height of a body row. pub row_height: f32, /// The caret drawn after the heading of an ascending column. /// /// Defaults to [`Sort::glyph`], which is where the spelling lives now: /// three renderers holding the same literal agreed by coincidence. Bare, /// with no leading space -- the gap is [`heading`]'s, written once for all /// three states rather than baked into two strings and forgotten in the /// third. pub ascending: &'static str, /// Drawn after the heading of a descending column. pub descending: &'static str, /// Whether alternate rows take a different background. /// /// egui_extras' own striping, off by default: the description has no word /// for it, and a renderer that turned it on would be adding a claim the /// other two cannot make. /// /// Not every setting egui_extras has becomes a field here. A sticky heading /// is what `TableBuilder::header` does and there is no version that does /// not, so a knob for it would offer a choice this renderer cannot make. /// This one and [`resizable`](Self::resizable) are the two that pass that /// test. pub striped: bool, /// Whether the user can drag the divider between two columns. /// /// The second knob that is not a metric, and it passes the same test /// `sticky_header` failed: egui_extras offers both settings and a renderer /// can honestly make either choice. Off by default for `striped`'s reason: /// the description has no word for it, so a default that turned it on would /// be this renderer adding a claim the other two cannot make. /// /// It does not fight the narrowing. A drag moves a track for the frames it /// is held; [`cutoff_for`] still decides which columns exist, off the widths /// the app declared in [`Sizing`], so a resize can never drop a column. pub resizable: bool, } impl Default for TableStyle { fn default() -> Self { Self { header_height: 20.0, row_height: 18.0, ascending: Sort::Ascending.glyph(), descending: Sort::Descending.glyph(), striped: false, resizable: false, } } } /// The body's own facts for this frame: how many rows, which are selected, and /// which one to bring into view. /// /// Held apart from [`TableStyle`] because none of it is style and none of it /// survives the frame: a row count changes when a folder does, a selection when /// the user clicks, and a scroll request exists for exactly one frame. Held /// apart from the [`Column`] slice because none of it is description either. /// The description says what a table *is*, and this says what it holds right /// now. /// /// Both of the optional fields are here rather than left to the app because /// egui_extras answers them on a handle the app never sees: `set_selected` is a /// method on the row, and `scroll_to_row` a method on the builder, and this /// crate owns both. That is the same reason [`cell`] exists. #[derive(Default)] pub struct Body<'a> { /// How many rows to draw. pub rows: usize, /// Whether a row is selected, by index. /// /// A predicate rather than a set, so an app whose selection is a range, a /// bitmap or a single index does not have to build a collection to be asked. /// `None` is a table no row of which is selected, which is not the same /// claim as a predicate that always answers false and costs nothing to make. pub selected: Option<&'a dyn Fn(usize) -> bool>, /// A row to bring into view this frame. /// /// Set it from a request the app then clears, the way a keyboard cursor /// moving off-screen raises one: held rather than taken, it would fight /// every scroll the user makes with the mouse. pub scroll_to: Option, } impl std::fmt::Debug for Body<'_> { // Hand-written because `selected` is a closure and `#[derive(Debug)]` will // not have it. What is worth printing is whether one was supplied. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Body") .field("rows", &self.rows) .field("selected", &self.selected.is_some()) .field("scroll_to", &self.scroll_to) .finish() } } /// The colour a cell of this part takes. /// /// [`CellPart`] is `#[non_exhaustive]`, and a member added upstream lands on /// `content`: a part this renderer has not learned draws as text, which is a /// cell rendering plainly rather than a build that stops. Grep this when /// adopting a new `makeover-layout`. #[must_use] pub const fn part_color(part: Option, palette: &Palette) -> egui::Color32 { match part { // A token paints its own background and carries its own tone. What is // set here is what shows between them, not what paints them. Some(CellPart::Tokens) => palette.content_muted, // The drift `CellPart` exists to end: a control in a cell inheriting the // cell's text colour. Both of these take the action intent instead. Some(CellPart::Actions | CellPart::Link) => palette.action, _ => palette.content, } } /// Draw a cell's contents with the tone its part takes. /// /// The app calls this inside its own cell closure, wrapping whatever it draws. /// A scoping function rather than a parameter on [`table`], for the reason /// [`frame`](crate::frame) is one: the part is a property of the cell, the cell /// does not exist until the closure runs, and immediate mode has no cascade to /// carry the answer down on its own. This is the cascade, for one scope. /// /// ```no_run /// # use makeover_layout::CellPart; /// # let palette: makeover_immediate::Palette = unimplemented!(); /// # let ui: &mut egui::Ui = unimplemented!(); /// makeover_immediate::table::cell(ui, Some(CellPart::Link), &palette, |ui| { /// ui.label("opens the item"); /// }); /// ``` pub fn cell( ui: &mut Ui, part: Option, palette: &Palette, add_contents: impl FnOnce(&mut Ui) -> R, ) -> R { let restore = ui.visuals().override_text_color; ui.visuals_mut().override_text_color = Some(part_color(part, palette)); let out = add_contents(ui); ui.visuals_mut().override_text_color = restore; out } /// The heading, with the caret if the table is ordered by this column. /// /// A column [`sorted`](Column::sorted) but not [`sortable`](Column::sortable) /// still gets its caret. Both combinations mean something, which is why the /// description holds the two fields apart: a list ordered by a key the user /// cannot change is a real thing, and the caret is how it says so. #[must_use] pub fn heading(column: &Column<'_>, style: &TableStyle) -> String { let caret = match column.sorted { Some(Sort::Ascending) => style.ascending, Some(Sort::Descending) => style.descending, // Sortable and not sorted draws the idle mark, in the ascending // spelling because that is the direction a first press takes. What // separates it from the column in force is the tone, which is // [`press`]'s to pick. None if column.sortable => style.ascending, None => return column.name.to_owned(), }; format!("{} {caret}", column.name) } /// How wide a column asks to be at its narrowest, in points. fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 { // Every arm is the declared length, including `Content`: nothing can be // measured before the app's closure has drawn it. See the module header on // what immediate mode costs the narrowing. sizing.length_for(column.name) } /// Whether the columns kept at `cutoff` fit in `width`. fn fits(columns: &[Column<'_>], sizing: &Sizing<'_>, cutoff: Priority, width: f32) -> bool { columns .iter() .filter(|c| c.kept_at(cutoff)) .map(|c| min_width(c, sizing)) .sum::() <= width } /// The weakest cutoff whose columns fit in `width`. /// /// Raised until the layout fits, and never past [`Priority::Essential`]: the /// essential columns are what makes a row identify itself, so a window too /// narrow for them gets them squeezed rather than dropped. Nothing here counts /// positions, so which column drops is a property of the column. #[must_use] pub fn cutoff_for(columns: &[Column<'_>], sizing: &Sizing<'_>, width: f32) -> Priority { for cutoff in CUTOFFS { if fits(columns, sizing, cutoff, width) { return cutoff; } } Priority::Essential } /// The track for one column. fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track { match column.width { // The one place immediate mode beats the terminal: egui_extras measures // this and remembers it between frames, where `makeover-tui` has to walk // the cells itself. Width::Content => Track::auto(), Width::Fixed => Track::exact(sizing.length_for(column.name)), // Includes a width added to the description since this renderer was // built. Taking the slack above a floor is the behaviour that makes no // claim, which is the same fallback the webview renderer's `auto` track // is chosen to be. _ => Track::remainder().at_least(sizing.length_for(column.name)), } } /// A described table, narrowed for the width available. /// /// `draw` is called once per cell of each kept column, in column order, for each /// of [`Body::rows`] rows. Taking a closure rather than a slice of contents is /// what keeps the app's own data borrowed one cell at a time, which is /// [`group`](crate::group)'s reasoning and immediate mode's habit. /// /// `body` is borrowed immutably and `draw` is `FnMut`, which is the split a /// caller has to plan for: a selection read by [`Body::selected`] cannot be the /// same value `draw` mutates. Snapshot it before the call. That is not this /// crate imposing anything. It is the borrow the app already takes when it /// clones its row list to hand egui a closure. /// /// Returns the sortable column whose heading was pressed this frame, if any. The /// app owns the ordering, so this reports the press and changes nothing: what a /// press *calls* is an address, and the description names none. That is /// [`Column::sortable`]'s own documented split. /// /// A heading is only pressable when its column says /// [`sortable`](Column::sortable). A column sorted by a key the user cannot /// change still draws its caret and does not answer. pub fn table<'a>( ui: &mut Ui, columns: &'a [Column<'a>], body: &Body<'_>, sizing: &Sizing<'_>, palette: &Palette, style: &TableStyle, mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize), ) -> Option<&'a Column<'a>> { let cutoff = cutoff_for(columns, sizing, ui.available_width()); let kept: Vec<&'a Column<'a>> = columns.iter().filter(|c| c.kept_at(cutoff)).collect(); // egui_extras panics on a table with no tracks, and a description whose // every column dropped is reachable: `kept_at` keeps the essential ones, and // a table described with none at all has nothing to keep. if kept.is_empty() { return None; } let mut builder = TableBuilder::new(ui) .striped(style.striped) .resizable(style.resizable) // Not a knob, because there is no second honest answer: a cell's // contents sit on the row's centre line. CSS says `vertical-align: // middle` and a terminal row is one line tall, so a field offering the // choice would be offering one only this renderer could take. egui's own // default is top-aligned, which is why it has to be said at all. .cell_layout(egui::Layout::left_to_right(egui::Align::Center)); for column in &kept { builder = builder.column(track(column, sizing)); } if let Some(row) = body.scroll_to { builder = builder.scroll_to_row(row, None); } // Written through a Cell rather than returned, because egui_extras hands the // header and the body their own closures and neither can return a value past // the other. let pressed = std::cell::Cell::new(None::<&'a Column<'a>>); builder .header(style.header_height, |mut header| { for column in &kept { header.col(|ui| { if press(ui, column, palette, style) { pressed.set(Some(column)); } }); } }) .body(|table_body| { table_body.rows(style.row_height, body.rows, |mut row| { let index = row.index(); if let Some(selected) = body.selected { // Before the cells, and on the row rather than on any of // them: a selection marks the whole row, and a renderer that // tinted each cell would leave the gaps between them // unpainted. row.set_selected(selected(index)); } for column in &kept { row.col(|ui| draw(ui, column, index)); } }); }); pressed.get() } /// What one heading is drawn in. /// /// Three states, three tones (wiki `three-tone-convention`). The column in force /// is the emphasised thing; a column offering to reorder is inactive but usable, /// because it answers a press; a column that is not a control at all is inert. /// /// The middle one may not take `content_muted`, which is what /// [`State::Disabled`](makeover_layout::State::Disabled) resolves to: a heading /// the user can press would be claiming it will not answer. fn heading_color(column: &Column<'_>, palette: &Palette) -> egui::Color32 { match (column.sorted, column.sortable) { (Some(_), _) => palette.content, (None, true) => palette.content_secondary, (None, false) => palette.content_muted, } } /// One heading, and whether it was pressed. fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool { let text = RichText::new(heading(column, style)).color(heading_color(column, palette)); if !column.sortable { // Not sensed. A heading a user cannot press must not look like one they // can, which is the affordance `Column::sortable` exists to carry, and // the tone above is half of saying so. ui.label(text.strong()); return false; } let response: Response = ui .add(egui::Label::new(text.strong()).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand); // Announced as the control it is, rather than as the `Label` it is drawn // with. egui maps a `Label` to `Role::Label` whatever it senses, so until // 2026-08-22 a screen reader was told this was static text and a user who // could not see the pointer change had no way to know the table sorts. // The same argument the comment above makes about affordance, made about // the half of the interface that is not pixels. // // The name is the column's own, not `heading`'s: the caret is a rendering of // `Column::sorted`, and reading a triangle aloud after every heading is // noise. Which column is in force is a fact a client should get from the // sort state, and egui has nowhere to put that yet -- worth revisiting if it // grows a sort field on `WidgetInfo`. response.widget_info(|| { egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), column.name) }); response.clicked() } #[cfg(test)] mod tests { use super::*; /// What the accessibility tree says a heading row drew. /// /// egui builds it from the `WidgetInfo` each widget reports, so this is /// what a screen reader would be handed rather than a second opinion. fn announced(draw: impl FnMut(&mut Ui)) -> Vec<(egui::accesskit::Role, String)> { let ctx = egui::Context::default(); ctx.enable_accesskit(); let mut draw = draw; let input = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(800.0, 600.0), )), ..Default::default() }; let _ = ctx.run_ui(input(), &mut draw); let out = ctx.run_ui(input(), &mut draw); out.platform_output .accesskit_update .expect("accesskit is on") .nodes .iter() .map(|(_, node)| { ( node.role(), node.label() .or_else(|| node.value()) .unwrap_or_default() .to_owned(), ) }) .collect() } #[test] fn a_sortable_heading_is_announced_as_something_you_press() { let column = Column { name: "Name", width: Width::Fill, priority: Priority::Essential, sortable: true, sorted: Some(Sort::Ascending), }; let p = palette(); let drawn = announced(|ui| { press(ui, &column, &p, &TableStyle::default()); }); // The name is the column's, with no caret in it: the glyph renders // `Column::sorted` and is not part of what the control is called. assert!( drawn .iter() .any(|(role, name)| *role == egui::accesskit::Role::Button && name == "Name"), "{drawn:?}" ); } #[test] fn a_heading_that_is_not_a_control_is_not_announced_as_one() { let column = Column { name: "Tags", width: Width::Fixed, priority: Priority::Optional, sortable: false, sorted: None, }; let p = palette(); let drawn = announced(|ui| { press(ui, &column, &p, &TableStyle::default()); }); assert!( !drawn .iter() .any(|(role, _)| *role == egui::accesskit::Role::Button), "a heading with no sort answers nothing and must not claim to: {drawn:?}" ); } use egui::Color32; fn palette() -> Palette { Palette { page: Color32::from_rgb(1, 1, 1), raised: Color32::from_rgb(2, 2, 2), overlay: Color32::from_rgb(3, 3, 3), well: Color32::from_rgb(4, 4, 4), sunken: Color32::from_rgb(5, 5, 5), bevel_light: Color32::WHITE, bevel_dark: Color32::BLACK, elevation: Color32::from_black_alpha(46), content: Color32::from_rgb(6, 6, 6), content_secondary: Color32::from_rgb(56, 56, 56), content_muted: Color32::from_rgb(7, 7, 7), action: Color32::from_rgb(8, 8, 8), danger: Color32::from_rgb(9, 9, 9), success: Color32::from_rgb(10, 10, 10), warning: Color32::from_rgb(11, 11, 11), info: Color32::from_rgb(12, 12, 12), } } fn columns() -> Vec> { vec![ Column { name: "name", width: Width::Fill, priority: Priority::Essential, sortable: true, sorted: Some(Sort::Ascending), }, Column { name: "size", width: Width::Fixed, priority: Priority::Secondary, sortable: true, sorted: None, }, Column { name: "note", width: Width::Content, priority: Priority::Optional, sortable: false, sorted: None, }, ] } fn sizing() -> Sizing<'static> { Sizing { lengths: &[("name", 120.0), ("size", 60.0), ("note", 80.0)], fallback: 40.0, } } #[test] fn narrowing_drops_the_optional_column_first_and_the_essential_one_never() { let (cols, sz) = (columns(), sizing()); assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional); assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary); assert_eq!(cutoff_for(&cols, &sz, 150.0), Priority::Essential); // Narrower than the essential column, which stays anyway. assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential); } #[test] fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() { // The goingson bug, as a test. `nth-child(n+5)` against a seven-column // table hides whatever lands at position five, so inserting a column // moves the cut onto a different column with nothing edited. // // Asserted at a fixed cutoff, because that is where the two ways of // addressing a column disagree. A narrower budget SHOULD drop more; what // must not change is which ones, for a given cutoff. let dropped = |cols: &[Column<'_>], cutoff| -> Vec { cols.iter() .filter(|c| !c.kept_at(cutoff)) .map(|c| c.name.to_owned()) .collect() }; let before = columns(); let mut after = vec![Column { name: "mark", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }]; after.extend(columns()); for cutoff in CUTOFFS { assert_eq!(dropped(&before, cutoff), dropped(&after, cutoff)); } assert_eq!(dropped(&before, Priority::Secondary), vec!["note"]); } #[test] fn the_two_renderers_narrow_a_description_the_same_way() { // The cutoff ladder is duplicated in `makeover-tui` because neither // crate depends on the other, and duplication is what drifts. This is // the assertion that would catch it: the ladder is the description's // order, weakest first, and a tier added upstream belongs in both. assert_eq!(CUTOFFS.len(), 3); assert!(CUTOFFS.windows(2).all(|pair| pair[0] < pair[1])); assert_eq!(CUTOFFS[0], Priority::Optional); assert_eq!(CUTOFFS[2], Priority::Essential); } #[test] fn a_content_column_is_measured_by_egui_and_budgeted_by_its_floor() { // The split the module header names. The track defers to egui_extras, // which can measure; the narrowing cannot wait for that and uses the // declared floor. Both readings of the same column, and both honest. let cols = columns(); let sz = sizing(); let note = &cols[2]; assert!(matches!(note.width, Width::Content)); assert!((min_width(note, &sz) - 80.0).abs() < f32::EPSILON); // 120 + 60 + 80 is 260, so 300 fits and 250 does not. assert!(fits(&cols, &sz, Priority::Optional, 300.0)); assert!(!fits(&cols, &sz, Priority::Optional, 250.0)); } #[test] fn a_column_with_no_length_of_its_own_takes_the_fallback() { let column = Column { name: "unlisted", width: Width::Fixed, priority: Priority::Essential, sortable: false, sorted: None, }; assert!((min_width(&column, &sizing()) - 40.0).abs() < f32::EPSILON); } #[test] fn the_parts_a_cell_can_be_are_coloured_apart() { // The drift `CellPart` exists to end: one colour for a whole cell paints // a control as though it were text. let p = palette(); assert_eq!(part_color(Some(CellPart::Value), &p), p.content); assert_eq!(part_color(Some(CellPart::Tokens), &p), p.content_muted); assert_eq!(part_color(Some(CellPart::Actions), &p), p.action); assert_eq!(part_color(Some(CellPart::Link), &p), p.action); assert_ne!(part_color(Some(CellPart::Link), &p), p.content); // A cell mixing parts says nothing, and takes the text colour. assert_eq!(part_color(None, &p), p.content); } #[test] fn a_heading_carries_a_caret_when_it_is_ordered_by_or_offers_to_be() { let style = TableStyle::default(); let cols = columns(); assert_eq!(heading(&cols[0], &style), "name \u{25B2}"); // Sortable and idle. It draws the mark a first press would give, which // is what stops the press from widening the column and shifting the // ones after it. assert_eq!(heading(&cols[1], &style), "size \u{25B2}"); // Not a control. Nothing to mark. assert_eq!(heading(&cols[2], &style), "note"); } #[test] fn the_three_states_of_a_heading_are_three_tones() { // wiki `three-tone-convention`. The middle state may not take // content_muted, which is what `State::Disabled` resolves to: a heading // the user can press would claim it will not answer. The arm that keeps // muted is the one where it is true. let p = palette(); let cols = columns(); assert_eq!(heading_color(&cols[0], &p), p.content); assert_eq!(heading_color(&cols[1], &p), p.content_secondary); assert_eq!(heading_color(&cols[2], &p), p.content_muted); } #[test] fn a_column_sorted_without_being_sortable_still_draws_its_caret() { // A list ordered by a key the user cannot change is a real thing to // describe, which is why the description holds the two fields apart. let column = Column { name: "rank", width: Width::Content, priority: Priority::Essential, sortable: false, sorted: Some(Sort::Descending), }; assert_eq!(heading(&column, &TableStyle::default()), "rank \u{25BC}"); } #[test] fn the_carets_match_the_terminal_renderers() { // Two crates, one glyph pair, and no dependency between them to enforce // it. A description sorted ascending must not point up in a window and // down in a terminal. // Composition rather than agreement since makeover-layout 0.27.5: both // read `Sort::glyph`, so a fourth spelling cannot appear in one crate. let style = TableStyle::default(); assert_eq!(style.ascending, Sort::Ascending.glyph()); assert_eq!(style.descending, Sort::Descending.glyph()); // Bare. The gap is `heading`'s, so a consumer swapping the glyph for an // ASCII one does not have to remember to bring a space with it. assert_eq!(style.ascending.trim(), style.ascending); } #[test] fn striping_is_off_because_the_description_has_no_word_for_it() { // egui_extras offers it and the other two renderers cannot say it. A // default that turned it on would be this renderer adding a claim. assert!(!TableStyle::default().striped); // Same test, same answer, and the reason `sticky_header` failed it: that // one had no second setting to offer. assert!(!TableStyle::default().resizable); } #[test] fn a_body_claims_nothing_until_it_is_asked_to() { // The default is a table of no rows, no selection and no scroll // request. All three absences are the honest reading of an app that has // not said otherwise, which is why they are `Option` and not a // predicate that always answers false. let body = Body::default(); assert_eq!(body.rows, 0); assert!(body.selected.is_none()); assert!(body.scroll_to.is_none()); } #[test] fn a_selection_is_asked_per_row_and_not_collected() { // A predicate, so an app whose selection is a range or a single index // does not build a set to be asked. Exercised the way `table` asks it: // once per row index, in order. let selected = |index: usize| index.is_multiple_of(2); let body = Body { rows: 4, selected: Some(&selected), scroll_to: None, }; let f = body.selected.expect("a predicate was supplied"); assert_eq!( (0..body.rows).map(f).collect::>(), vec![true, false, true, false] ); } #[test] fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() { // `resizable` lets the user move a divider, and `cutoff_for` must not // hear about it: a drag that could drop a column would make the // narrowing a thing the user does by accident rather than a property of // the description. That `cutoff_for` takes no `TableStyle` at all is the // structural half of the guarantee; this is the behavioural half, and it // is what would fail if a measured width were ever threaded in beside // the declared one. let (cols, sz) = (columns(), sizing()); assert_eq!(cutoff_for(&cols, &sz, 300.0), Priority::Optional); assert_eq!(cutoff_for(&cols, &sz, 200.0), Priority::Secondary); } }