use alloc::string::{String, ToString};
use alloc::vec::Vec;
use denise::{ElementState, InputEvent, KeyCode, Point, Radius, Rect, Role, Theme};
use denise_render::Canvas;
use denise_text::TextStyle;
use crate::widget::{Event, EventCtx, Handled, PaintCtx, VisualState, Widget};
use crate::widgets::style::{
Align, ClickPair, Intent, RowKind, draw_aligned, focus_ring, hovered_row, interactive_pair,
row_colors,
};
const THUMB: i32 = 3;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Column {
title: String,
width: Option<i32>,
align: Align,
}
impl Column {
pub fn new(title: impl Into<String>, width: i32) -> Self {
Self {
title: title.into(),
width: Some(width.max(0)),
align: Align::Start,
}
}
pub fn flex(title: impl Into<String>) -> Self {
Self {
title: title.into(),
width: None,
align: Align::Start,
}
}
pub fn align_end(mut self) -> Self {
self.align = Align::End;
self
}
pub fn align_center(mut self) -> Self {
self.align = Align::Center;
self
}
#[inline]
pub fn title(&self) -> &str {
&self.title
}
}
impl From<&str> for Column {
fn from(title: &str) -> Self {
Self::flex(title)
}
}
#[derive(Clone, Debug)]
pub struct Table<M> {
columns: Vec<Column>,
rows: Vec<Vec<String>>,
selected: Option<usize>,
hovered: Option<usize>,
scroll: usize,
row_height: Option<i32>,
selection: Option<fn(usize) -> M>,
activation: Option<fn(usize) -> M>,
single_click: bool,
clicks: ClickPair,
role: Role,
style: TextStyle,
}
impl<M> Table<M> {
pub fn new(
columns: impl IntoIterator<Item = impl Into<Column>>,
message: fn(usize) -> M,
) -> Self {
Self {
columns: columns.into_iter().map(Into::into).collect(),
rows: Vec::new(),
selected: None,
hovered: None,
scroll: 0,
row_height: None,
selection: Some(message),
activation: None,
single_click: false,
clicks: ClickPair::default(),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn inert(columns: impl IntoIterator<Item = impl Into<Column>>) -> Self {
Self {
columns: columns.into_iter().map(Into::into).collect(),
rows: Vec::new(),
selected: None,
hovered: None,
scroll: 0,
row_height: None,
selection: None,
activation: None,
single_click: false,
clicks: ClickPair::default(),
role: Role::Primary,
style: TextStyle::built_in(16),
}
}
pub fn with_rows(
mut self,
rows: impl IntoIterator<Item = impl IntoIterator<Item = impl Into<String>>>,
) -> Self {
self.set_rows(rows);
self
}
pub fn on_activate(mut self, message: fn(usize) -> M) -> Self {
self.activation = Some(message);
self
}
pub fn activate_on_click(mut self) -> Self {
self.single_click = true;
self
}
pub fn with_selected(mut self, index: Option<usize>) -> Self {
self.set_selected(index);
self
}
pub fn with_row_height(mut self, height: i32) -> Self {
self.row_height = Some(height.max(1));
self
}
pub fn with_role(mut self, role: Role) -> Self {
self.role = role;
self
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
#[inline]
pub const fn selected(&self) -> Option<usize> {
self.selected
}
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = index.filter(|index| *index < self.rows.len());
}
#[inline]
pub const fn scroll(&self) -> usize {
self.scroll
}
pub fn set_scroll(&mut self, index: usize) {
self.scroll = index.min(self.rows.len().saturating_sub(1));
}
#[inline]
pub fn row_count(&self) -> usize {
self.rows.len()
}
#[inline]
pub fn columns(&self) -> &[Column] {
&self.columns
}
pub fn cell(&self, row: usize, column: usize) -> &str {
self.rows
.get(row)
.and_then(|cells| cells.get(column))
.map_or("", String::as_str)
}
pub fn set_rows(
&mut self,
rows: impl IntoIterator<Item = impl IntoIterator<Item = impl Into<String>>>,
) {
self.rows = rows
.into_iter()
.map(|cells| cells.into_iter().map(Into::into).collect())
.collect();
self.set_selected(self.selected);
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
self.clicks.forget();
}
pub fn push_row(&mut self, cells: impl IntoIterator<Item = impl Into<String>>) -> usize {
self.rows.push(cells.into_iter().map(Into::into).collect());
self.rows.len() - 1
}
pub fn update_cell(&mut self, row: usize, column: usize, text: &str) -> bool {
let Some(cell) = self
.rows
.get_mut(row)
.and_then(|cells| cells.get_mut(column))
else {
return false;
};
if cell == text {
return false;
}
*cell = text.to_string();
true
}
pub fn set_role(&mut self, role: Role) {
self.role = role;
}
pub fn set_style(&mut self, style: TextStyle) {
self.style = style;
}
pub fn row_height(&self, theme: &Theme) -> i32 {
self.row_height.unwrap_or(theme.metrics.size_field).max(1)
}
pub fn visible_rows(&self, theme: &Theme, height: i32) -> usize {
let row = self.row_height(theme);
((height - row).max(0) / row) as usize
}
pub fn preferred_height(&self, theme: &Theme, rows: usize) -> i32 {
let row = i64::from(self.row_height(theme));
(row * (rows.min(i32::MAX as usize) as i64 + 1)).min(i64::from(i32::MAX)) as i32
}
fn max_scroll(&self, fits: usize) -> usize {
self.rows.len().saturating_sub(fits.max(1))
}
fn ensure_visible(&mut self, index: usize, fits: usize) {
let fits = fits.max(1);
if index < self.scroll {
self.scroll = index;
} else if index >= self.scroll + fits {
self.scroll = index + 1 - fits;
}
}
fn row_at(&self, bounds: Rect, row_height: i32, point: Point) -> Option<usize> {
if self.rows.is_empty() || !bounds.contains(point) {
return None;
}
let inside = point.y - bounds.y;
if inside < row_height {
return None; }
let slot = ((inside - row_height) / row_height.max(1)) as usize;
let index = self.scroll.checked_add(slot)?;
(index < self.rows.len()).then_some(index)
}
fn select(&mut self, target: Option<usize>, fits: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
let Some(target) = target else {
return Handled::Yes;
};
self.ensure_visible(target, fits);
if self.selected == Some(target) {
return Handled::Yes;
}
self.selected = Some(target);
if let Some(message) = self.selection {
ctx.emit(message(target));
}
Handled::Yes
}
fn activate(&mut self, row: usize, ctx: &mut EventCtx<'_, M>) -> Handled {
if let Some(message) = self.activation {
ctx.emit(message(row));
}
Handled::Yes
}
fn step(&self, forward: bool) -> Option<usize> {
let count = self.rows.len();
if count == 0 {
return None;
}
match self.selected {
None => Some(if forward { 0 } else { count - 1 }),
Some(index) if forward => (index + 1 < count).then_some(index + 1),
Some(index) => index.checked_sub(1),
}
}
}
#[inline]
const fn padding(size_px: u16) -> i32 {
let half = size_px as i32 / 2;
if half < 4 { 4 } else { half }
}
fn column_spans(width: i32, columns: &[Column], pad: i32) -> Vec<(i32, i32)> {
let gaps = pad * (columns.len().max(1) as i32 - 1) + pad * 2;
let fixed: i64 = columns.iter().filter_map(|c| c.width.map(i64::from)).sum();
let flexes = columns.iter().filter(|c| c.width.is_none()).count() as i64;
let leftover = (i64::from(width) - i64::from(gaps) - fixed).max(0);
let (share, mut spare) = if flexes > 0 {
((leftover / flexes) as i32, (leftover % flexes) as i32)
} else {
(0, 0)
};
let mut spans = Vec::with_capacity(columns.len());
let mut x = pad;
for column in columns {
let w = match column.width {
Some(fixed) => fixed,
None => {
let extra = i32::from(spare > 0);
spare -= extra;
share + extra
}
};
let w = w.min((width - pad - x).max(0));
spans.push((x, w));
x += w + pad;
}
spans
}
impl<M: 'static> Widget<M> for Table<M> {
fn paint(&self, ctx: &mut PaintCtx<'_>, canvas: &mut Canvas<'_>) {
let bounds = ctx.bounds;
if bounds.is_empty() || self.columns.is_empty() {
return;
}
let row_height = self.row_height(ctx.theme);
let pad = padding(self.style.size_px);
let spans = column_spans(bounds.width, &self.columns, pad);
let radius = ctx.theme.radius(Radius::Field);
let (backdrop, _) = row_colors(ctx.theme, ctx.state, self.role, RowKind::Resting, true);
canvas.fill_rect(bounds, backdrop);
let header = Rect::new(bounds.x, bounds.y, bounds.width, row_height);
let (strip, title_color) = interactive_pair(ctx.theme, Role::Base200, ctx.state);
canvas.fill_rect(header, strip);
for (column, &(x, w)) in self.columns.iter().zip(&spans) {
let cell = Rect::new(bounds.x + x, header.y, w, header.height);
if cell.is_empty() || column.title.is_empty() {
continue;
}
let mut clipped = canvas.with_clip(cell);
draw_aligned(
&mut clipped,
ctx.text,
self.style,
cell,
(column.align, Align::Center),
&column.title,
title_color,
);
}
let fits = self.visible_rows(ctx.theme, bounds.height);
let scroll = self.scroll.min(self.max_scroll(fits));
let hovered = hovered_row(ctx.state, self.hovered);
for slot in 0..fits {
let index = scroll + slot;
let Some(cells) = self.rows.get(index) else {
break;
};
let row = Rect::new(
bounds.x,
bounds.y + row_height * (slot as i32 + 1),
bounds.width,
row_height,
);
let kind = if self.selected == Some(index) {
RowKind::Selected
} else if hovered == Some(index) {
RowKind::Hovered
} else {
RowKind::Resting
};
let (fill, content) = row_colors(ctx.theme, ctx.state, self.role, kind, true);
if kind != RowKind::Resting {
canvas.fill_rounded_rect(row, radius, fill);
}
if kind == RowKind::Selected && ctx.state.contains(VisualState::FOCUSED) {
focus_ring(ctx.theme, row, radius, canvas);
}
for (c, (column, &(x, w))) in self.columns.iter().zip(&spans).enumerate() {
let cell = Rect::new(bounds.x + x, row.y, w, row.height);
let text = cells.get(c).map_or("", String::as_str);
if cell.is_empty() || text.is_empty() {
continue;
}
let mut clipped = canvas.with_clip(cell);
draw_aligned(
&mut clipped,
ctx.text,
self.style,
cell,
(column.align, Align::Center),
text,
content,
);
}
}
if self.rows.len() > fits && fits > 0 {
let region = Rect::new(
bounds.right() - THUMB,
bounds.y + row_height,
THUMB,
bounds.height - row_height,
);
let len = self.rows.len() as i64;
let h = (i64::from(region.height) * fits as i64 / len).max(8) as i32;
let travel = i64::from(region.height - h);
let top = if self.max_scroll(fits) > 0 {
(travel * scroll as i64 / self.max_scroll(fits) as i64) as i32
} else {
0
};
let (thumb, _) = interactive_pair(ctx.theme, Role::Base300, ctx.state);
canvas.fill_rounded_rect(
Rect::new(region.x, region.y + top, THUMB, h),
THUMB / 2,
thumb,
);
}
if ctx.state.contains(VisualState::FOCUSED) && self.selected.is_none() {
focus_ring(ctx.theme, bounds, radius, canvas);
}
}
fn on_event(&mut self, event: &Event<'_>, ctx: &mut EventCtx<'_, M>) -> Handled {
let row_height = self.row_height(ctx.theme);
let fits = self.visible_rows(ctx.theme, ctx.bounds.height);
match event {
Event::Input(InputEvent::PointerMoved { position }) => {
let row = self.row_at(ctx.bounds, row_height, *position);
if row == self.hovered {
return Handled::No;
}
self.hovered = row;
Handled::Yes
}
Event::Input(InputEvent::PointerScroll { delta_y, .. }) => {
if self.max_scroll(fits) == 0 {
return Handled::No;
}
let magnitude = ((delta_y.abs() as i32) / row_height).max(1) as usize;
let scroll = if *delta_y > 0.0 {
self.scroll.saturating_add(magnitude)
} else {
self.scroll.saturating_sub(magnitude)
};
let scroll = scroll.min(self.max_scroll(fits));
if scroll == self.scroll {
return Handled::Yes;
}
self.scroll = scroll;
Handled::Yes
}
Event::Input(InputEvent::PointerButton {
state: ElementState::Up,
position,
..
})
| Event::Input(InputEvent::TouchUp {
position,
cancelled: false,
..
}) => {
let Some(row) = self.row_at(ctx.bounds, row_height, *position) else {
return Handled::No;
};
let intent = self.clicks.classify(row, ctx.now_ms, self.single_click);
let handled = self.select(Some(row), fits, ctx);
if intent == Intent::Activate {
self.activate(row, ctx);
}
handled
}
Event::Input(InputEvent::Key {
code,
state: ElementState::Down,
..
}) if ctx.state.contains(VisualState::FOCUSED) => match code {
KeyCode::ArrowDown => self.select(self.step(true), fits, ctx),
KeyCode::ArrowUp => self.select(self.step(false), fits, ctx),
KeyCode::Home => self.select((!self.rows.is_empty()).then_some(0), fits, ctx),
KeyCode::End => self.select(self.rows.len().checked_sub(1), fits, ctx),
KeyCode::PageDown => {
let target = self
.selected
.map_or(0, |index| index + fits.max(1))
.min(self.rows.len().saturating_sub(1));
self.select((!self.rows.is_empty()).then_some(target), fits, ctx)
}
KeyCode::PageUp => {
let target = self
.selected
.map_or(0, |index| index.saturating_sub(fits.max(1)));
self.select((!self.rows.is_empty()).then_some(target), fits, ctx)
}
KeyCode::Enter | KeyCode::NumpadEnter => match self.selected {
Some(row) => self.activate(row, ctx),
None => Handled::No,
},
_ => Handled::No,
},
_ => Handled::No,
}
}
fn accepts_pointer(&self) -> bool {
true
}
fn focusable(&self) -> bool {
!self.rows.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use denise::theme;
fn columns() -> Vec<Column> {
alloc::vec![
Column::new("Navn", 190),
Column::flex("Rolle"),
Column::new("Alder", 60).align_end(),
]
}
fn rows(n: usize) -> Vec<[String; 3]> {
(0..n)
.map(|i| {
[
alloc::format!("Person {i}"),
alloc::format!("Rolle {i}"),
alloc::format!("{}", 20 + i),
]
})
.collect()
}
fn table(n: usize) -> Table<usize> {
Table::new(columns(), |index| index).with_rows(rows(n))
}
#[test]
fn the_window_follows_the_selection_only_when_it_leaves() {
let mut t = table(100);
t.ensure_visible(5, 10);
assert_eq!(t.scroll(), 0, "a selection inside the window moves nothing");
t.ensure_visible(10, 10);
assert_eq!(t.scroll(), 1, "one past the bottom scrolls by one");
t.ensure_visible(50, 10);
assert_eq!(t.scroll(), 41, "a jump lands the target on the last slot");
t.ensure_visible(3, 10);
assert_eq!(t.scroll(), 3, "leaving upward puts the target on top");
}
#[test]
fn the_scroll_clamps_to_the_last_full_window() {
let t = table(25);
assert_eq!(t.max_scroll(10), 15);
assert_eq!(t.max_scroll(30), 0, "a window bigger than the data");
assert_eq!(table(0).max_scroll(10), 0);
let mut t = table(25);
t.set_scroll(999);
assert_eq!(t.scroll(), 24, "set_scroll clamps against the data");
}
#[test]
fn a_huge_table_is_addressed_without_iterating_it() {
let t = table(10_000);
assert_eq!(t.cell(9_999, 0), "Person 9999");
assert_eq!(t.max_scroll(9), 9_991);
assert_eq!(
t.visible_rows(&theme::DARK, 400),
(400 / t.row_height(&theme::DARK) - 1) as usize
);
}
#[test]
fn the_header_costs_one_row_of_height() {
let t = table(5).with_row_height(30);
assert_eq!(t.visible_rows(&theme::DARK, 300), 9);
assert_eq!(
t.visible_rows(&theme::DARK, 30),
0,
"room only for the header"
);
assert_eq!(t.visible_rows(&theme::DARK, 0), 0);
assert_eq!(t.preferred_height(&theme::DARK, 5), 180);
}
#[test]
fn hits_land_on_the_data_row_not_the_screen_row() {
let mut t = table(50).with_row_height(20);
t.set_scroll(30);
let bounds = Rect::new(10, 10, 300, 100);
assert_eq!(
t.row_at(bounds, 20, Point::new(50, 15)),
None,
"the header is not a row"
);
assert_eq!(
t.row_at(bounds, 20, Point::new(50, 35)),
Some(30),
"the first slot is the scrolled-to row"
);
assert_eq!(t.row_at(bounds, 20, Point::new(50, 95)), Some(33));
assert_eq!(t.row_at(bounds, 20, Point::new(400, 35)), None, "outside");
let mut short = table(2).with_row_height(20);
short.set_scroll(0);
assert_eq!(
short.row_at(bounds, 20, Point::new(50, 75)),
None,
"below the last row is nobody's"
);
}
#[test]
fn flex_columns_share_the_leftover_exactly() {
let cols = alloc::vec![Column::new("a", 100), Column::flex("b"), Column::flex("c"),];
let spans = column_spans(400, &cols, 10);
assert_eq!(spans[0], (10, 100));
assert_eq!(spans[1].1 + spans[2].1, 260);
assert!(
(spans[1].1 - spans[2].1).abs() <= 1,
"shares differ by more than the remainder"
);
assert_eq!(spans[2].0 + spans[2].1, 400 - 10);
}
#[test]
fn overflowing_columns_squeeze_rather_than_escape() {
let cols = alloc::vec![
Column::new("a", 300),
Column::new("b", 300),
Column::flex("c"),
];
for width in [0, 50, 320, 640] {
let spans = column_spans(width, &cols, 8);
for (i, &(x, w)) in spans.iter().enumerate() {
assert!(w >= 0, "width {width}: column {i} inverted");
assert!(
w == 0 || x + w <= width,
"width {width}: column {i} escaped ({x}+{w})"
);
}
}
}
#[test]
fn a_bare_string_is_a_flex_column() {
let t: Table<usize> = Table::new(["Navn", "Rolle"], |i| i);
assert_eq!(t.columns().len(), 2);
assert_eq!(t.columns()[0].title(), "Navn");
assert!(t.columns()[0].width.is_none());
}
#[test]
fn a_short_row_has_empty_cells_at_the_end() {
let mut t: Table<usize> = Table::new(columns(), |i| i);
t.push_row(["bare", "to"]);
assert_eq!(t.cell(0, 0), "bare");
assert_eq!(t.cell(0, 2), "", "the missing cell is empty");
assert_eq!(t.cell(5, 0), "", "a missing row is empty too");
}
#[test]
fn writing_the_same_cell_reports_no_change() {
let mut t = table(3);
assert!(t.update_cell(1, 2, "99"));
assert!(!t.update_cell(1, 2, "99"));
assert_eq!(t.cell(1, 2), "99");
assert!(!t.update_cell(50, 0, "x"), "out of range changes nothing");
}
#[test]
fn replacing_the_rows_drops_what_no_longer_exists() {
let mut t = table(50);
t.set_selected(Some(40));
t.set_scroll(35);
t.set_rows(rows(5));
assert_eq!(t.selected(), None, "the selection pointed past the end");
assert_eq!(t.scroll(), 4, "the window clamped back into the data");
let mut kept = table(50);
kept.set_selected(Some(3));
kept.set_rows(rows(10));
assert_eq!(kept.selected(), Some(3), "a selection that exists survives");
}
#[test]
fn stepping_stops_at_the_ends() {
let mut t = table(3);
assert_eq!(t.step(true), Some(0), "first press lands on the near end");
t.set_selected(Some(2));
assert_eq!(t.step(true), None, "no wrap at the bottom");
t.set_selected(Some(0));
assert_eq!(t.step(false), None, "no wrap at the top");
assert_eq!(table(0).step(true), None, "an empty table has no step");
}
#[test]
fn only_a_table_with_rows_is_a_tab_stop() {
let empty_inert: Table<usize> = Table::inert(columns());
assert!(!Widget::<usize>::focusable(&empty_inert));
let empty: Table<usize> = Table::new(columns(), |i| i);
assert!(!Widget::<usize>::focusable(&empty));
assert!(Widget::<usize>::focusable(&table(3)));
}
}