//! 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, /// Drawn after the heading of an ascending column. 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 the knob 0.12.0 briefly carried for it offered 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, // The pair audiofiles already draws, so a sorted column points the // same way here as it does in a terminal. ascending: " \u{25B2}", descending: " \u{25BC}", 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 { match column.sorted { Some(Sort::Ascending) => format!("{}{}", column.name, style.ascending), Some(Sort::Descending) => format!("{}{}", column.name, style.descending), None => column.name.to_owned(), } } /// 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() } /// 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)).strong(); if !column.sortable { // Muted, and not sensed. A heading a user cannot press must not look // like one they can, which is the affordance `Column::sortable` exists // to carry. ui.label(text.color(palette.content_muted)); return false; } let tone = if column.sorted.is_some() { palette.content } else { palette.content_muted }; let response: Response = ui .add(egui::Label::new(text.color(tone)).sense(Sense::click())) .on_hover_cursor(egui::CursorIcon::PointingHand); response.clicked() } #[cfg(test)] mod tests { use super::*; 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_muted: Color32::from_rgb(7, 7, 7), action: Color32::from_rgb(8, 8, 8), danger: Color32::from_rgb(9, 9, 9), } } 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 the_ordered_column_draws_a_caret_and_the_others_do_not() { let style = TableStyle::default(); let cols = columns(); assert_eq!(heading(&cols[0], &style), "name \u{25B2}"); assert_eq!(heading(&cols[1], &style), "size"); assert_eq!(heading(&cols[2], &style), "note"); } #[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. let style = TableStyle::default(); assert_eq!(style.ascending, " \u{25B2}"); assert_eq!(style.descending, " \u{25BC}"); } #[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); } }