use crate::Palette;
use egui::{Response, RichText, Sense, Ui};
use egui_extras::{Column as Track, TableBuilder};
use makeover_layout::{CellPart, Column, ColumnKind, 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 code_row_height: f32,
pub edge_padding: f32,
pub ascending: &'static str,
pub descending: &'static str,
pub resizable: bool,
}
impl Default for TableStyle {
fn default() -> Self {
Self {
header_height: 32.0,
row_height: 45.0,
code_row_height: 20.0,
edge_padding: 12.0,
ascending: Sort::Ascending.glyph(),
descending: Sort::Descending.glyph(),
resizable: false,
}
}
}
#[derive(Default)]
pub struct Body<'a> {
pub rows: usize,
pub selected: Option<&'a dyn Fn(usize) -> bool>,
pub scroll_to: Option<usize>,
}
impl std::fmt::Debug for Body<'_> {
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()
}
}
#[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 {
let caret = match column.sorted {
Some(Sort::Ascending) => style.ascending,
Some(Sort::Descending) => style.descending,
None if column.sortable => style.ascending,
None => return column.name.to_uppercase(),
};
format!("{} {caret}", column.name.to_uppercase())
}
fn fits(columns: &[Column<'_>], ch: f32, edge: f32, cutoff: Priority, width: f32) -> bool {
columns
.iter()
.filter(|c| c.kept_at(cutoff))
.map(|c| f32::from(c.floor()) * ch + 2.0 * edge)
.sum::<f32>()
<= width
}
#[must_use]
pub fn cutoff_for(columns: &[Column<'_>], ch: f32, edge: f32, width: f32) -> Priority {
for cutoff in CUTOFFS {
if fits(columns, ch, edge, 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>],
body: &Body<'_>,
sizing: &Sizing<'_>,
palette: &Palette,
style: &TableStyle,
mut draw: impl FnMut(&mut Ui, &'a Column<'a>, usize),
) -> Option<&'a Column<'a>> {
let ch = {
let font = egui::TextStyle::Body.resolve(ui.style());
ui.fonts_mut(|fonts| fonts.glyph_width(&font, '0'))
};
let cutoff = cutoff_for(columns, ch, style.edge_padding, 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 code = kept.iter().any(|column| column.kind == ColumnKind::Code);
let row_height = if code {
style.code_row_height
} else {
style.row_height
};
let outer = ui.available_rect_before_wrap();
let across = outer.x_range();
let fills = egui::Rangef::new(across.min + 1.0, across.max - 1.0);
let top = ui.cursor().top();
let ground = ui.painter().add(egui::Shape::Noop);
let reach = std::cell::Cell::new(top);
let inner = outer.shrink2(egui::vec2(style.edge_padding, 0.0));
let pressed = std::cell::Cell::new(None::<&'a Column<'a>>);
let viewport = ui
.scope_builder(egui::UiBuilder::new().max_rect(inner), |ui| {
let mut builder = TableBuilder::new(ui)
.striped(false)
.resizable(style.resizable)
.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);
}
builder
.header(style.header_height, |mut header| {
for (at, column) in kept.iter().enumerate() {
header.col(|ui| {
if at == 0 {
strip(ui, fills, &reach, palette);
}
placed(ui, column, |ui| {
if press(ui, column, palette, style) {
pressed.set(Some(column));
}
});
});
}
})
.body(|table_body| {
table_body.rows(row_height, body.rows, |mut row| {
let index = row.index();
let selected = body.selected.is_some_and(|selected| selected(index));
for (at, column) in kept.iter().enumerate() {
row.col(|ui| {
if at == 0 {
let row = Row {
index,
selected,
code,
};
ground_row(ui, fills, row, &reach, palette);
}
placed(ui, column, |ui| draw(ui, column, index));
});
}
});
})
.inner_rect
})
.inner;
let bottom = reach.get().min(viewport.bottom());
let frame = egui::Rect::from_x_y_ranges(across, top..=bottom);
ui.painter().set(
ground,
egui::epaint::RectShape::filled(frame, 0, palette.raised),
);
ui.painter().rect_stroke(
frame,
0,
egui::Stroke::new(1.0, palette.row_rule),
egui::StrokeKind::Inside,
);
pressed.get()
}
#[derive(Clone, Copy)]
struct Row {
index: usize,
selected: bool,
code: bool,
}
fn row_rect(ui: &Ui, across: egui::Rangef) -> egui::Rect {
let half = 0.5 * ui.spacing().item_spacing.y;
let cell = ui.max_rect();
egui::Rect::from_x_y_ranges(across, (cell.top() - half)..=(cell.bottom() + half))
}
fn strip(ui: &Ui, across: egui::Rangef, reach: &std::cell::Cell<f32>, palette: &Palette) {
let rect = row_rect(ui, across);
reach.set(reach.get().max(rect.bottom()));
ui.painter().rect_filled(rect, 0, palette.sunken);
ui.painter().hline(
across,
rect.bottom(),
egui::Stroke::new(1.0, palette.bevel_dark),
);
}
fn ground_row(
ui: &Ui,
across: egui::Rangef,
Row {
index,
selected,
code,
}: Row,
reach: &std::cell::Cell<f32>,
palette: &Palette,
) {
let rect = row_rect(ui, across);
reach.set(reach.get().max(rect.bottom()));
let fill = if selected {
Some(palette.row_selected)
} else if ui.rect_contains_pointer(rect) {
Some(palette.row_hover)
} else if !code && index % 2 == 1 {
Some(palette.row_stripe)
} else {
None
};
if let Some(fill) = fill {
ui.painter().rect_filled(rect, 0, fill);
}
if !code && index > 0 {
ui.painter()
.hline(across, rect.top(), egui::Stroke::new(1.0, palette.row_rule));
}
}
fn placed(ui: &mut Ui, column: &Column<'_>, add: impl FnOnce(&mut Ui)) {
if column.kind.aligns_end() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), add);
} else {
add(ui);
}
}
fn caret_color(column: &Column<'_>, palette: &Palette) -> egui::Color32 {
match column.sorted {
Some(_) => palette.content,
None => palette.content_secondary,
}
}
fn press(ui: &mut Ui, column: &Column<'_>, palette: &Palette, style: &TableStyle) -> bool {
let size = egui::TextStyle::Small.resolve(ui.style()).size;
let heading = heading(column, style);
let label_len = column.name.to_uppercase().len();
let mut job = egui::text::LayoutJob::default();
RichText::new(&heading[..label_len])
.color(palette.content_secondary)
.small()
.strong()
.extra_letter_spacing(0.06 * size)
.append_to(
&mut job,
ui.style(),
egui::FontSelection::Default,
egui::Align::Center,
);
if heading.len() > label_len {
RichText::new(&heading[label_len..])
.color(caret_color(column, palette))
.small()
.append_to(
&mut job,
ui.style(),
egui::FontSelection::Default,
egui::Align::Center,
);
}
let text = job;
if !column.sortable {
ui.label(text);
return false;
}
let response: Response = ui
.add(egui::Label::new(text).sense(Sense::click()))
.on_hover_cursor(egui::CursorIcon::PointingHand);
response.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), column.name)
});
response.clicked()
}
#[cfg(test)]
mod tests {
use super::*;
use makeover_layout::ColumnKind;
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,
kind: ColumnKind::Text,
min: None,
sortable: true,
sorted: Some(Sort::Ascending),
};
let p = palette();
let drawn = announced(|ui| {
press(ui, &column, &p, &TableStyle::default());
});
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,
kind: ColumnKind::Text,
min: None,
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),
border: Color32::from_rgb(200, 200, 200),
info_surface: Color32::from_rgb(201, 201, 201),
success_surface: Color32::from_rgb(202, 202, 202),
warning_surface: Color32::from_rgb(203, 203, 203),
danger_surface: Color32::from_rgb(204, 204, 204),
row_stripe: Color32::from_rgb(205, 205, 205),
row_hover: Color32::from_rgb(206, 206, 206),
row_rule: Color32::from_rgb(207, 207, 207),
row_selected: Color32::from_rgb(208, 208, 208),
}
}
fn columns() -> Vec<Column<'static>> {
vec![
Column {
name: "name",
width: Width::Fill,
priority: Priority::Essential,
kind: ColumnKind::Text,
min: None,
sortable: true,
sorted: Some(Sort::Ascending),
},
Column {
name: "size",
width: Width::Fixed,
priority: Priority::Secondary,
kind: ColumnKind::Text,
min: None,
sortable: true,
sorted: None,
},
Column {
name: "note",
width: Width::Content,
priority: Priority::Optional,
kind: ColumnKind::Text,
min: None,
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 = columns();
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 392.0), Priority::Optional);
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 391.0), Priority::Secondary);
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 288.0), Priority::Secondary);
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 287.0), Priority::Essential);
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 10.0), Priority::Essential);
}
#[test]
fn the_same_floors_drop_at_the_same_ch_count_as_a_terminal() {
let cols = columns();
assert_eq!(cutoff_for(&cols, 1.0, 0.0, 32.0), Priority::Optional);
assert_eq!(cutoff_for(&cols, 1.0, 0.0, 31.0), Priority::Secondary);
assert_eq!(cutoff_for(&cols, 1.0, 0.0, 24.0), Priority::Secondary);
assert_eq!(cutoff_for(&cols, 1.0, 0.0, 23.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,
kind: ColumnKind::Text,
min: None,
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 note = &cols[2];
assert!(matches!(note.width, Width::Content));
assert_eq!(note.floor(), 8, "undeclared, so the kind's floor");
assert!(fits(&cols, 10.0, 12.0, Priority::Optional, 392.0));
assert!(!fits(&cols, 10.0, 12.0, Priority::Optional, 391.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,
kind: ColumnKind::Text,
min: None,
sortable: false,
sorted: None,
};
assert!((sizing().length_for(column.name) - 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 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}");
assert_eq!(heading(&cols[1], &style), "SIZE \u{25B2}");
assert_eq!(heading(&cols[2], &style), "NOTE");
}
#[test]
fn the_three_states_of_a_heading_are_carried_by_its_caret() {
let p = palette();
let cols = columns();
let style = TableStyle::default();
assert_eq!(caret_color(&cols[0], &p), p.content);
assert_eq!(caret_color(&cols[1], &p), p.content_secondary);
assert_ne!(caret_color(&cols[1], &p), p.content_muted);
assert!(
!heading(&cols[2], &style).contains(' '),
"an inert heading has no caret"
);
}
#[test]
fn a_column_sorted_without_being_sortable_still_draws_its_caret() {
let column = Column {
name: "rank",
width: Width::Content,
priority: Priority::Essential,
kind: ColumnKind::Text,
min: None,
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, Sort::Ascending.glyph());
assert_eq!(style.descending, Sort::Descending.glyph());
assert_eq!(style.ascending.trim(), style.ascending);
}
#[test]
fn resizing_is_off_because_the_description_has_no_word_for_it() {
assert!(!TableStyle::default().resizable);
}
#[test]
fn a_record_row_is_the_models_45_and_a_code_row_is_one_line() {
let style = TableStyle::default();
assert!((style.row_height - 45.0).abs() < f32::EPSILON);
assert!(style.code_row_height < style.row_height);
}
#[test]
fn a_body_claims_nothing_until_it_is_asked_to() {
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() {
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<_>>(),
vec![true, false, true, false]
);
}
#[test]
fn narrowing_reads_the_declared_widths_and_not_a_dragged_track() {
let cols = columns();
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 392.0), Priority::Optional);
assert_eq!(cutoff_for(&cols, 10.0, 12.0, 288.0), Priority::Secondary);
}
}