use crate::Palette;
use egui::{Response, RichText, Sense, Ui};
use egui_extras::{Column as Track, TableBuilder};
use makeover_layout::{CellPart, Column, Priority, Sort, Width};
const CUTOFFS: [Priority; 3] = [Priority::Optional, Priority::Secondary, Priority::Essential];
#[derive(Debug, Clone, Copy, Default)]
pub struct Sizing<'a> {
pub lengths: &'a [(&'a str, f32)],
pub fallback: f32,
}
impl Sizing<'_> {
fn length_for(&self, name: &str) -> f32 {
self.lengths
.iter()
.find(|(column, _)| *column == name)
.map_or(self.fallback, |(_, length)| *length)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TableStyle {
pub header_height: f32,
pub row_height: f32,
pub ascending: &'static str,
pub descending: &'static str,
pub striped: bool,
}
impl Default for TableStyle {
fn default() -> Self {
Self {
header_height: 20.0,
row_height: 18.0,
ascending: " \u{25B2}",
descending: " \u{25BC}",
striped: false,
}
}
}
#[must_use]
pub const fn part_color(part: Option<CellPart>, palette: &Palette) -> egui::Color32 {
match part {
Some(CellPart::Tokens) => palette.content_muted,
Some(CellPart::Actions | CellPart::Link) => palette.action,
_ => palette.content,
}
}
pub fn cell<R>(
ui: &mut Ui,
part: Option<CellPart>,
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
}
#[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(),
}
}
fn min_width(column: &Column<'_>, sizing: &Sizing<'_>) -> f32 {
sizing.length_for(column.name)
}
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::<f32>()
<= width
}
#[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
}
fn track(column: &Column<'_>, sizing: &Sizing<'_>) -> Track {
match column.width {
Width::Content => Track::auto(),
Width::Fixed => Track::exact(sizing.length_for(column.name)),
_ => Track::remainder().at_least(sizing.length_for(column.name)),
}
}
pub fn table<'a>(
ui: &mut Ui,
columns: &'a [Column<'a>],
rows: usize,
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();
if kept.is_empty() {
return None;
}
let mut builder = TableBuilder::new(ui).striped(style.striped);
for column in &kept {
builder = builder.column(track(column, sizing));
}
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(|body| {
body.rows(style.row_height, rows, |mut row| {
let index = row.index();
for column in &kept {
row.col(|ui| draw(ui, column, index));
}
});
});
pressed.get()
}
fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
let text = RichText::new(heading(column, style)).strong();
if !column.sortable {
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<Column<'static>> {
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);
assert_eq!(cutoff_for(&cols, &sz, 10.0), Priority::Essential);
}
#[test]
fn a_column_inserted_left_of_the_cut_does_not_change_what_drops() {
let dropped = |cols: &[Column<'_>], cutoff| -> Vec<String> {
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() {
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() {
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);
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() {
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);
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() {
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() {
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() {
assert!(!TableStyle::default().striped);
}
}