use std::rc::Rc;
use gpui::{
div, prelude::*, px, AnyElement, App, Div, ElementId, FontWeight, IntoElement, Length,
MouseButton, ParentElement, RenderOnce, SharedString, Styled, Window,
};
use crate::element_id;
use crate::elements::checkbox::{checkbox_box, CheckState};
use crate::theme::{ActiveTheme, ControlSize, Themeable};
use crate::traits::control_sized::ControlSized;
type CellRenderer<R> = Rc<dyn Fn(&R, &mut Window, &mut App) -> AnyElement>;
type ActivateHandler = Rc<dyn Fn(&mut Window, &mut App)>;
type SortHandler = Rc<dyn Fn(&SortRequest, &mut Window, &mut App)>;
type SelectHandler = Rc<dyn Fn(&SelectRequest, &mut Window, &mut App)>;
type SelectAllHandler = Rc<dyn Fn(&SelectAllRequest, &mut Window, &mut App)>;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ColumnWidth {
Flex(f32),
Fixed(Length),
}
impl Default for ColumnWidth {
fn default() -> Self {
ColumnWidth::Flex(1.0)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CellAlign {
#[default]
Start,
Center,
End,
}
pub struct Column<R> {
header: SharedString,
width: ColumnWidth,
min_width: Option<Length>,
align: CellAlign,
sortable: bool,
render: CellRenderer<R>,
}
impl<R> Clone for Column<R> {
fn clone(&self) -> Self {
Self {
header: self.header.clone(),
width: self.width,
min_width: self.min_width,
align: self.align,
sortable: self.sortable,
render: self.render.clone(),
}
}
}
impl<R> Column<R> {
pub fn new(
header: impl Into<SharedString>,
render: impl Fn(&R, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
Self {
header: header.into(),
width: ColumnWidth::default(),
min_width: None,
align: CellAlign::default(),
sortable: false,
render: Rc::new(render),
}
}
pub fn width(mut self, width: ColumnWidth) -> Self {
self.width = width;
self
}
pub fn flex(self, grow: f32) -> Self {
self.width(ColumnWidth::Flex(grow))
}
pub fn fixed(self, width: impl Into<Length>) -> Self {
self.width(ColumnWidth::Fixed(width.into()))
}
pub fn min_width(mut self, min_width: impl Into<Length>) -> Self {
self.min_width = Some(min_width.into());
self
}
pub fn align(mut self, align: CellAlign) -> Self {
self.align = align;
self
}
pub fn center(self) -> Self {
self.align(CellAlign::Center)
}
pub fn end(self) -> Self {
self.align(CellAlign::End)
}
pub fn sortable(mut self) -> Self {
self.sortable = true;
self
}
pub fn is_sortable(&self) -> bool {
self.sortable
}
}
pub fn column<R>(
header: impl Into<SharedString>,
render: impl Fn(&R, &mut Window, &mut App) -> AnyElement + 'static,
) -> Column<R> {
Column::new(header, render)
}
pub struct Row<R> {
data: R,
selected: bool,
on_click: Option<ActivateHandler>,
}
impl<R> Row<R> {
pub fn new(data: R) -> Self {
Self {
data,
selected: false,
on_click: None,
}
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
pub fn is_selected(&self) -> bool {
self.selected
}
}
pub fn row<R>(data: R) -> Row<R> {
Row::new(data)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortDirection {
Ascending,
Descending,
}
impl SortDirection {
pub fn reversed(self) -> Self {
match self {
SortDirection::Ascending => SortDirection::Descending,
SortDirection::Descending => SortDirection::Ascending,
}
}
pub fn indicator(self) -> &'static str {
match self {
SortDirection::Ascending => "â–²",
SortDirection::Descending => "â–¼",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortDescriptor {
pub column: usize,
pub direction: SortDirection,
}
impl SortDescriptor {
pub fn new(column: usize, direction: SortDirection) -> Self {
Self { column, direction }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SortRequest {
pub column: usize,
pub current: Option<SortDescriptor>,
}
impl SortRequest {
pub fn next(current: Option<SortDescriptor>, column: usize) -> SortDescriptor {
match current {
Some(descriptor) if descriptor.column == column => {
SortDescriptor::new(column, descriptor.direction.reversed())
}
_ => SortDescriptor::new(column, SortDirection::Ascending),
}
}
pub fn suggested(&self) -> SortDescriptor {
Self::next(self.current, self.column)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectRequest {
pub row: usize,
pub selected: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelectAllRequest {
pub selected: bool,
}
pub struct Table<R> {
id: ElementId,
columns: Vec<Column<R>>,
rows: Vec<Row<R>>,
sorted_by: Option<SortDescriptor>,
on_sort: Option<SortHandler>,
on_select_row: Option<SelectHandler>,
on_select_all: Option<SelectAllHandler>,
max_height: Option<Length>,
empty_message: Option<SharedString>,
size: ControlSize,
}
impl<R: 'static> IntoElement for Table<R> {
type Element = gpui::ViewElement<Self>;
#[track_caller]
fn into_element(self) -> Self::Element {
gpui::ViewElement::new(self)
}
}
pub fn table<R>(id: impl Into<ElementId>) -> Table<R> {
Table::new(id)
}
impl<R> Table<R> {
pub fn new(id: impl Into<ElementId>) -> Self {
Self {
id: id.into(),
columns: Vec::new(),
rows: Vec::new(),
sorted_by: None,
on_sort: None,
on_select_row: None,
on_select_all: None,
max_height: None,
empty_message: None,
size: ControlSize::default(),
}
}
pub fn column(mut self, column: Column<R>) -> Self {
self.columns.push(column);
self
}
pub fn columns(mut self, columns: impl IntoIterator<Item = Column<R>>) -> Self {
self.columns.extend(columns);
self
}
pub fn row(mut self, row: Row<R>) -> Self {
self.rows.push(row);
self
}
pub fn rows(mut self, rows: impl IntoIterator<Item = Row<R>>) -> Self {
self.rows.extend(rows);
self
}
pub fn sorted_by(mut self, sorted_by: impl Into<Option<SortDescriptor>>) -> Self {
self.sorted_by = sorted_by.into();
self
}
pub fn on_sort(
mut self,
handler: impl Fn(&SortRequest, &mut Window, &mut App) + 'static,
) -> Self {
self.on_sort = Some(Rc::new(handler));
self
}
pub fn on_select_row(
mut self,
handler: impl Fn(&SelectRequest, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select_row = Some(Rc::new(handler));
self
}
pub fn on_select_all(
mut self,
handler: impl Fn(&SelectAllRequest, &mut Window, &mut App) + 'static,
) -> Self {
self.on_select_all = Some(Rc::new(handler));
self
}
pub fn max_h(mut self, max_height: impl Into<Length>) -> Self {
self.max_height = Some(max_height.into());
self
}
pub fn empty(mut self, message: impl Into<SharedString>) -> Self {
self.empty_message = Some(message.into());
self
}
pub fn has_selection_column(&self) -> bool {
self.on_select_row.is_some()
}
pub fn has_select_all(&self) -> bool {
self.on_select_row.is_some() && self.on_select_all.is_some()
}
pub fn select_all_state(&self) -> CheckState {
let selected = self.rows.iter().filter(|row| row.selected).count();
CheckState::from_count(selected, self.rows.len())
}
}
impl<R> ControlSized for Table<R> {
fn control_size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl<R: 'static> RenderOnce for Table<R> {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let metrics = theme.control(self.size);
let id = self.id;
let columns = self.columns;
let rows = self.rows;
let sorted_by = self.sorted_by;
let on_sort = self.on_sort;
let on_select_row = self.on_select_row;
let on_select_all = self.on_select_all;
let row_count = rows.len();
let selected_count = rows.iter().filter(|row| row.selected).count();
let selection_width = metrics.ink + metrics.padding_x * 2.0;
let line_height = metrics.multiline_line_height();
let cell = |width: &ColumnWidth, min_width: Option<Length>, align: CellAlign| -> Div {
let base = div()
.flex()
.items_start()
.gap(metrics.gap)
.px(metrics.padding_x)
.py(metrics.padding_y())
.min_w_0()
.map(|this| match align {
CellAlign::Start => this.justify_start(),
CellAlign::Center => this.justify_center(),
CellAlign::End => this.justify_end(),
});
let base = match width {
ColumnWidth::Flex(grow) => base
.flex_grow(*grow)
.flex_shrink(1.0)
.flex_basis(px(0.)),
ColumnWidth::Fixed(length) => base.flex_none().w(*length),
};
base.when_some(min_width, |this, min_width| this.min_w(min_width))
};
let cell_content = |content: AnyElement| div().min_w_0().child(content);
let mut header = div()
.flex()
.w_full()
.flex_none()
.bg(theme.surface_secondary())
.border_b_1()
.border_color(theme.border());
if on_select_row.is_some() {
let state = CheckState::from_count(selected_count, row_count);
let mut select_all_cell = div()
.id(element_id::scoped(&id, "select-all"))
.flex()
.flex_none()
.w(selection_width)
.justify_center()
.py(metrics.padding_y())
.child(
div()
.flex()
.h(line_height)
.items_center()
.child(checkbox_box(state).control_size(self.size)),
);
if let Some(handler) = on_select_all.clone() {
select_all_cell =
select_all_cell
.cursor_pointer()
.on_click(move |_, window, cx| {
handler(
&SelectAllRequest {
selected: state.toggled().is_checked(),
},
window,
cx,
);
});
}
let select_all_cell =
select_all_cell.debug_selector(|| "gpuikit-table-select-all".into());
header = header.child(select_all_cell);
}
for (index, column) in columns.iter().enumerate() {
let is_sorted = sorted_by.is_some_and(|sort| sort.column == index);
let sortable = column.sortable && on_sort.is_some();
let mut header_cell = cell(&column.width, column.min_width, column.align)
.id(element_id::scoped(&id, format!("header-{index}")))
.items_center()
.font_weight(FontWeight::SEMIBOLD)
.text_color(if is_sorted {
theme.fg()
} else {
theme.fg_muted()
})
.child(div().min_w_0().child(column.header.clone()))
.when_some(sorted_by.filter(|_| is_sorted), |this, sort| {
this.child(
div()
.flex_none()
.text_color(theme.fg_muted())
.child(sort.direction.indicator()),
)
});
if sortable {
let handler = on_sort.clone().expect("checked just above");
header_cell = header_cell
.cursor_pointer()
.hover(|style| style.bg(theme.surface_tertiary()))
.on_click(move |_, window, cx| {
handler(
&SortRequest {
column: index,
current: sorted_by,
},
window,
cx,
);
});
}
let header_cell =
header_cell.debug_selector(move || format!("gpuikit-table-header-{index}"));
header = header.child(header_cell);
}
let mut body = div()
.id(element_id::scoped(&id, "body"))
.flex()
.flex_col()
.w_full()
.when_some(self.max_height, |this, max_height| {
this.max_h(max_height).overflow_y_scroll()
})
.debug_selector(|| "gpuikit-table-body".into());
if rows.is_empty() {
if let Some(message) = self.empty_message.clone() {
let empty = div()
.w_full()
.px(metrics.padding_x)
.py(metrics.padding_y() * 4.0)
.text_color(theme.fg_muted())
.child(message);
let empty = empty.debug_selector(|| "gpuikit-table-empty".into());
body = body.child(empty);
}
}
for (row_index, table_row) in rows.into_iter().enumerate() {
let selected = table_row.selected;
let mut row_element = div()
.id(element_id::scoped(&id, format!("row-{row_index}")))
.flex()
.w_full()
.when(selected, |this| this.bg(theme.accent_bg()))
.when(row_index + 1 < row_count, |this| {
this.border_b_1().border_color(theme.border_subtle())
});
if let Some(activate) = table_row.on_click.clone() {
row_element = row_element
.cursor_pointer()
.hover(|style| style.bg(theme.surface_secondary()))
.on_click(move |_, window, cx| activate(window, cx));
}
if let Some(handler) = on_select_row.clone() {
let select_cell = div()
.id(element_id::scoped(&id, format!("select-{row_index}")))
.flex()
.flex_none()
.w(selection_width)
.justify_center()
.py(metrics.padding_y())
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
.on_click(move |_, window, cx| {
cx.stop_propagation();
handler(
&SelectRequest {
row: row_index,
selected: !selected,
},
window,
cx,
);
})
.child(
div().flex().h(line_height).items_center().child(
checkbox_box(if selected {
CheckState::Checked
} else {
CheckState::Unchecked
})
.control_size(self.size),
),
);
let select_cell =
select_cell.debug_selector(move || format!("gpuikit-table-select-{row_index}"));
row_element = row_element.child(select_cell);
}
for (column_index, column) in columns.iter().enumerate() {
let content = (column.render)(&table_row.data, window, cx);
let body_cell = cell(&column.width, column.min_width, column.align)
.child(cell_content(content));
let body_cell = body_cell.debug_selector(move || {
format!("gpuikit-table-cell-{row_index}-{column_index}")
});
row_element = row_element.child(body_cell);
}
body = body.child(row_element);
}
div()
.id(id)
.flex()
.flex_col()
.w_full()
.overflow_hidden()
.bg(theme.surface())
.border_1()
.border_color(theme.border())
.rounded(metrics.radius)
.text_size(metrics.text_size)
.line_height(line_height)
.text_color(theme.fg())
.child(header)
.child(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Bounds, Context, Modifiers, Pixels, Render, TestAppContext, VisualTestContext};
use std::cell::RefCell;
#[derive(Clone)]
struct TestRow {
name: &'static str,
stars: u32,
}
fn test_rows() -> Vec<TestRow> {
vec![
TestRow {
name: "gpui",
stars: 3,
},
TestRow {
name: "zed",
stars: 1,
},
TestRow {
name: "taffy",
stars: 2,
},
]
}
fn name_column() -> Column<TestRow> {
column("Repository", |row: &TestRow, _, _| {
div().child(row.name).into_any_element()
})
}
fn stars_column() -> Column<TestRow> {
column("Stars", |row: &TestRow, _, _| {
div().child(row.stars.to_string()).into_any_element()
})
.end()
}
#[derive(Clone, Default)]
struct Log(Rc<RefCell<Vec<String>>>);
impl Log {
fn push(&self, entry: impl Into<String>) {
self.0.borrow_mut().push(entry.into());
}
fn entries(&self) -> Vec<String> {
self.0.borrow().clone()
}
}
struct TableView {
build: Box<dyn Fn() -> Table<TestRow>>,
}
impl Render for TableView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
(self.build)()
}
}
fn draw(
cx: &mut TestAppContext,
build: impl Fn() -> Table<TestRow> + 'static,
) -> &mut VisualTestContext {
cx.update(crate::theme::init);
let (_view, cx) = cx.add_window_view(move |_window, _cx| TableView {
build: Box::new(build),
});
cx.run_until_parked();
cx
}
fn bounds(cx: &mut VisualTestContext, selector: &'static str) -> Bounds<Pixels> {
cx.debug_bounds(selector)
.unwrap_or_else(|| panic!("`{selector}` was never laid out"))
}
fn click(cx: &mut VisualTestContext, selector: &'static str) {
let target = bounds(cx, selector).center();
cx.simulate_click(target, Modifiers::default());
}
#[test]
fn a_column_defaults_to_flexible_leading_and_inert() {
let column = name_column();
assert_eq!(column.width, ColumnWidth::Flex(1.0));
assert_eq!(column.align, CellAlign::Start);
assert!(!column.is_sortable());
assert_eq!(column.min_width, None);
}
#[test]
fn the_sort_toggle_reverses_one_column_and_starts_the_others_ascending() {
let ascending = SortDescriptor::new(1, SortDirection::Ascending);
assert_eq!(
SortRequest::next(Some(ascending), 1),
SortDescriptor::new(1, SortDirection::Descending)
);
assert_eq!(
SortRequest::next(Some(SortDescriptor::new(1, SortDirection::Descending)), 1),
ascending
);
assert_eq!(
SortRequest::next(Some(SortDescriptor::new(1, SortDirection::Descending)), 0),
SortDescriptor::new(0, SortDirection::Ascending)
);
assert_eq!(
SortRequest::next(None, 2),
SortDescriptor::new(2, SortDirection::Ascending)
);
}
#[test]
fn the_selection_column_appears_only_when_a_handler_asks_for_it() {
let plain: Table<TestRow> = table("t").column(name_column());
assert!(!plain.has_selection_column());
assert!(!plain.has_select_all());
let selectable: Table<TestRow> =
table("t").column(name_column()).on_select_row(|_, _, _| {});
assert!(selectable.has_selection_column());
assert!(!selectable.has_select_all());
let select_all: Table<TestRow> = table("t")
.column(name_column())
.on_select_row(|_, _, _| {})
.on_select_all(|_, _, _| {});
assert!(select_all.has_select_all());
let orphan: Table<TestRow> = table("t").column(name_column()).on_select_all(|_, _, _| {});
assert!(!orphan.has_select_all());
}
#[test]
fn the_header_checkbox_has_three_states() {
let build = |selected: &[bool]| -> Table<TestRow> {
table("t").column(name_column()).rows(
test_rows()
.into_iter()
.zip(selected.iter())
.map(|(data, selected)| row(data).selected(*selected)),
)
};
assert_eq!(
build(&[false, false, false]).select_all_state(),
CheckState::Unchecked
);
assert_eq!(
build(&[true, false, false]).select_all_state(),
CheckState::Indeterminate
);
assert_eq!(
build(&[true, true, true]).select_all_state(),
CheckState::Checked
);
let empty: Table<TestRow> = table("t").column(name_column());
assert_eq!(empty.select_all_state(), CheckState::Unchecked);
}
#[gpui::test]
fn every_column_lines_up_in_the_header_and_in_every_row(cx: &mut TestAppContext) {
let cx = draw(cx, || {
table("repos")
.column(name_column())
.column(stars_column().fixed(px(120.)))
.rows(test_rows().into_iter().map(row))
});
for (column_index, header) in ["gpuikit-table-header-0", "gpuikit-table-header-1"]
.into_iter()
.enumerate()
{
let header = bounds(cx, header);
for (row_index, cell) in [
["gpuikit-table-cell-0-0", "gpuikit-table-cell-0-1"],
["gpuikit-table-cell-1-0", "gpuikit-table-cell-1-1"],
["gpuikit-table-cell-2-0", "gpuikit-table-cell-2-1"],
]
.into_iter()
.enumerate()
{
let cell = bounds(cx, cell[column_index]);
assert_eq!(
cell.origin.x, header.origin.x,
"row {row_index} column {column_index} does not start where its header does"
);
assert_eq!(
cell.size.width, header.size.width,
"row {row_index} column {column_index} is not as wide as its header"
);
}
}
}
#[gpui::test]
fn the_columns_still_line_up_when_the_body_scrolls(cx: &mut TestAppContext) {
let cx = draw(cx, || {
table("repos")
.column(name_column())
.column(stars_column())
.rows(test_rows().into_iter().map(row))
.max_h(px(48.))
});
for (header, cell) in [
("gpuikit-table-header-0", "gpuikit-table-cell-0-0"),
("gpuikit-table-header-1", "gpuikit-table-cell-0-1"),
] {
let header = bounds(cx, header);
let cell = bounds(cx, cell);
assert_eq!(cell.origin.x, header.origin.x);
assert_eq!(cell.size.width, header.size.width);
}
}
#[gpui::test]
fn max_h_caps_the_body_and_leaves_the_header_above_it(cx: &mut TestAppContext) {
let capped = px(48.);
let cx = draw(cx, move || {
table("repos")
.column(name_column())
.rows(test_rows().into_iter().map(row))
.max_h(capped)
});
let header = bounds(cx, "gpuikit-table-header-0");
let body = bounds(cx, "gpuikit-table-body");
let first = bounds(cx, "gpuikit-table-cell-0-0");
let last = bounds(cx, "gpuikit-table-cell-2-0");
assert!(
header.origin.y + header.size.height <= body.origin.y,
"the header overlaps the body it is supposed to sit above"
);
assert!(
body.size.height <= capped,
"the body is {} tall, so `max_h` did not cap it",
body.size.height
);
let content = last.origin.y + last.size.height - first.origin.y;
assert!(
content > capped,
"the rows total {content}, which fits inside the cap, so this is not \
testing a scrolled body"
);
}
#[gpui::test]
fn a_long_cell_wraps_instead_of_running_off_the_edge(cx: &mut TestAppContext) {
let long = "a repository name far too long to fit inside one hundred and twenty pixels";
let cx = draw(cx, move || {
table("repos")
.column(
column("Repository", move |row: &TestRow, _, _| {
div().child(row.name).into_any_element()
})
.fixed(px(120.)),
)
.row(row(TestRow {
name: "gpui",
stars: 1,
}))
.row(row(TestRow {
name: long,
stars: 1,
}))
});
let short = bounds(cx, "gpuikit-table-cell-0-0");
let wrapped = bounds(cx, "gpuikit-table-cell-1-0");
assert_eq!(
wrapped.size.width, short.size.width,
"the long cell widened its column instead of wrapping"
);
assert!(
wrapped.size.height > short.size.height,
"the long cell is one line tall, so it did not wrap"
);
}
#[gpui::test]
fn the_empty_message_stands_in_for_the_rows(cx: &mut TestAppContext) {
let cx = draw(cx, || {
table("repos")
.column(name_column())
.empty("No repositories match this filter")
});
assert!(cx.debug_bounds("gpuikit-table-empty").is_some());
assert!(
cx.debug_bounds("gpuikit-table-cell-0-0").is_none(),
"there are no rows, so nothing should have drawn a cell"
);
}
#[gpui::test]
fn clicking_a_sortable_header_asks_for_a_sort(cx: &mut TestAppContext) {
let log = Log::default();
let cx = draw(cx, {
let log = log.clone();
move || {
let log = log.clone();
table("repos")
.column(name_column().sortable())
.column(stars_column().sortable())
.rows(test_rows().into_iter().map(row))
.sorted_by(SortDescriptor::new(0, SortDirection::Ascending))
.on_sort(move |request, _, _| {
let suggested = request.suggested();
log.push(format!("{} -> {:?}", request.column, suggested.direction));
})
}
});
click(cx, "gpuikit-table-header-0");
click(cx, "gpuikit-table-header-1");
assert_eq!(
log.entries(),
vec!["0 -> Descending".to_string(), "1 -> Ascending".to_string()]
);
}
#[gpui::test]
fn a_header_with_nothing_to_ask_is_inert(cx: &mut TestAppContext) {
let log = Log::default();
let cx = draw(cx, {
let log = log.clone();
move || {
let log = log.clone();
table("repos")
.column(name_column().sortable())
.column(stars_column())
.rows(test_rows().into_iter().map(row))
.on_sort(move |request, _, _| log.push(request.column.to_string()))
}
});
click(cx, "gpuikit-table-header-1");
assert!(
log.entries().is_empty(),
"an unsortable header asked for a sort"
);
click(cx, "gpuikit-table-header-0");
assert_eq!(log.entries(), vec!["0".to_string()]);
}
#[gpui::test]
fn a_sortable_column_without_a_handler_stays_inert(cx: &mut TestAppContext) {
let cx = draw(cx, || {
table("repos")
.column(name_column().sortable())
.rows(test_rows().into_iter().map(row))
});
click(cx, "gpuikit-table-header-0");
}
#[gpui::test]
fn clicking_a_row_checkbox_asks_for_that_row(cx: &mut TestAppContext) {
let log = Log::default();
let cx = draw(cx, {
let log = log.clone();
move || {
let log = log.clone();
table("repos")
.column(name_column())
.rows(
test_rows()
.into_iter()
.enumerate()
.map(|(index, data)| row(data).selected(index == 1)),
)
.on_select_row(move |request, _, _| {
log.push(format!("{}:{}", request.row, request.selected));
})
}
});
click(cx, "gpuikit-table-select-0");
click(cx, "gpuikit-table-select-1");
assert_eq!(
log.entries(),
vec!["0:true".to_string(), "1:false".to_string()]
);
}
#[gpui::test]
fn the_header_checkbox_asks_for_all_or_none_from_each_of_its_states(cx: &mut TestAppContext) {
for (selected, expected) in [(0usize, true), (1, true), (3, false)] {
let log = Log::default();
let cx = draw(cx, {
let log = log.clone();
move || {
let log = log.clone();
table("repos")
.column(name_column())
.rows(
test_rows()
.into_iter()
.enumerate()
.map(|(index, data)| row(data).selected(index < selected)),
)
.on_select_row(|_, _, _| {})
.on_select_all(move |request, _, _| log.push(request.selected.to_string()))
}
});
click(cx, "gpuikit-table-select-all");
assert_eq!(
log.entries(),
vec![expected.to_string()],
"with {selected} of 3 rows selected"
);
}
}
#[gpui::test]
fn a_checkbox_click_selects_without_activating_the_row(cx: &mut TestAppContext) {
let log = Log::default();
let cx = draw(cx, {
let log = log.clone();
move || {
let log = log.clone();
let activated = log.clone();
table("repos")
.column(name_column())
.rows(
test_rows()
.into_iter()
.enumerate()
.map(move |(index, data)| {
let activated = activated.clone();
row(data)
.on_click(move |_, _| activated.push(format!("open {index}")))
}),
)
.on_select_row(move |request, _, _| log.push(format!("select {}", request.row)))
}
});
click(cx, "gpuikit-table-select-0");
assert_eq!(log.entries(), vec!["select 0".to_string()]);
click(cx, "gpuikit-table-cell-1-0");
assert_eq!(
log.entries(),
vec!["select 0".to_string(), "open 1".to_string()]
);
}
}