#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod table;
mod align;
mod csv;
mod filter;
mod selection;
mod sort;
pub mod accessibility;
pub mod async_source;
pub mod clipboard;
pub mod format;
pub mod header;
pub mod height;
pub mod height_cache;
pub mod nav;
pub mod pagination;
pub mod persistence;
pub mod text_integration;
pub mod theme_integration;
#[cfg(feature = "egui-table")]
mod egui_table;
#[cfg(feature = "iced-table")]
mod iced_table;
pub use table::{RenderedCell, Table};
pub use align::CellAlign;
pub use csv::to_csv;
pub use filter::{apply_all, filter_indices, ColumnFilter};
pub use selection::{SelectionMode, SelectionModel};
pub use sort::{sort_indices, SortDirection, SortState};
pub use async_source::{AsyncRowSource, BoxFuture, PrefetchBuffer};
pub use clipboard::{selection_to_tsv, CaptureClipboard, ClipboardSink, NullClipboard};
pub use format::{CellFormatter, DateFormatter, DefaultFormatter, NumberFormatter};
pub use header::{handle_row_click, move_column, HeaderSortState, TableIndex};
pub use height::CumulativeHeights;
pub use height_cache::{CumulativeHeightCache, RowCache};
pub use nav::TableNav;
pub use pagination::PaginationState;
#[cfg(feature = "egui-table")]
pub use egui_table::EguiTableState;
#[cfg(feature = "iced-table")]
pub use iced_table::{
render_iced, render_iced_sortable, render_iced_with_filters, render_iced_with_selection,
};
pub const DEFAULT_ROW_HEIGHT: f32 = 24.0;
#[derive(Debug, Clone)]
pub enum TableEvent {
RowSelected(usize),
CellEdited {
row: usize,
col: usize,
new_value: String,
},
SortChanged {
col: usize,
ascending: bool,
},
ColumnResized {
col: usize,
new_width: f32,
},
FilterChanged {
col: usize,
new_filter: String,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum TableError {
ReadOnly,
OutOfBounds {
row: usize,
col: usize,
},
InvalidValue(String),
}
impl std::fmt::Display for TableError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TableError::ReadOnly => write!(f, "table source is read-only"),
TableError::OutOfBounds { row, col } => {
write!(f, "cell ({row}, {col}) is out of bounds")
}
TableError::InvalidValue(msg) => write!(f, "invalid cell value: {msg}"),
}
}
}
impl std::error::Error for TableError {}
pub trait RowSource {
fn row_count(&self) -> usize;
fn row(&self, index: usize) -> Vec<Cell>;
fn column_defs(&self) -> &[ColumnDef];
fn set_cell(&mut self, _row: usize, _col: usize, _value: Cell) -> Result<(), TableError> {
Err(TableError::ReadOnly)
}
fn row_height(&self, _index: usize) -> f32 {
DEFAULT_ROW_HEIGHT
}
fn children(&self, _row: usize) -> Option<Vec<usize>> {
None
}
fn indent_level(&self, _row: usize) -> usize {
0
}
fn footer(&self) -> Option<Vec<Cell>> {
None
}
}
impl<T: RowSource + ?Sized> RowSource for Box<T> {
fn row_count(&self) -> usize {
(**self).row_count()
}
fn row(&self, index: usize) -> Vec<Cell> {
(**self).row(index)
}
fn column_defs(&self) -> &[ColumnDef] {
(**self).column_defs()
}
fn set_cell(&mut self, row: usize, col: usize, value: Cell) -> Result<(), TableError> {
(**self).set_cell(row, col, value)
}
fn row_height(&self, index: usize) -> f32 {
(**self).row_height(index)
}
fn children(&self, row: usize) -> Option<Vec<usize>> {
(**self).children(row)
}
fn indent_level(&self, row: usize) -> usize {
(**self).indent_level(row)
}
fn footer(&self) -> Option<Vec<Cell>> {
(**self).footer()
}
}
pub trait CellRenderer: std::fmt::Debug + Send {
fn render_str(&self) -> String;
}
fn unix_ms_to_iso8601(ms: i64) -> String {
let days = if ms >= 0 {
ms / 86_400_000
} else {
(ms - 86_399_999) / 86_400_000
};
let jdn = days + 2_440_588_i64;
let a = jdn + 32044;
let b = (4 * a + 3) / 146097;
let c = a - (146097 * b) / 4;
let d = (4 * c + 3) / 1461;
let e = c - (1461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = e - (153 * m + 2) / 5 + 1;
let month = m + 3 - 12 * (m / 10);
let year = 100 * b + d - 4800 + m / 10;
format!("{year:04}-{month:02}-{day:02}")
}
#[derive(Debug)]
pub enum Cell {
Text(String),
Int(i64),
Float(f64),
Bool(bool),
Empty,
Date(i64),
Currency {
amount_cents: i64,
code: String,
},
Link {
label: String,
url: String,
},
Image {
uri: String,
},
Custom(Box<dyn CellRenderer>),
}
impl Clone for Cell {
fn clone(&self) -> Self {
match self {
Cell::Text(s) => Cell::Text(s.clone()),
Cell::Int(n) => Cell::Int(*n),
Cell::Float(v) => Cell::Float(*v),
Cell::Bool(b) => Cell::Bool(*b),
Cell::Empty => Cell::Empty,
Cell::Date(ms) => Cell::Date(*ms),
Cell::Currency { amount_cents, code } => Cell::Currency {
amount_cents: *amount_cents,
code: code.clone(),
},
Cell::Link { label, url } => Cell::Link {
label: label.clone(),
url: url.clone(),
},
Cell::Image { uri } => Cell::Image { uri: uri.clone() },
Cell::Custom(_) => Cell::Empty,
}
}
}
impl std::fmt::Display for Cell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Cell::Text(s) => write!(f, "{s}"),
Cell::Int(n) => write!(f, "{n}"),
Cell::Float(v) => write!(f, "{v}"),
Cell::Bool(b) => write!(f, "{b}"),
Cell::Empty => Ok(()),
Cell::Date(ms) => write!(f, "{}", unix_ms_to_iso8601(*ms)),
Cell::Currency { amount_cents, code } => {
let major = amount_cents / 100;
let minor = amount_cents.abs() % 100;
if *amount_cents < 0 && major == 0 {
write!(f, "-0.{minor:02} {code}")
} else {
write!(f, "{major}.{minor:02} {code}")
}
}
Cell::Link { label, .. } => write!(f, "{label}"),
Cell::Image { uri } => write!(f, "[image: {uri}]"),
Cell::Custom(renderer) => write!(f, "{}", renderer.render_str()),
}
}
}
impl Cell {
pub fn is_empty(&self) -> bool {
matches!(self, Cell::Empty)
}
pub fn compare(&self, other: &Cell) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (self, other) {
(Cell::Int(a), Cell::Int(b)) => a.cmp(b),
(Cell::Float(a), Cell::Float(b)) => a.total_cmp(b),
(Cell::Int(a), Cell::Float(b)) => (*a as f64).total_cmp(b),
(Cell::Float(a), Cell::Int(b)) => a.total_cmp(&(*b as f64)),
(Cell::Text(a), Cell::Text(b)) => a.cmp(b),
(Cell::Bool(a), Cell::Bool(b)) => a.cmp(b),
(Cell::Empty, Cell::Empty) => Ordering::Equal,
(Cell::Date(a), Cell::Date(b)) => a.cmp(b),
(
Cell::Currency {
amount_cents: a, ..
},
Cell::Currency {
amount_cents: b, ..
},
) => a.cmp(b),
_ => self.type_rank().cmp(&other.type_rank()),
}
}
fn type_rank(&self) -> u8 {
match self {
Cell::Empty => 0,
Cell::Bool(_) => 1,
Cell::Int(_) => 2,
Cell::Float(_) => 2, Cell::Text(_) => 3,
Cell::Date(_) => 4,
Cell::Currency { .. } => 5,
Cell::Link { .. } => 6,
Cell::Image { .. } => 7,
Cell::Custom(_) => 8,
}
}
}
impl From<&str> for Cell {
fn from(s: &str) -> Self {
Cell::Text(s.to_owned())
}
}
impl From<String> for Cell {
fn from(s: String) -> Self {
Cell::Text(s)
}
}
impl From<i64> for Cell {
fn from(n: i64) -> Self {
Cell::Int(n)
}
}
impl From<i32> for Cell {
fn from(n: i32) -> Self {
Cell::Int(n as i64)
}
}
impl From<f64> for Cell {
fn from(v: f64) -> Self {
Cell::Float(v)
}
}
impl From<bool> for Cell {
fn from(b: bool) -> Self {
Cell::Bool(b)
}
}
pub struct ColumnDef {
pub name: String,
pub width: f32,
pub min_width: f32,
pub max_width: f32,
pub resizable: bool,
pub formatter: Option<Box<dyn CellFormatter>>,
pub align: Option<CellAlign>,
}
impl Clone for ColumnDef {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
width: self.width,
min_width: self.min_width,
max_width: self.max_width,
resizable: self.resizable,
formatter: None,
align: self.align,
}
}
}
impl std::fmt::Debug for ColumnDef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ColumnDef")
.field("name", &self.name)
.field("width", &self.width)
.field("min_width", &self.min_width)
.field("max_width", &self.max_width)
.field("resizable", &self.resizable)
.field("formatter", &self.formatter.as_ref().map(|_| "<formatter>"))
.field("align", &self.align)
.finish()
}
}
impl Default for ColumnDef {
fn default() -> Self {
Self {
name: String::new(),
width: 100.0,
min_width: 40.0,
max_width: 800.0,
resizable: true,
formatter: None,
align: None,
}
}
}
impl ColumnDef {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
..Self::default()
}
}
}
pub struct ColumnDefBuilder {
inner: ColumnDef,
}
impl ColumnDefBuilder {
pub fn new(name: impl Into<String>) -> Self {
Self {
inner: ColumnDef::new(name),
}
}
pub fn width(mut self, w: f32) -> Self {
self.inner.width = w;
self
}
pub fn min_width(mut self, w: f32) -> Self {
self.inner.min_width = w;
self
}
pub fn max_width(mut self, w: f32) -> Self {
self.inner.max_width = w;
self
}
pub fn resizable(mut self) -> Self {
self.inner.resizable = true;
self
}
pub fn formatter(mut self, f: impl CellFormatter + 'static) -> Self {
self.inner.formatter = Some(Box::new(f));
self
}
pub fn align(mut self, a: CellAlign) -> Self {
self.inner.align = Some(a);
self
}
pub fn build(self) -> ColumnDef {
self.inner
}
}
pub fn aggregate_sum(cells: &[Cell]) -> f64 {
cells
.iter()
.filter_map(|c| match c {
Cell::Int(n) => Some(*n as f64),
Cell::Float(f) => Some(*f),
_ => None,
})
.sum()
}
pub fn aggregate_count(cells: &[Cell]) -> usize {
cells.iter().filter(|c| !matches!(c, Cell::Empty)).count()
}
pub fn aggregate_avg(cells: &[Cell]) -> Option<f64> {
let nums: Vec<f64> = cells
.iter()
.filter_map(|c| match c {
Cell::Int(n) => Some(*n as f64),
Cell::Float(f) => Some(*f),
_ => None,
})
.collect();
if nums.is_empty() {
None
} else {
Some(nums.iter().sum::<f64>() / nums.len() as f64)
}
}
pub struct TableBuilder<S: RowSource> {
source: S,
page_size: usize,
zebra_striping: bool,
}
impl<S: RowSource> TableBuilder<S> {
pub fn new(source: S) -> Self {
Self {
source,
page_size: 50,
zebra_striping: false,
}
}
pub fn page_size(mut self, size: usize) -> Self {
self.page_size = size;
self
}
pub fn zebra_striping(mut self, enabled: bool) -> Self {
self.zebra_striping = enabled;
self
}
pub fn build(self) -> Table<S> {
Table::new(self.source)
.with_page_size(self.page_size)
.with_zebra_striping(self.zebra_striping)
}
}
#[cfg(test)]
mod cell_type_tests {
use super::*;
#[test]
fn cell_date_epoch() {
assert_eq!(format!("{}", Cell::Date(0)), "1970-01-01");
}
#[test]
fn cell_date_one_day() {
assert_eq!(format!("{}", Cell::Date(86_400_000)), "1970-01-02");
}
#[test]
fn cell_date_leap_year_2000_02_29() {
let ms = 11_016_i64 * 86_400_000_i64;
assert_eq!(format!("{}", Cell::Date(ms)), "2000-02-29");
}
#[test]
fn cell_date_2100_02_28() {
let ms = 47_540_i64 * 86_400_000_i64;
assert_eq!(format!("{}", Cell::Date(ms)), "2100-02-28");
}
#[test]
fn cell_currency_display() {
let c = Cell::Currency {
amount_cents: 12345,
code: "EUR".to_string(),
};
assert_eq!(format!("{c}"), "123.45 EUR");
}
#[test]
fn cell_currency_negative() {
let c = Cell::Currency {
amount_cents: -100,
code: "USD".to_string(),
};
assert_eq!(format!("{c}"), "-1.00 USD");
}
#[test]
fn cell_currency_zero() {
let c = Cell::Currency {
amount_cents: 0,
code: "GBP".to_string(),
};
assert_eq!(format!("{c}"), "0.00 GBP");
}
#[test]
fn cell_link_shows_label() {
let c = Cell::Link {
label: "Click here".to_string(),
url: "https://example.com".to_string(),
};
assert_eq!(format!("{c}"), "Click here");
}
#[test]
fn cell_image_display() {
let c = Cell::Image {
uri: "https://example.com/img.png".to_string(),
};
assert_eq!(format!("{c}"), "[image: https://example.com/img.png]");
}
#[test]
fn cell_custom_delegates_to_render_str() {
#[derive(Debug)]
struct MyRenderer;
impl CellRenderer for MyRenderer {
fn render_str(&self) -> String {
"custom".to_string()
}
}
let c = Cell::Custom(Box::new(MyRenderer));
assert_eq!(format!("{c}"), "custom");
}
}