use crate::types::{Decoration, Icon, Modifiers, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Column {
pub title: String,
#[serde(default)]
pub width: ColumnWidth,
#[serde(default)]
pub align: ColumnAlign,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum ColumnWidth {
Fixed(f32),
Flex(f32),
Content { min: f32, max: f32 },
}
impl Default for ColumnWidth {
fn default() -> Self {
ColumnWidth::Flex(1.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum ColumnAlign {
#[default]
Left,
Center,
Right,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DataRow {
pub cells: Vec<StyledText>,
#[serde(default)]
pub decoration: Decoration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SortDirection {
Ascending,
Descending,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct DataTableGlyphs {
sort_ascending: Option<Icon>,
sort_descending: Option<Icon>,
}
impl DataTableGlyphs {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_sort_ascending_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.sort_ascending = Some(glyph.into());
self
}
#[must_use]
pub fn with_sort_descending_glyph(mut self, glyph: impl Into<Icon>) -> Self {
self.sort_descending = Some(glyph.into());
self
}
#[must_use]
pub fn sort_ascending_glyph(&self) -> Option<&Icon> {
self.sort_ascending.as_ref()
}
#[must_use]
pub fn sort_descending_glyph(&self) -> Option<&Icon> {
self.sort_descending.as_ref()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DataTable {
pub id: WidgetId,
pub columns: Vec<Column>,
pub rows: Vec<DataRow>,
#[serde(default)]
pub selected_idx: Option<usize>,
#[serde(default)]
pub scroll_offset: usize,
#[serde(default)]
pub sort: Option<(usize, SortDirection)>,
#[serde(default)]
pub has_focus: bool,
#[serde(default)]
pub show_scrollbar: bool,
#[serde(default)]
pub min_total_width: Option<f32>,
#[serde(default)]
pub h_scroll: f32,
#[serde(default)]
pub column_overrides: Vec<Option<f32>>,
#[serde(default)]
pub footer: Option<DataRow>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum DataTableEvent {
HeaderClicked { col: usize },
RowActivated { idx: usize },
RowSelected { idx: usize },
Scroll { delta: i32, modifiers: Modifiers },
ColumnResized { col: usize, width: f32 },
SelectionSet { indices: Vec<usize> },
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ColumnMeasure {
pub content_width: f32,
}
impl ColumnMeasure {
pub fn new(content_width: f32) -> Self {
Self { content_width }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ResolvedColumn {
pub x: f32,
pub width: f32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DataTableHit {
Header { col: usize },
HeaderDivider { col: usize },
Row { idx: usize },
Footer,
Empty,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct DataTableLayout {
pub header_height: f32,
pub row_height: f32,
pub columns: Vec<ResolvedColumn>,
pub visible_rows: usize,
pub viewport_width: f32,
pub viewport_height: f32,
pub scrollbar_width: f32,
pub content_width: f32,
pub h_scrollbar_height: f32,
pub footer_height: f32,
pub h_scroll: f32,
}
const DIVIDER_GRAB_PX: f32 = 3.0;
impl DataTableLayout {
#[inline]
fn content_x(&self, x: f32) -> f32 {
x + self.h_scroll
}
#[inline]
fn in_v_scrollbar_strip(&self, x: f32) -> bool {
self.h_scroll != 0.0
&& self.scrollbar_width > 0.0
&& x >= (self.viewport_width - self.scrollbar_width).max(0.0)
}
pub fn hit_test(
&self,
x: f32,
y: f32,
scroll_offset: usize,
total_rows: usize,
) -> DataTableHit {
if x < 0.0 || y < 0.0 || x >= self.viewport_width || y >= self.viewport_height {
return DataTableHit::Empty;
}
if y < self.header_height {
if self.in_v_scrollbar_strip(x) {
return DataTableHit::Empty;
}
let cx = self.content_x(x);
for (i, rc) in self.columns.iter().enumerate() {
let right_edge = rc.x + rc.width;
if (cx - right_edge).abs() <= DIVIDER_GRAB_PX && i + 1 < self.columns.len() {
return DataTableHit::HeaderDivider { col: i };
}
}
let col = self
.columns
.iter()
.position(|c| cx >= c.x && cx < c.x + c.width);
return match col {
Some(col) => DataTableHit::Header { col },
None => DataTableHit::Empty,
};
}
let body_bottom = self.header_height + self.visible_rows as f32 * self.row_height;
if y < body_bottom {
let row_in_viewport = ((y - self.header_height) / self.row_height).floor() as usize;
let abs_idx = scroll_offset + row_in_viewport;
return if abs_idx < total_rows {
DataTableHit::Row { idx: abs_idx }
} else {
DataTableHit::Empty
};
}
if self.footer_height > 0.0 && y < body_bottom + self.footer_height {
return DataTableHit::Footer;
}
DataTableHit::Empty
}
pub fn column_hit(&self, x: f32) -> Option<usize> {
if self.in_v_scrollbar_strip(x) {
return None;
}
let cx = self.content_x(x);
self.columns
.iter()
.position(|c| cx >= c.x && cx < c.x + c.width)
}
pub fn drag_divider(
&self,
overrides: &[Option<f32>],
col: usize,
pointer_x: f32,
min_width: f32,
) -> Vec<Option<f32>> {
let mut next: Vec<Option<f32>> = if overrides.len() == self.columns.len() {
overrides.to_vec()
} else {
vec![None; self.columns.len()]
};
if col + 1 >= self.columns.len() {
return next;
}
for (i, rc) in self.columns.iter().enumerate() {
if next[i].is_none() {
next[i] = Some(rc.width);
}
}
let last = self.columns.len() - 1;
let min_width = min_width.max(0.0);
let pair = self.columns[col].width + self.columns[last].width;
let col_x = self.columns[col].x;
let target = (self.content_x(pointer_x) - col_x).max(min_width);
let last_width = (pair - target).max(min_width);
next[col] = Some(target);
next[last] = Some(last_width);
next
}
}
impl DataTable {
pub fn layout<F>(
&self,
viewport_width: f32,
viewport_height: f32,
row_height: f32,
header_height: f32,
scrollbar_width: f32,
measure: F,
) -> DataTableLayout
where
F: Fn(&Column) -> ColumnMeasure,
{
let sb_w = if self.show_scrollbar {
scrollbar_width
} else {
0.0
};
let visible_col_area = (viewport_width - sb_w).max(0.0);
let layout_width = match self.min_total_width {
Some(min) if min > visible_col_area => min,
_ => visible_col_area,
};
let resolved = resolve_columns(
&self.columns,
layout_width,
&measure,
&self.column_overrides,
);
let content_width = resolved.last().map(|c| c.x + c.width).unwrap_or(0.0);
let h_scrolling = content_width > visible_col_area + 0.5;
let h_sb_h = if h_scrolling {
if row_height > 1.5 {
(row_height * 0.5).round()
} else {
row_height
}
} else {
0.0
};
let footer_height = if self.footer.is_some() {
row_height * 2.0
} else {
0.0
};
let body_height = (viewport_height - header_height - h_sb_h - footer_height).max(0.0);
let visible_rows = if row_height > 0.0 {
(body_height / row_height).floor() as usize
} else {
0
};
DataTableLayout {
header_height,
row_height,
columns: resolved,
visible_rows,
viewport_width,
viewport_height,
scrollbar_width: sb_w,
content_width,
h_scrollbar_height: h_sb_h,
footer_height,
h_scroll: self.h_scroll,
}
}
}
fn resolve_columns<F>(
columns: &[Column],
viewport_width: f32,
measure: &F,
overrides: &[Option<f32>],
) -> Vec<ResolvedColumn>
where
F: Fn(&Column) -> ColumnMeasure,
{
if columns.is_empty() {
return Vec::new();
}
let mut widths: Vec<f32> = Vec::with_capacity(columns.len());
let mut remaining = viewport_width;
let mut total_flex = 0.0_f32;
for (i, col) in columns.iter().enumerate() {
if let Some(Some(ow)) = overrides.get(i) {
let w = ow.max(0.0);
widths.push(w);
remaining -= w;
continue;
}
match col.width {
ColumnWidth::Fixed(w) => {
let w = w.min(remaining).max(0.0);
widths.push(w);
remaining -= w;
}
ColumnWidth::Content { min, max } => {
let m = measure(col);
let w = m.content_width.clamp(min, max).min(remaining).max(0.0);
widths.push(w);
remaining -= w;
}
ColumnWidth::Flex(weight) => {
widths.push(0.0); total_flex += weight.max(0.0);
}
}
}
if total_flex > 0.0 && remaining > 0.0 {
for (i, col) in columns.iter().enumerate() {
if matches!(overrides.get(i), Some(Some(_))) {
continue;
}
if let ColumnWidth::Flex(weight) = col.width {
widths[i] = (weight.max(0.0) / total_flex) * remaining;
}
}
}
let mut x = 0.0_f32;
let mut resolved: Vec<ResolvedColumn> = widths
.iter()
.map(|&w| {
let rc = ResolvedColumn { x, width: w };
x += w;
rc
})
.collect();
if let Some(last) = resolved.last_mut() {
let shortfall = viewport_width - (last.x + last.width);
if shortfall > 0.0 {
last.width += shortfall;
}
}
resolved
}
#[cfg(any(feature = "win", all(feature = "macos", target_os = "macos")))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{ColumnAlign, DataTable, DataTableGlyphs, DataTableLayout, SortDirection};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::primitives::layout_metrics::{pixel_data_table_layout, TextMeasure};
use crate::primitives::scrollbar::Scrollbar;
use crate::theme::Theme;
use crate::types::Decoration;
const HOVER_ALPHA: f32 = 0.5;
struct SurfaceTextMeasure<'a>(&'a dyn PaintSurface);
impl TextMeasure for SurfaceTextMeasure<'_> {
fn width_of(&self, text: &str) -> f32 {
self.0.surface_measure_text(text).0
}
}
fn align_text_x(col_x: f32, col_w: f32, text_w: f32, align: ColumnAlign) -> f32 {
match align {
ColumnAlign::Left => col_x,
ColumnAlign::Center => col_x + (col_w - text_w) / 2.0,
ColumnAlign::Right => col_x + col_w - text_w,
}
}
pub(crate) fn paint(
table: &DataTable,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
line_height: f32,
hovered_idx: Option<usize>,
) -> DataTableLayout {
paint_with_glyphs(
table,
surface,
theme,
rect,
line_height,
hovered_idx,
None,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn paint_with_glyphs(
table: &DataTable,
surface: &mut dyn PaintSurface,
theme: &Theme,
rect: Rect,
line_height: f32,
hovered_idx: Option<usize>,
glyphs: Option<&DataTableGlyphs>,
nerd_fonts_enabled: bool,
) -> DataTableLayout {
let layout = {
let measure = SurfaceTextMeasure(&*surface);
pixel_data_table_layout(table, rect.width, rect.height, line_height, &measure)
};
if rect.width <= 0.0 || rect.height <= 0.0 {
return layout;
}
let h_off = table.h_scroll;
let header_height = layout.header_height;
let footer_h = layout.footer_height;
surface.surface_push_clip(rect);
surface.surface_fill_rect(
Rect::new(rect.x, rect.y, rect.width, header_height),
theme.tab_bar_bg,
);
let sort_ascending_glyph: std::borrow::Cow<'_, str> = glyphs
.and_then(DataTableGlyphs::sort_ascending_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(
crate::codicon::SORT_ASCENDING.to_string(),
));
let sort_descending_glyph: std::borrow::Cow<'_, str> = glyphs
.and_then(DataTableGlyphs::sort_descending_glyph)
.map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
.unwrap_or(std::borrow::Cow::Owned(
crate::codicon::SORT_DESCENDING.to_string(),
));
for (col_idx, rc) in layout.columns.iter().enumerate() {
let Some(col) = table.columns.get(col_idx) else {
break;
};
if rc.width <= 0.0 {
continue;
}
let sort_suffix: Option<&str> = match &table.sort {
Some((si, dir)) if *si == col_idx => Some(match dir {
SortDirection::Ascending => sort_ascending_glyph.as_ref(),
SortDirection::Descending => sort_descending_glyph.as_ref(),
}),
_ => None,
};
let title = match sort_suffix {
Some(glyph) => format!("{} {glyph}", col.title),
None => col.title.clone(),
};
let col_x = rect.x + rc.x - h_off;
let col_w = rc.width;
surface.surface_push_clip(Rect::new(col_x, rect.y, col_w, header_height));
let (tw, th) = surface.surface_measure_text_styled(&title, true);
let text_x = align_text_x(col_x, col_w, tw, col.align);
surface.surface_draw_text_run_styled(
Rect::new(text_x, rect.y, tw.max(1.0), th.max(1.0)),
&title,
theme.foreground,
true,
false,
false,
1.0,
);
surface.surface_pop_clip();
}
for (col_idx, rc) in layout.columns.iter().enumerate() {
if col_idx + 1 >= layout.columns.len() {
break;
}
let sep_x = rect.x + rc.x + rc.width - h_off;
surface.surface_fill_rect(
Rect::new(sep_x - 0.5, rect.y, 1.0, header_height),
theme.separator,
);
}
let body_y = rect.y + header_height;
let visible = layout
.visible_rows
.min(table.rows.len().saturating_sub(table.scroll_offset));
for row_idx in 0..visible {
let abs_idx = table.scroll_offset + row_idx;
let row = &table.rows[abs_idx];
let row_y = body_y + row_idx as f32 * line_height;
let is_selected = table.selected_idx == Some(abs_idx);
let is_hovered = hovered_idx == Some(abs_idx) && !is_selected;
if is_selected {
surface.surface_fill_rect_alpha(
Rect::new(rect.x, row_y, rect.width, line_height),
theme.selection_bg,
theme.selection_alpha,
);
} else if is_hovered {
surface.surface_fill_rect_alpha(
Rect::new(rect.x, row_y, rect.width, line_height),
theme.tab_bar_bg,
HOVER_ALPHA,
);
}
let is_muted = row.decoration == Decoration::Muted;
for (col_idx, rc) in layout.columns.iter().enumerate() {
let Some(styled) = row.cells.get(col_idx).filter(|c| !c.spans.is_empty()) else {
continue;
};
if rc.width <= 0.0 {
continue;
}
let col_x = rect.x + rc.x - h_off;
let col_w = rc.width;
surface.surface_push_clip(Rect::new(col_x, row_y, col_w, line_height));
let full_text: String = styled.spans.iter().map(|s| s.text.as_str()).collect();
let (tw, th) = surface.surface_measure_text(&full_text);
let align = table
.columns
.get(col_idx)
.map(|c| c.align)
.unwrap_or(ColumnAlign::Left);
let text_x = align_text_x(col_x, col_w, tw, align);
let text_y = row_y + (line_height - th) / 2.0;
if is_muted {
surface.surface_draw_text_run(
Rect::new(text_x, text_y, tw.max(1.0), th.max(1.0)),
&full_text,
theme.muted_fg,
);
} else {
let mut run_x = text_x;
for span in &styled.spans {
let (sw, sh) = surface.surface_measure_text(&span.text);
let span_fg = span.fg.unwrap_or(theme.foreground);
surface.surface_draw_text_run(
Rect::new(run_x, text_y, sw.max(1.0), sh.max(1.0)),
&span.text,
span_fg,
);
run_x += sw;
}
}
surface.surface_pop_clip();
}
for (col_idx, rc) in layout.columns.iter().enumerate() {
if col_idx + 1 >= layout.columns.len() || rc.width <= 0.0 {
continue;
}
let sep_x = rect.x + rc.x + rc.width - h_off;
surface.surface_fill_rect(
Rect::new(sep_x - 0.5, row_y, 1.0, line_height),
theme.separator,
);
}
}
if table.show_scrollbar
&& table.rows.len() > layout.visible_rows
&& layout.scrollbar_width > 0.0
{
let sb_x = rect.x + rect.width - layout.scrollbar_width;
let track = Rect::new(
sb_x,
rect.y + header_height,
layout.scrollbar_width,
(rect.height - header_height - footer_h).max(0.0),
);
let sb = Scrollbar::vertical(
table.id,
track,
table.scroll_offset as f32,
table.rows.len() as f32,
layout.visible_rows as f32,
line_height,
);
crate::primitives::scrollbar::native_surface_paint::paint(&sb, surface, theme);
}
if layout.h_scrollbar_height > 0.0 && layout.content_width > 0.0 {
let hsb_y = rect.y + rect.height - footer_h - layout.h_scrollbar_height;
let track_w = (rect.width - layout.scrollbar_width).max(1.0);
let track = Rect::new(rect.x, hsb_y, track_w, layout.h_scrollbar_height);
let sb = Scrollbar::horizontal(
table.id,
track,
table.h_scroll,
layout.content_width,
track_w,
line_height,
);
crate::primitives::scrollbar::native_surface_paint::paint(&sb, surface, theme);
}
if let Some(footer) = &table.footer {
if footer_h > 0.0 {
let footer_band_top = rect.y + rect.height - footer_h;
let footer_y = rect.y + rect.height - line_height;
surface.surface_fill_rect(
Rect::new(rect.x, footer_band_top - 0.5, rect.width, 1.0),
theme.separator,
);
surface.surface_fill_rect(
Rect::new(rect.x, footer_band_top, rect.width, footer_h),
theme.tab_bar_bg,
);
for (col_idx, rc) in layout.columns.iter().enumerate() {
let Some(styled) = footer.cells.get(col_idx).filter(|c| !c.spans.is_empty())
else {
continue;
};
if rc.width <= 0.0 {
continue;
}
let col_x = rect.x + rc.x - h_off;
let col_w = rc.width;
surface.surface_push_clip(Rect::new(col_x, footer_y, col_w, line_height));
let full_text: String = styled.spans.iter().map(|s| s.text.as_str()).collect();
let (tw, _th) = surface.surface_measure_text_styled(&full_text, true);
let align = table
.columns
.get(col_idx)
.map(|c| c.align)
.unwrap_or(ColumnAlign::Left);
let text_x = align_text_x(col_x, col_w, tw, align);
let mut run_x = text_x;
for span in &styled.spans {
let (sw, sh) = surface.surface_measure_text_styled(&span.text, true);
let span_fg = span.fg.unwrap_or(theme.foreground);
surface.surface_draw_text_run_styled(
Rect::new(run_x, footer_y, sw.max(1.0), sh.max(1.0)),
&span.text,
span_fg,
true,
false,
false,
1.0,
);
run_x += sw;
}
surface.surface_pop_clip();
}
}
}
surface.surface_pop_clip();
layout
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::primitives::data_table::{Column, ColumnWidth, DataRow};
use crate::theme::Theme;
use crate::types::{Color, StyledSpan, StyledText, WidgetId};
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
fills_alpha: Vec<(Rect, Color, f32)>,
text_runs: Vec<(Rect, String, Color)>,
styled_runs: Vec<(Rect, String, Color, bool)>,
clip_pushes: Vec<Rect>,
clip_pops: usize,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: crate::Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> crate::Viewport {
crate::Viewport::new(300.0, 200.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 16.0)
}
fn surface_measure_text_styled(&self, text: &str, _bold: bool) -> (f32, f32) {
self.surface_measure_text(text)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rect_alpha(&mut self, rect: Rect, color: Color, alpha: f32) {
self.fills_alpha.push((rect, color, alpha));
}
fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.text_runs.push((rect, text.to_string(), color));
}
#[allow(clippy::too_many_arguments)]
fn surface_draw_text_run_styled(
&mut self,
rect: Rect,
text: &str,
color: Color,
bold: bool,
_italic: bool,
_underline: bool,
_scale_x: f32,
) {
self.styled_runs.push((rect, text.to_string(), color, bold));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clip_pushes.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clip_pops += 1;
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn two_col_table() -> DataTable {
DataTable {
id: WidgetId::new("dt"),
columns: vec![
Column {
title: "Name".into(),
width: ColumnWidth::Flex(2.0),
align: ColumnAlign::Left,
},
Column {
title: "Value".into(),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Right,
},
],
rows: vec![
DataRow {
cells: vec![StyledText::plain("alpha"), StyledText::plain("1")],
decoration: Decoration::Normal,
},
DataRow {
cells: vec![StyledText::plain("beta"), StyledText::plain("2")],
decoration: Decoration::Normal,
},
],
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn header_bg_then_body_paint_in_order() {
let table = two_col_table();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, None);
assert_eq!(surface.fills[0].1, theme.tab_bar_bg);
assert!(surface.styled_runs.iter().any(|r| r.1 == "Name" && r.3));
assert!(surface.styled_runs.iter().any(|r| r.1 == "Value" && r.3));
assert!(surface.text_runs.iter().any(|r| r.1 == "alpha"));
assert!(surface.text_runs.iter().any(|r| r.1 == "beta"));
}
#[test]
fn sorted_column_title_carries_the_codicon_sort_glyph_not_a_plain_triangle() {
let theme = Theme::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let mut ascending = two_col_table();
ascending.sort = Some((0, SortDirection::Ascending));
let mut surface = RecordingSurface::default();
paint(&ascending, &mut surface, &theme, rect, 16.0, None);
let expected_asc = format!("Name {}", crate::codicon::SORT_ASCENDING);
assert!(
surface.styled_runs.iter().any(|r| r.1 == expected_asc),
"expected a header run {:?}, got {:?}",
expected_asc,
surface.styled_runs
);
assert!(!surface.styled_runs.iter().any(|r| r.1.contains('\u{25B2}')));
let mut descending = two_col_table();
descending.sort = Some((0, SortDirection::Descending));
let mut surface = RecordingSurface::default();
paint(&descending, &mut surface, &theme, rect, 16.0, None);
let expected_desc = format!("Name {}", crate::codicon::SORT_DESCENDING);
assert!(surface.styled_runs.iter().any(|r| r.1 == expected_desc));
assert!(!surface.styled_runs.iter().any(|r| r.1.contains('\u{25BC}')));
}
#[test]
fn data_table_glyphs_override_swaps_sort_glyphs_default_keeps_codicon() {
let theme = Theme::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let mut ascending = two_col_table();
ascending.sort = Some((0, SortDirection::Ascending));
let glyphs = DataTableGlyphs::new().with_sort_ascending_glyph("^");
let mut surface = RecordingSurface::default();
paint_with_glyphs(
&ascending,
&mut surface,
&theme,
rect,
16.0,
None,
Some(&glyphs),
true,
);
assert!(
surface.styled_runs.iter().any(|r| r.1 == "Name ^"),
"expected the overridden glyph among header runs, got {:?}",
surface.styled_runs
);
assert!(!surface
.styled_runs
.iter()
.any(|r| r.1.contains(crate::codicon::SORT_ASCENDING)));
let mut default_surface = RecordingSurface::default();
paint_with_glyphs(
&ascending,
&mut default_surface,
&theme,
rect,
16.0,
None,
None,
false,
);
let expected_default = format!("Name {}", crate::codicon::SORT_ASCENDING);
assert!(default_surface
.styled_runs
.iter()
.any(|r| r.1 == expected_default));
}
#[test]
fn selected_row_uses_alpha_blended_fill_not_opaque() {
let mut table = two_col_table();
table.selected_idx = Some(0);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, None);
assert_eq!(surface.fills_alpha.len(), 1);
assert_eq!(surface.fills_alpha[0].1, theme.selection_bg);
assert_eq!(surface.fills_alpha[0].2, theme.selection_alpha);
assert!(
surface.fills.iter().all(|(_, c)| *c != theme.selection_bg),
"selection colour must only be painted through the alpha-blended fill"
);
}
#[test]
fn hovered_non_selected_row_uses_alpha_blended_tab_bar_bg() {
let table = two_col_table();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, Some(1));
assert_eq!(surface.fills_alpha.len(), 1);
assert_eq!(surface.fills_alpha[0].1, theme.tab_bar_bg);
assert_eq!(surface.fills_alpha[0].2, HOVER_ALPHA);
}
#[test]
fn selected_row_takes_priority_over_hover() {
let mut table = two_col_table();
table.selected_idx = Some(0);
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, Some(0));
assert_eq!(surface.fills_alpha.len(), 1);
assert_eq!(surface.fills_alpha[0].1, theme.selection_bg);
}
#[test]
fn footer_honours_per_span_fg_override() {
let mut table = two_col_table();
table.footer = Some(DataRow {
cells: vec![
StyledText {
spans: vec![StyledSpan {
text: "Total".into(),
fg: Some(Color::rgb(255, 0, 0)),
bg: None,
bold: false,
italic: false,
underline: false,
}],
},
StyledText::plain("3"),
],
decoration: Decoration::Normal,
});
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, None);
let total_run = surface
.styled_runs
.iter()
.find(|r| r.1 == "Total")
.expect("footer 'Total' span should paint as its own styled run");
assert_eq!(total_run.2, Color::rgb(255, 0, 0));
assert!(total_run.3, "footer text paints bold");
}
#[test]
fn muted_row_ignores_per_span_fg_and_uses_muted_fg() {
let mut table = two_col_table();
table.rows[0] = DataRow {
cells: vec![
StyledText {
spans: vec![StyledSpan {
text: "alpha".into(),
fg: Some(Color::rgb(255, 0, 0)),
bg: None,
bold: false,
italic: false,
underline: false,
}],
},
StyledText::plain("1"),
],
decoration: Decoration::Muted,
};
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
paint(&table, &mut surface, &theme, rect, 16.0, None);
let alpha_run = surface
.text_runs
.iter()
.find(|r| r.1 == "alpha")
.expect("muted row should still paint its text");
assert_eq!(alpha_run.2, theme.muted_fg);
}
#[test]
fn zero_size_rect_paints_nothing() {
let table = two_col_table();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
paint(
&table,
&mut surface,
&theme,
Rect::new(0.0, 0.0, 0.0, 100.0),
16.0,
None,
);
assert!(surface.fills.is_empty());
assert!(surface.text_runs.is_empty());
assert!(surface.styled_runs.is_empty());
assert_eq!(surface.clip_pops, 0);
}
#[test]
fn body_and_header_separators_share_the_half_pixel_inset() {
let table = two_col_table();
let theme = Theme::default();
let mut surface = RecordingSurface::default();
let rect = Rect::new(0.0, 0.0, 200.0, 100.0);
let layout = paint(&table, &mut surface, &theme, rect, 16.0, None);
let expected_sep_x = layout.columns[0].x + layout.columns[0].width - 0.5;
let sep_fills: Vec<_> = surface
.fills
.iter()
.filter(|(r, c)| *c == theme.separator && (r.x - expected_sep_x).abs() < 0.01)
.collect();
assert_eq!(sep_fills.len(), 3);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::StyledText;
fn make_table(ncols: usize, nrows: usize) -> DataTable {
let columns: Vec<Column> = (0..ncols)
.map(|i| Column {
title: format!("Col{i}"),
width: ColumnWidth::Flex(1.0),
align: ColumnAlign::Left,
})
.collect();
let rows: Vec<DataRow> = (0..nrows)
.map(|r| DataRow {
cells: (0..ncols)
.map(|c| StyledText::plain(format!("r{r}c{c}")))
.collect(),
decoration: Decoration::Normal,
})
.collect();
DataTable {
id: WidgetId::new("test"),
columns,
rows,
selected_idx: None,
scroll_offset: 0,
sort: None,
has_focus: false,
show_scrollbar: false,
min_total_width: None,
h_scroll: 0.0,
column_overrides: Vec::new(),
footer: None,
}
}
#[test]
fn flex_columns_share_space_equally() {
let table = make_table(4, 0);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(10.0));
assert_eq!(layout.columns.len(), 4);
for rc in &layout.columns {
assert!(
(rc.width - 20.0).abs() < 0.01,
"expected 20.0, got {}",
rc.width
);
}
assert!((layout.columns[0].x - 0.0).abs() < 0.01);
assert!((layout.columns[1].x - 20.0).abs() < 0.01);
assert!((layout.columns[2].x - 40.0).abs() < 0.01);
assert!((layout.columns[3].x - 60.0).abs() < 0.01);
}
#[test]
fn fixed_column_takes_exact_width() {
let mut table = make_table(3, 0);
table.columns[0].width = ColumnWidth::Fixed(10.0);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!((layout.columns[0].width - 10.0).abs() < 0.01);
assert!((layout.columns[1].width - 35.0).abs() < 0.01);
assert!((layout.columns[2].width - 35.0).abs() < 0.01);
}
#[test]
fn content_column_clamps_to_min_max() {
let mut table = make_table(2, 0);
table.columns[0].width = ColumnWidth::Content {
min: 5.0,
max: 15.0,
};
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(3.0));
assert!((layout.columns[0].width - 5.0).abs() < 0.01);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(20.0));
assert!((layout.columns[0].width - 15.0).abs() < 0.01);
}
#[test]
fn visible_rows_computed_from_body_height() {
let table = make_table(2, 100);
let layout = table.layout(80.0, 25.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.visible_rows, 24);
}
#[test]
fn hit_test_header() {
let table = make_table(3, 10);
let layout = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(
layout.hit_test(45.0, 0.5, 0, 10),
DataTableHit::Header { col: 1 }
);
}
#[test]
fn hit_test_row() {
let table = make_table(3, 10);
let layout = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(
layout.hit_test(10.0, 3.5, 0, 10),
DataTableHit::Row { idx: 2 }
);
assert_eq!(
layout.hit_test(10.0, 3.5, 5, 10),
DataTableHit::Row { idx: 7 }
);
}
#[test]
fn hit_test_empty_below_rows() {
let table = make_table(2, 3);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.hit_test(10.0, 10.0, 0, 3), DataTableHit::Empty);
}
#[test]
fn hit_test_outside_viewport() {
let table = make_table(2, 10);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.hit_test(-1.0, 5.0, 0, 10), DataTableHit::Empty);
assert_eq!(layout.hit_test(5.0, -1.0, 0, 10), DataTableHit::Empty);
assert_eq!(layout.hit_test(80.0, 5.0, 0, 10), DataTableHit::Empty);
assert_eq!(layout.hit_test(5.0, 20.0, 0, 10), DataTableHit::Empty);
}
#[test]
fn weighted_flex_distributes_proportionally() {
let mut table = make_table(3, 0);
table.columns[0].width = ColumnWidth::Flex(1.0);
table.columns[1].width = ColumnWidth::Flex(2.0);
table.columns[2].width = ColumnWidth::Flex(1.0);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!((layout.columns[0].width - 20.0).abs() < 0.01);
assert!((layout.columns[1].width - 40.0).abs() < 0.01);
assert!((layout.columns[2].width - 20.0).abs() < 0.01);
}
#[test]
fn override_on_flex_column_is_not_clobbered_by_flex_redistribution() {
let table = make_table(3, 0);
let mut overrides = vec![None; 3];
overrides[1] = Some(45.0_f32);
let layout = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(layout.columns[1].width - 30.0).abs() < 0.01,
"sanity: unoverridden layout gives each Flex(1.0) column an equal 30.0 share"
);
let mut table = table;
table.column_overrides = overrides;
let layout = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(layout.columns[1].width - 45.0).abs() < 0.01,
"override should win outright, not get re-derived by flex redistribution: \
expected 45.0, got {}",
layout.columns[1].width
);
}
#[test]
fn divider_before_last_column_resizes_in_drag_direction() {
let table = make_table(3, 0);
let baseline = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let baseline_w = baseline.columns[1].width;
let mut widen = table.clone();
widen.column_overrides = vec![None, Some(baseline_w + 20.0), None];
let widen_layout = widen.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let mut narrow = table.clone();
narrow.column_overrides = vec![None, Some((baseline_w - 20.0).max(1.0)), None];
let narrow_layout = narrow.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
widen_layout.columns[1].width > baseline_w,
"dragging the divider right (larger override) must widen the column: \
baseline={baseline_w}, widened={}",
widen_layout.columns[1].width
);
assert!(
narrow_layout.columns[1].width < baseline_w,
"dragging the divider left (smaller override) must narrow the column: \
baseline={baseline_w}, narrowed={}",
narrow_layout.columns[1].width
);
}
fn make_sample_shaped_table() -> DataTable {
let mut table = make_table(4, 0);
table.columns[0].width = ColumnWidth::Flex(3.0);
table.columns[1].width = ColumnWidth::Flex(1.5);
table.columns[2].width = ColumnWidth::Flex(0.5);
table.columns[3].width = ColumnWidth::Fixed(10.0);
table
}
#[test]
fn drag_divider_moves_only_the_two_columns_it_separates() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let pointer_x = baseline.columns[2].x + baseline.columns[2].width + 5.0;
let overrides = baseline.drag_divider(&[], 2, pointer_x, 4.0);
let mut dragged = table.clone();
dragged.column_overrides = overrides;
let after = dragged.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(
baseline.columns[0], after.columns[0],
"column 0 must be untouched"
);
assert_eq!(
baseline.columns[1], after.columns[1],
"column 1 must be untouched"
);
assert_eq!(
baseline.columns[2].x, after.columns[2].x,
"dragged column's left edge must not move"
);
assert!(
after.columns[2].width > baseline.columns[2].width,
"dragging the divider right must widen the column to its left"
);
let baseline_pair = baseline.columns[2].width + baseline.columns[3].width;
let after_pair = after.columns[2].width + after.columns[3].width;
assert!(
(baseline_pair - after_pair).abs() < 0.01,
"pair's combined width must be conserved: before={baseline_pair}, after={after_pair}"
);
}
#[test]
fn drag_divider_result_is_independent_of_prior_drag_history() {
let table = make_sample_shaped_table();
let fresh = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let a_pointer = fresh.columns[2].x + fresh.columns[2].width + 15.0;
let a_overrides = fresh.drag_divider(&[], 2, a_pointer, 4.0);
let mut a_table = table.clone();
a_table.column_overrides = a_overrides;
let a_after = a_table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let a_delta = a_after.columns[2].width - fresh.columns[2].width;
let b_pointer0 = fresh.columns[0].x + fresh.columns[0].width - 8.0;
let b_overrides0 = fresh.drag_divider(&[], 0, b_pointer0, 4.0);
let mut b_table0 = table.clone();
b_table0.column_overrides = b_overrides0;
let b_mid = b_table0.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let b_pointer2 = b_mid.columns[2].x + b_mid.columns[2].width + 15.0;
let b_overrides2 = b_mid.drag_divider(&b_table0.column_overrides, 2, b_pointer2, 4.0);
let mut b_table2 = table.clone();
b_table2.column_overrides = b_overrides2;
let b_after = b_table2.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let b_delta = b_after.columns[2].width - b_mid.columns[2].width;
assert!(
(a_delta - b_delta).abs() < 0.01,
"the same +15 divider-2 drag must produce the same width delta \
regardless of whether divider 0 was dragged first: \
a_delta={a_delta}, b_delta={b_delta}"
);
}
#[test]
fn drag_divider_redundant_replay_matches_the_equivalent_clean_order() {
let table = make_sample_shaped_table();
let fresh = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let target_x_width = fresh.columns[0].width + 1.0;
let target_y_width = fresh.columns[1].width + 1.0;
fn drag_to(
on: &DataTableLayout,
overrides: &[Option<f32>],
col: usize,
target_width: f32,
) -> Vec<Option<f32>> {
let pointer_x = on.columns[col].x + target_width;
on.drag_divider(overrides, col, pointer_x, 4.0)
}
let mut seq1 = table.clone();
let ov = drag_to(&fresh, &[], 0, target_x_width);
seq1.column_overrides = ov;
let after_x = seq1.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let ov = drag_to(&after_x, &seq1.column_overrides, 1, target_y_width);
seq1.column_overrides = ov;
let after_y = seq1.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let ov = drag_to(&after_y, &seq1.column_overrides, 0, target_x_width);
seq1.column_overrides = ov;
let seq1_final = seq1.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let mut seq2 = table.clone();
let ov = drag_to(&fresh, &[], 1, target_y_width);
seq2.column_overrides = ov;
let after_y2 = seq2.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let ov = drag_to(&after_y2, &seq2.column_overrides, 0, target_x_width);
seq2.column_overrides = ov;
let seq2_final = seq2.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
for i in 0..seq1_final.columns.len() {
assert!(
(seq1_final.columns[i].width - seq2_final.columns[i].width).abs() < 0.01,
"column {i} must match between [X, Y, X-again] and [Y, X]: \
seq1={}, seq2={}",
seq1_final.columns[i].width,
seq2_final.columns[i].width
);
}
}
#[test]
fn drag_divider_stops_at_minimum_without_displacing_other_columns() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let overrides = baseline.drag_divider(&[], 2, -1000.0, 4.0);
let mut dragged = table.clone();
dragged.column_overrides = overrides;
let after = dragged.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(after.columns[2].width - 4.0).abs() < 0.01,
"col 2 should stop at its 4.0 minimum, got {}",
after.columns[2].width
);
assert_eq!(baseline.columns[0], after.columns[0]);
assert_eq!(baseline.columns[1], after.columns[1]);
let pair_total = baseline.columns[2].width + baseline.columns[3].width;
assert!((after.columns[3].width - (pair_total - 4.0)).abs() < 0.01);
}
#[test]
fn drag_divider_widen_takes_slack_from_the_last_column_not_the_neighbour() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let pointer_x = baseline.columns[0].x + baseline.columns[0].width + 4.0;
let overrides = baseline.drag_divider(&[], 0, pointer_x, 4.0);
let mut dragged = table.clone();
dragged.column_overrides = overrides;
let after = dragged.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(after.columns[0].width - (baseline.columns[0].width + 4.0)).abs() < 0.01,
"col 0 should widen by exactly 4.0, got {}",
after.columns[0].width
);
assert!(
(after.columns[3].width - (baseline.columns[3].width - 4.0)).abs() < 0.01,
"col 3 (the last column) should absorb the 4.0, got {}",
after.columns[3].width
);
assert!(
(after.columns[1].width - baseline.columns[1].width).abs() < 0.01,
"col 1 sits strictly between the dragged column and the last one — untouched"
);
assert!(
(after.columns[2].width - baseline.columns[2].width).abs() < 0.01,
"col 2 sits strictly between the dragged column and the last one — untouched"
);
let baseline_total: f32 = baseline.columns.iter().map(|c| c.width).sum();
let after_total: f32 = after.columns.iter().map(|c| c.width).sum();
assert!(
(baseline_total - after_total).abs() < 0.01,
"total content width must stay constant while the last column has room: \
before={baseline_total}, after={after_total}"
);
assert!(
after.h_scrollbar_height == 0.0 && after.content_width <= after.viewport_width + 0.5,
"no overflow yet — the last column still had room"
);
}
#[test]
fn drag_divider_shrink_returns_slack_to_the_last_column_not_the_neighbour() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let pointer_x = baseline.columns[0].x + baseline.columns[0].width - 4.0;
let overrides = baseline.drag_divider(&[], 0, pointer_x, 4.0);
let mut dragged = table.clone();
dragged.column_overrides = overrides;
let after = dragged.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(after.columns[0].width - (baseline.columns[0].width - 4.0)).abs() < 0.01,
"col 0 should narrow by exactly 4.0, got {}",
after.columns[0].width
);
assert!(
(after.columns[3].width - (baseline.columns[3].width + 4.0)).abs() < 0.01,
"col 3 (the last column) should grow by the freed 4.0, got {}",
after.columns[3].width
);
assert!(
(after.columns[1].width - baseline.columns[1].width).abs() < 0.01,
"col 1 sits strictly between the dragged column and the last one — untouched"
);
assert!(
(after.columns[2].width - baseline.columns[2].width).abs() < 0.01,
"col 2 sits strictly between the dragged column and the last one — untouched"
);
let baseline_total: f32 = baseline.columns.iter().map(|c| c.width).sum();
let after_total: f32 = after.columns.iter().map(|c| c.width).sum();
assert!(
(baseline_total - after_total).abs() < 0.01,
"total content width must stay constant: before={baseline_total}, after={after_total}"
);
}
#[test]
fn drag_divider_overflows_once_the_last_column_bottoms_out() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let pointer_x = baseline.columns[0].x + baseline.columns[0].width + 20.0;
let overrides = baseline.drag_divider(&[], 0, pointer_x, 4.0);
let mut dragged = table.clone();
dragged.column_overrides = overrides;
let after = dragged.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(after.columns[0].width - (baseline.columns[0].width + 20.0)).abs() < 0.01,
"col 0 gets everything it asked for — it's the table that overflows, not the drag \
that's refused: got {}",
after.columns[0].width
);
assert!(
(after.columns[3].width - 4.0).abs() < 0.01,
"col 3 (the last column) stops dead at its 4.0 floor, got {}",
after.columns[3].width
);
assert!(
(after.columns[1].width - baseline.columns[1].width).abs() < 0.01,
"col 1 must still be untouched even while the table overflows"
);
assert!(
(after.columns[2].width - baseline.columns[2].width).abs() < 0.01,
"col 2 (second-from-last) must NOT be recruited to absorb the overflow — only the \
last column ever does"
);
let baseline_total: f32 = baseline.columns.iter().map(|c| c.width).sum();
let after_total: f32 = after.columns.iter().map(|c| c.width).sum();
assert!(
(after_total - (baseline_total + 14.0)).abs() < 0.01,
"content width should grow by exactly the 14.0 excess: before={baseline_total}, \
after={after_total}"
);
assert!(
after.content_width > after.viewport_width,
"content_width must exceed the viewport once overflowed: content_width={}, \
viewport_width={}",
after.content_width,
after.viewport_width
);
assert!(
after.h_scrollbar_height > 0.0,
"the horizontal scrollbar must appear once the table overflows"
);
}
#[test]
fn drag_divider_overflow_is_reversible_back_to_the_original_pointer_position() {
let table = make_sample_shaped_table();
let baseline = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let original_pointer_x = baseline.columns[0].x + baseline.columns[0].width;
let overflow_pointer_x = original_pointer_x + 20.0;
let out_overrides = baseline.drag_divider(&[], 0, overflow_pointer_x, 4.0);
let mut out_table = table.clone();
out_table.column_overrides = out_overrides.clone();
let out_layout = out_table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
out_layout.h_scrollbar_height > 0.0,
"test precondition: the outward drag must overflow"
);
let back_overrides = baseline.drag_divider(&out_overrides, 0, original_pointer_x, 4.0);
let mut back_table = table.clone();
back_table.column_overrides = back_overrides;
let back_layout =
back_table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
for i in 0..baseline.columns.len() {
assert!(
(back_layout.columns[i].width - baseline.columns[i].width).abs() < 0.01,
"column {i} must return to its baseline width: baseline={}, after round-trip={}",
baseline.columns[i].width,
back_layout.columns[i].width
);
}
assert_eq!(
back_layout.h_scrollbar_height, 0.0,
"h_scrolling must turn back off once the drag returns to its starting position"
);
assert!(
(back_layout.content_width - baseline.content_width).abs() < 0.01,
"content_width must return to its baseline value: baseline={}, after round-trip={}",
baseline.content_width,
back_layout.content_width
);
}
#[test]
fn overriding_every_flex_column_still_fills_the_viewport() {
let mut table = make_sample_shaped_table();
table.column_overrides = vec![Some(20.0), Some(15.0), Some(8.0), None];
let layout = table.layout(100.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let total_width: f32 = layout.columns.iter().map(|c| c.width).sum();
assert!(
(total_width - 100.0).abs() < 0.01,
"resolved widths must still sum to the viewport width, got {total_width}"
);
let last = layout.columns.last().unwrap();
assert!(
(last.x + last.width - 100.0).abs() < 0.01,
"the rightmost column's right edge must be flush with the viewport's right edge"
);
}
#[test]
fn fill_never_shrinks_content_that_legitimately_overflows_the_viewport() {
let mut table = make_table(3, 0);
table.columns[0].width = ColumnWidth::Fixed(50.0);
table.columns[1].width = ColumnWidth::Fixed(50.0);
table.columns[2].width = ColumnWidth::Fixed(50.0);
table.min_total_width = Some(150.0);
let layout = table.layout(40.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(
(layout.columns[2].width - 50.0).abs() < 0.01,
"fixed columns legitimately exceeding the viewport must not be squeezed by the \
fill: got {}",
layout.columns[2].width
);
assert!(
layout.content_width > layout.viewport_width,
"content legitimately exceeding the viewport must still be reported as overflowing \
(h-scroll), not squeezed to fit: content_width={}, viewport_width={}",
layout.content_width,
layout.viewport_width
);
}
#[test]
fn empty_table_layout_is_valid() {
let table = make_table(0, 0);
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert!(layout.columns.is_empty());
assert_eq!(layout.visible_rows, 19);
}
#[test]
fn serde_round_trip() {
let table = make_table(2, 3);
let json = serde_json::to_string(&table).unwrap();
let back: DataTable = serde_json::from_str(&json).unwrap();
assert_eq!(table, back);
}
fn footer_row(ncols: usize) -> DataRow {
DataRow {
cells: (0..ncols)
.map(|c| StyledText::plain(format!("total{c}")))
.collect(),
decoration: Decoration::Normal,
}
}
#[test]
fn none_footer_is_byte_identical_to_pre_change_layout() {
let table = make_table(2, 100);
let layout = table.layout(80.0, 25.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.footer_height, 0.0);
assert_eq!(layout.visible_rows, 24);
}
#[test]
fn footer_reserves_height_and_shrinks_visible_rows() {
let mut table = make_table(2, 100);
table.footer = Some(footer_row(2));
let layout = table.layout(80.0, 25.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.footer_height, 2.0);
assert_eq!(layout.visible_rows, 22);
}
#[test]
fn footer_columns_align_with_body_columns() {
let mut table = make_table(3, 10);
table.footer = Some(footer_row(3));
let layout = table.layout(90.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
assert_eq!(layout.columns.len(), 3);
assert!((layout.columns[0].x - 0.0).abs() < 0.01);
assert!((layout.columns[1].x - 30.0).abs() < 0.01);
assert!((layout.columns[2].x - 60.0).abs() < 0.01);
}
#[test]
fn hit_test_footer_is_pinned_regardless_of_scroll_offset() {
let mut table = make_table(2, 100);
table.footer = Some(footer_row(2));
let layout = table.layout(80.0, 25.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
for scroll_offset in [0, 5, 50, 76] {
assert_eq!(
layout.hit_test(10.0, 24.0, scroll_offset, 100),
DataTableHit::Footer,
"footer hit should be stable at scroll_offset={scroll_offset}"
);
}
}
#[test]
fn hit_test_footer_is_not_a_row() {
let mut table = make_table(2, 3);
table.footer = Some(footer_row(2));
let layout = table.layout(80.0, 20.0, 1.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let body_bottom = layout.header_height + layout.visible_rows as f32 * layout.row_height;
let hit = layout.hit_test(10.0, body_bottom + 0.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Footer);
}
#[test]
fn hit_test_same_band_is_empty_without_footer() {
let table = make_table(2, 3);
let layout = table.layout(80.0, 20.0, 3.0, 1.0, 0.0, |_| ColumnMeasure::new(0.0));
let body_bottom = layout.header_height + layout.visible_rows as f32 * layout.row_height;
assert!(
body_bottom + 0.5 < layout.viewport_height,
"test setup should leave a real gap below the last visible row"
);
let hit = layout.hit_test(10.0, body_bottom + 0.5, 0, table.rows.len());
assert_eq!(hit, DataTableHit::Empty);
}
#[test]
fn footer_serde_round_trip() {
let mut table = make_table(2, 3);
table.footer = Some(footer_row(2));
let json = serde_json::to_string(&table).unwrap();
let back: DataTable = serde_json::from_str(&json).unwrap();
assert_eq!(table, back);
}
fn make_wide_table(nrows: usize) -> DataTable {
let mut table = make_table(4, nrows);
for c in &mut table.columns {
c.width = ColumnWidth::Fixed(30.0);
}
table.min_total_width = Some(120.0);
table
}
fn wide_layout(h_scroll: f32, nrows: usize) -> DataTableLayout {
let mut table = make_wide_table(nrows);
table.h_scroll = h_scroll;
table.layout(60.0, 20.0, 1.0, 1.0, 1.0, |_| ColumnMeasure::new(0.0))
}
fn legacy_hit_test(
l: &DataTableLayout,
x: f32,
y: f32,
scroll_offset: usize,
total_rows: usize,
) -> DataTableHit {
if x < 0.0 || y < 0.0 || x >= l.viewport_width || y >= l.viewport_height {
return DataTableHit::Empty;
}
if y < l.header_height {
for (i, rc) in l.columns.iter().enumerate() {
let right_edge = rc.x + rc.width;
if (x - right_edge).abs() <= DIVIDER_GRAB_PX && i + 1 < l.columns.len() {
return DataTableHit::HeaderDivider { col: i };
}
}
return match l.columns.iter().position(|c| x >= c.x && x < c.x + c.width) {
Some(col) => DataTableHit::Header { col },
None => DataTableHit::Empty,
};
}
let body_bottom = l.header_height + l.visible_rows as f32 * l.row_height;
if y < body_bottom {
let row_in_viewport = ((y - l.header_height) / l.row_height).floor() as usize;
let abs_idx = scroll_offset + row_in_viewport;
return if abs_idx < total_rows {
DataTableHit::Row { idx: abs_idx }
} else {
DataTableHit::Empty
};
}
if l.footer_height > 0.0 && y < body_bottom + l.footer_height {
return DataTableHit::Footer;
}
DataTableHit::Empty
}
#[test]
fn layout_carries_h_scroll_through_to_the_layout() {
assert_eq!(wide_layout(0.0, 10).h_scroll, 0.0);
assert_eq!(wide_layout(45.0, 10).h_scroll, 45.0);
}
#[test]
fn hit_test_header_resolves_to_the_painted_column_at_every_h_scroll() {
for h_scroll in [0.0_f32, 10.0, 30.0, 45.0, 62.0] {
let layout = wide_layout(h_scroll, 10);
for cell in 0..60u32 {
let x = cell as f32 + 0.5;
let content_x = x + h_scroll;
let near_divider = layout.columns[..layout.columns.len() - 1]
.iter()
.any(|rc| (content_x - (rc.x + rc.width)).abs() <= DIVIDER_GRAB_PX);
if near_divider {
continue;
}
let painted = layout
.columns
.iter()
.position(|rc| content_x >= rc.x && content_x < rc.x + rc.width);
let expected = match painted {
Some(col) => DataTableHit::Header { col },
None => DataTableHit::Empty,
};
assert_eq!(
layout.hit_test(x, 0.5, 0, 10),
expected,
"h_scroll={h_scroll}, viewport x={x} (content x={content_x})"
);
}
}
}
#[test]
fn hit_test_header_at_scroll_that_pushes_the_first_column_off_screen() {
let layout = wide_layout(45.0, 10);
assert_eq!(
layout.hit_test(5.0, 0.5, 0, 10),
DataTableHit::Header { col: 1 },
"viewport x=5 sits over c1's painted right half"
);
assert_eq!(
layout.hit_test(25.0, 0.5, 0, 10),
DataTableHit::Header { col: 2 }
);
assert_eq!(
layout.hit_test(50.0, 0.5, 0, 10),
DataTableHit::Header { col: 3 }
);
for cell in 0..60u32 {
assert_ne!(
layout.hit_test(cell as f32 + 0.5, 0.5, 0, 10),
DataTableHit::Header { col: 0 },
"column 0 is scrolled fully off-screen; no viewport x may resolve to it \
(viewport x={cell})"
);
}
}
#[test]
fn hit_test_header_past_the_last_column_is_no_column() {
let layout = wide_layout(90.0, 10);
assert_eq!(
layout.hit_test(10.0, 0.5, 0, 10),
DataTableHit::Header { col: 3 }
);
assert_eq!(
layout.hit_test(45.0, 0.5, 0, 10),
DataTableHit::Empty,
"content x=135 is past the 120-wide content — no column lives there"
);
}
#[test]
fn hit_test_header_divider_follows_h_scroll() {
let layout = wide_layout(45.0, 10);
assert_eq!(
layout.hit_test(15.0, 0.5, 0, 10),
DataTableHit::HeaderDivider { col: 1 }
);
assert_eq!(
layout.hit_test(45.0, 0.5, 0, 10),
DataTableHit::HeaderDivider { col: 2 }
);
assert_eq!(
layout.hit_test(30.0, 0.5, 0, 10),
DataTableHit::Header { col: 2 }
);
}
#[test]
fn column_hit_follows_h_scroll() {
let unscrolled = wide_layout(0.0, 10);
assert_eq!(unscrolled.column_hit(5.0), Some(0));
assert_eq!(unscrolled.column_hit(35.0), Some(1));
let scrolled = wide_layout(45.0, 10);
assert_eq!(scrolled.column_hit(5.0), Some(1));
assert_eq!(scrolled.column_hit(25.0), Some(2));
assert_eq!(scrolled.column_hit(50.0), Some(3));
}
#[test]
fn hit_test_row_index_is_unaffected_by_h_scroll() {
for h_scroll in [0.0_f32, 30.0, 45.0, 90.0] {
let layout = wide_layout(h_scroll, 40);
assert_eq!(
layout.hit_test(10.0, 3.5, 5, 40),
DataTableHit::Row { idx: 7 },
"h_scroll={h_scroll} must not shift row resolution"
);
}
}
#[test]
fn scrollbar_strips_keep_priority_when_horizontally_scrolled() {
let mut table = make_wide_table(200);
table.show_scrollbar = true;
table.h_scroll = 45.0;
let layout = table.layout(60.0, 20.0, 1.0, 1.0, 1.0, |_| ColumnMeasure::new(0.0));
assert!(layout.scrollbar_width > 0.0);
assert!(layout.h_scrollbar_height > 0.0);
let sb_x = layout.viewport_width - layout.scrollbar_width;
assert_eq!(
layout.hit_test(sb_x + 0.5, 0.5, 0, 200),
DataTableHit::Empty,
"a vertical-scrollbar track click must not fall through to a header"
);
assert_eq!(layout.column_hit(sb_x + 0.5), None);
let body_bottom = layout.header_height + layout.visible_rows as f32 * layout.row_height;
let hit = layout.hit_test(10.0, body_bottom + 0.5, 0, 200);
assert!(
!matches!(hit, DataTableHit::Row { .. } | DataTableHit::Header { .. }),
"a horizontal-scrollbar track click must not fall through to a header or a row, \
got {hit:?}"
);
}
#[test]
fn v_scrollbar_strip_row_click_is_a_caller_concern() {
let mut table = make_wide_table(200);
table.show_scrollbar = true;
table.h_scroll = 45.0;
let layout = table.layout(60.0, 20.0, 1.0, 1.0, 1.0, |_| ColumnMeasure::new(0.0));
assert!(layout.scrollbar_width > 0.0);
let sb_x = layout.viewport_width - layout.scrollbar_width;
let y_in_body = layout.header_height + 0.5;
assert_eq!(
layout.hit_test(sb_x + 0.5, y_in_body, 0, 200),
DataTableHit::Row { idx: 0 },
"row branch does not filter the v-scrollbar strip — intentional, see comment on \
the row branch in `hit_test`"
);
}
#[test]
fn drag_divider_reads_pointer_x_in_viewport_space() {
let unscrolled = wide_layout(0.0, 10);
let baseline = unscrolled.drag_divider(&[], 1, 70.0, 4.0);
let scrolled = wide_layout(45.0, 10);
let dragged = scrolled.drag_divider(&[], 1, 70.0 - 45.0, 4.0);
assert_eq!(
baseline, dragged,
"the same physical divider position must resize identically at any h_scroll"
);
assert_eq!(dragged[1], Some(40.0), "c1 grows from 30 to 70 - 30 = 40");
assert_eq!(
dragged[2],
Some(30.0),
"c2 sits strictly between the dragged column and the last column, so #1031's \
last-absorbs rule leaves it untouched (col 1's divider does not border it)"
);
assert_eq!(
dragged[3],
Some(20.0),
"c3 (the last column) absorbs the slack instead of c2: 30 - (40 - 30) = 20"
);
}
#[test]
fn h_scroll_zero_hit_testing_is_bit_identical_to_the_pre_change_algorithm() {
let mut shapes: Vec<DataTable> = Vec::new();
shapes.push(make_table(4, 40));
let mut with_sb = make_table(4, 40);
with_sb.show_scrollbar = true;
shapes.push(with_sb);
let mut with_footer = make_table(3, 40);
with_footer.show_scrollbar = true;
with_footer.footer = Some(footer_row(3));
shapes.push(with_footer);
let mut wide = make_wide_table(40);
wide.show_scrollbar = true;
shapes.push(wide);
for (i, table) in shapes.iter().enumerate() {
assert_eq!(table.h_scroll, 0.0, "shape {i} must pin h_scroll at zero");
let layout = table.layout(60.0, 20.0, 1.0, 1.0, 1.0, |_| ColumnMeasure::new(0.0));
for cell_x in 0..62u32 {
for cell_y in 0..22u32 {
let x = cell_x as f32 + 0.5;
let y = cell_y as f32 + 0.5;
for scroll_offset in [0usize, 7] {
assert_eq!(
layout.hit_test(x, y, scroll_offset, 40),
legacy_hit_test(&layout, x, y, scroll_offset, 40),
"shape {i}: hit_test({x}, {y}, {scroll_offset}, 40) must match the \
pre-#550 algorithm exactly"
);
}
assert_eq!(
layout.column_hit(x),
layout
.columns
.iter()
.position(|c| x >= c.x && x < c.x + c.width),
"shape {i}: column_hit({x}) must match the pre-#550 algorithm exactly"
);
}
}
}
}
#[test]
fn footer_defaults_to_none_when_omitted_from_json() {
let table = make_table(2, 3);
let mut json: serde_json::Value = serde_json::to_value(&table).unwrap();
json.as_object_mut().unwrap().remove("footer");
let back: DataTable = serde_json::from_value(json).unwrap();
assert_eq!(back.footer, None);
}
}