From 0bfde9588809f56268a9763624c4fb1d9b256f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 20:30:41 -0400 Subject: [PATCH] T14b Tables: resize, reorder, visibility, pinning, expansion, grouping, inline edit, toolbar, filter and sort builders (review pending: codex 401) --- AGENTS.md | 2 + TASKS.md | 1 + examples/gallery/pages/tables/core.rs | 151 +++-- examples/gallery/pages/tables/features.rs | 125 +++++ examples/gallery/pages/tables/mod.rs | 20 + src/tables/body.rs | 335 +++++++++--- src/tables/builders.rs | 383 +++++++++++++ src/tables/cell.rs | 24 + src/tables/header.rs | 285 ++++++++++ src/tables/mod.rs | 8 + src/tables/model.rs | 28 +- src/tables/options.rs | 120 ++++ src/tables/rules.rs | 285 ++++++++++ src/tables/table.rs | 638 ++++++++++++---------- src/tables/tests.rs | 117 +++- src/tables/toolbar.rs | 170 ++++++ tasks/ch11-20.md | 20 +- 17 files changed, 2292 insertions(+), 420 deletions(-) create mode 100644 examples/gallery/pages/tables/features.rs create mode 100644 src/tables/builders.rs create mode 100644 src/tables/header.rs create mode 100644 src/tables/options.rs create mode 100644 src/tables/rules.rs create mode 100644 src/tables/toolbar.rs diff --git a/AGENTS.md b/AGENTS.md index f9a2700..e1afcac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A chevron that opens and closes is `primitives::Disclosure`. - `uniform_list` does not stretch its rows; each row sets `w_full`. - Chapters import only earlier chapters, so a select of tree nodes lives in `lists` (`TreeSelect`) and reaches `forms::listing`. +- Script clicks aim at a probe's corner, so a scripted target must fit in the window; a tall probe puts its top off screen. +- `scripts/check.sh` fails by its exit status; a pipe into `tail` hides it. - Reduced motion shortens animations to 1ms on the wall clock. Tests sleep past it between refreshes to see where one ends. - The test platform never runs next-frame callbacks, and its text metrics are simplified: each character takes a fixed advance. Tests refresh the window in place of the display link, and click where layout does not hang on text, such as padding and slots. - An absolute element with no insets sits where it would flow: after the content in a block, inside the padding in a flex box. A canvas that measures its parent pins itself with `.top_0().left_0()`. diff --git a/TASKS.md b/TASKS.md index 0686ce6..2e0c888 100644 --- a/TASKS.md +++ b/TASKS.md @@ -194,3 +194,4 @@ Per-component lines live in `tasks/`. Tags there: | T13b | 1 | — | pending: codex still logged out | | T13c | 1 | — | pending: codex still logged out | | T14a | 1 | — | pending: codex still logged out | +| T14b | 1 | — | pending: codex still logged out | diff --git a/examples/gallery/pages/tables/core.rs b/examples/gallery/pages/tables/core.rs index 02814f3..ea5980a 100644 --- a/examples/gallery/pages/tables/core.rs +++ b/examples/gallery/pages/tables/core.rs @@ -1,14 +1,18 @@ use std::rc::Rc; use ely_gpui_component::{ - buttons::SegmentedControl, data_display::Tone, - forms::{SearchInput, TextInput}, - tables::{Aggregate, Cell, Column, DataTable, HeatmapTable, Row, Table}, - theme::{ActiveTheme, ControlSize, Density, Radius}, + forms::TextInput, + tables::{ + Aggregate, Cell, Column, DataTable, FilterBuilder, FilterRule, HeatmapTable, Row, + SortBuilder, SortKey, Table, TableToolbar, Test, to_csv, + }, + theme::{ActiveTheme, Density, Radius}, typography::Caption, }; -use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems}; +use gpui::{ + App, ClipboardItem, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems, +}; use crate::{ probe::probe, @@ -56,7 +60,7 @@ fn noise(seed: u64) -> impl FnMut() -> f64 { } } -fn order_rows() -> Vec { +pub(super) fn order_rows() -> Vec { let mut next = noise(7); (0..24) .map(|ix| { @@ -82,7 +86,7 @@ fn order_rows() -> Vec { .collect() } -fn order_columns() -> [Column; 7] { +pub(super) fn order_columns() -> [Column; 7] { [ Column::new("owner", "Owner"), Column::new("status", "Status").width(rems(6.)), @@ -110,58 +114,125 @@ pub fn orders(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let search = window.use_keyed_state("orders-search", cx, TextInput::new); let query = search.read(cx).text().to_string(); let picked = keep("orders-picked", Vec::::new, window, cx); - let density = keep( - "orders-density", - || SharedString::from("standard"), + let spacing = keep("orders-density", || Density::Standard, window, cx); + let hidden = keep("orders-hidden", Vec::::new, window, cx); + let panel = keep("orders-panel", || false, window, cx); + let rules = keep( + "orders-rules", + || { + ( + vec![FilterRule { + column: "status".into(), + test: Test::Contains, + value: SharedString::default(), + }], + false, + ) + }, window, cx, ); - let (chosen, store, dense, pick) = ( - picked.read(cx).clone(), - picked.clone(), - density.read(cx).clone(), - density.clone(), - ); - let spacing = match dense.as_ref() { - "compact" => Density::Compact, - "comfortable" => Density::Comfortable, - _ => Density::Standard, - }; - section( - "DataTable / CellRenderer / RowSelection / TableDensity / AggregationFooter / ConditionalFormatting", - "Press a header to sort, rising then falling. Search keeps rows that hold the text; boxes select, Shift takes a range. Cells draw people, tags, sparklines and progress; growth is tinted by where it sits; the footer sums and averages what the search keeps.", + let sorts = keep( + "orders-sorts", + || { + vec![SortKey { + column: "region".into(), + rising: true, + }] + }, + window, cx, - ) - .child( + ); + let copied = keep("orders-copied", || None::, window, cx); + let (chosen, dense, gone, open, (now_rules, any), now_sorts, told) = ( + picked.read(cx).clone(), + *spacing.read(cx), + hidden.read(cx).clone(), + *panel.read(cx), + rules.read(cx).clone(), + sorts.read(cx).clone(), + *copied.read(cx), + ); + let titles: Vec<(SharedString, SharedString)> = order_columns() + .iter() + .map(|column| (column.key().clone(), column.title().clone())) + .collect(); + let theme = cx.theme(); + let toolbar = TableToolbar::new("orders-tools") + .search(&search) + .filter(now_rules.len(), { + let panel = panel.clone(); + move |_, cx| set(&panel, !open, cx) + }) + .columns(titles.clone(), gone.clone(), { + let hidden = hidden.clone(); + move |keys, _, cx| set(&hidden, keys.to_vec(), cx) + }) + .density(dense, { + let spacing = spacing.clone(); + move |density, _, cx| set(&spacing, density, cx) + }) + .export({ + let copied = copied.clone(); + move |_, cx| { + let rows = order_rows(); + cx.write_to_clipboard(ClipboardItem::new_string(to_csv(&order_columns(), &rows))); + set(&copied, Some(rows.len()), cx) + } + }); + let builders = open.then(|| { + let (store_rules, store_sorts) = (rules.clone(), sorts.clone()); div() .flex() - .items_center() - .justify_between() - .w(px(900.)) - .pb_3() - .child(div().w(px(260.)).child(SearchInput::new("orders-find", &search).size(ControlSize::Sm))) + .gap_8() + .p_4() + .mb_3() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) .child( - SegmentedControl::new("orders-density", dense) - .segment("compact", "Compact", None) - .segment("standard", "Standard", None) - .segment("comfortable", "Comfortable", None) - .size(ControlSize::Sm) - .on_change(move |value, _, cx| set(&pick, value.clone(), cx)), - ), + div().flex_1().child( + FilterBuilder::new("orders-filter", titles.clone()) + .rules(now_rules.clone(), any) + .on_change(move |rules, any, _, cx| { + set(&store_rules, (rules.to_vec(), any), cx) + }), + ), + ) + .child( + div().w(px(380.)).child( + SortBuilder::new("orders-sort", titles.clone()) + .keys(now_sorts.clone()) + .on_change(move |keys, _, cx| set(&store_sorts, keys.to_vec(), cx)), + ), + ) + }); + let store = picked.clone(); + section( + "DataTable / TableToolbar / FilterBuilder / SortBuilder / ColumnVisibility / CellRenderer / RowSelection / TableDensity / AggregationFooter / ConditionalFormatting", + "Press a header to sort, Shift to add it to the sort. The toolbar searches, opens the filter and sort builders, hides columns, sets the density and copies the rows as CSV. Boxes select; Shift takes a range. Growth is tinted by where it sits; the footer sums and averages what the filters keep.", + cx, ) + .child(div().pb_3().child(probe("orders-tools", div().w(px(900.)).child(toolbar)))) + .children(builders.map(|panel| div().w(px(900.)).child(panel))) .child(probe( "orders", div().w(px(900.)).child( DataTable::new("orders", order_columns()) .rows(order_rows()) .query(query) + .filters(now_rules, any) + .sorts(now_sorts) + .hidden(gone) .paged(8) - .density(spacing) + .density(dense) .selected(chosen.clone()) .on_select(move |keys, _, cx| set(&store, keys.to_vec(), cx)), ), )) - .child(Caption::new(format!("{} selected", chosen.len()))) + .child(Caption::new(match told { + Some(rows) => format!("{} selected. Copied {rows} rows as CSV.", chosen.len()), + None => format!("{} selected", chosen.len()), + })) } pub fn long(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { diff --git a/examples/gallery/pages/tables/features.rs b/examples/gallery/pages/tables/features.rs new file mode 100644 index 0000000..d3c94e7 --- /dev/null +++ b/examples/gallery/pages/tables/features.rs @@ -0,0 +1,125 @@ +use ely_gpui_component::{ + data_display::{DescriptionList, Tone}, + tables::{Cell, Column, DataTable, Row}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems}; + +use super::core::{order_columns, order_rows}; +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +pub fn wide(cx: &mut App) -> impl IntoElement + use<> { + let columns = std::iter::once(Column::new("team", "Team").width(rems(10.)).pinned()).chain( + MONTHS + .iter() + .map(|month| Column::new(*month, *month).width(rems(5.5)).end()), + ); + let rows = ["Design", "Platform", "Apps", "Growth", "Support"] + .iter() + .enumerate() + .map(|(ix, team)| { + Row::new( + *team, + std::iter::once(Cell::from(*team)).chain( + (0..12).map(|month| Cell::Number(((ix * 7 + month * 13) % 40 + 10) as f64)), + ), + ) + }); + section( + "ColumnPinning / ColumnResizer / ColumnReorder", + "Team stays put while the months scroll sideways. Drag a header's right edge to resize it; drag a header onto another to move it there.", + cx, + ) + .child(div().w(px(620.)).child(DataTable::new("wide", columns).rows(rows.collect::>()))) +} + +/// Tasks the demo edits in place: key, name, owner, state. +fn tasks() -> Vec<(SharedString, SharedString, &'static str, &'static str)> { + [ + ("t1", "Draft the launch notes", "Ada Lovelace", "Doing"), + ("t2", "Review the type scale", "Grace Hopper", "Done"), + ("t3", "Record the demo", "Alan Turing", "Next"), + ] + .map(|(key, name, owner, state)| (key.into(), name.into(), owner, state)) + .to_vec() +} + +pub fn rows(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let kept = keep("tasks", tasks, window, cx); + let (now, store) = (kept.read(cx).clone(), kept.clone()); + let rows: Vec = now + .iter() + .map(|(key, name, owner, state)| { + let tone = match *state { + "Done" => Tone::Success, + "Doing" => Tone::Info, + _ => Tone::Neutral, + }; + Row::new( + key.clone(), + [ + Cell::Text(name.clone()), + Cell::Person((*owner).into()), + Cell::Tag((*state).into(), tone), + ], + ) + }) + .collect(); + let about = now.clone(); + section( + "RowExpansion / InlineRowEdit", + "The chevron opens a row's detail beneath it. Double press a task's name to edit it; Enter keeps the change, Escape drops it.", + cx, + ) + .child(probe( + "tasks", + div().w(px(640.)).child( + DataTable::new( + "tasks", + [Column::new("name", "Task").editable(), Column::new("owner", "Owner").width(rems(12.)), Column::new("state", "State").width(rems(6.))], + ) + .rows(rows) + .detail(move |key, _, _| { + let (_, name, owner, state) = about.iter().find(|task| task.0 == *key).expect("a listed task").clone(); + DescriptionList::new() + .item("Task", name) + .item("Owner", owner) + .item("State", state) + .into_any_element() + }) + .on_edit(move |key, _, text, _, cx| { + let mut next = store.read(cx).clone(); + if let Some(task) = next.iter_mut().find(|task| task.0 == *key) { + task.1 = text.clone(); + } + set(&store, next, cx) + }), + ), + )) +} + +pub fn grouped(cx: &mut App) -> impl IntoElement + use<> { + section( + "RowGrouping", + "Orders gathered by region, each group with its count; a press on a group folds it.", + cx, + ) + .child(probe( + "grouped", + div().w(px(900.)).child( + DataTable::new("grouped", order_columns()) + .rows(order_rows().into_iter().take(9).collect::>()) + .group_by("region"), + ), + )) + .child(Caption::new( + "Groups keep the order their first rows come in; sort to reorder them.", + )) +} diff --git a/examples/gallery/pages/tables/mod.rs b/examples/gallery/pages/tables/mod.rs index 2243dee..6c039db 100644 --- a/examples/gallery/pages/tables/mod.rs +++ b/examples/gallery/pages/tables/mod.rs @@ -1,4 +1,5 @@ mod core; +mod features; use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; @@ -26,6 +27,22 @@ const SCRIPT: &[Step] = &[ Step::UpAt("orders", 18.0, 126.0), Step::Wait(200), Step::Shot("selected"), + Step::DownAt("orders-tools", 300.0, 14.0), + Step::UpAt("orders-tools", 300.0, 14.0), + Step::Wait(300), + Step::Shot("builders"), + Step::Hover("tasks"), + Step::Wait(300), + Step::DownAt("tasks", 14.0, 54.0), + Step::UpAt("tasks", 14.0, 54.0), + Step::Wait(300), + Step::Shot("expanded"), + Step::Hover("grouped"), + Step::Wait(300), + Step::DownAt("grouped", 60.0, 54.0), + Step::UpAt("grouped", 60.0, 54.0), + Step::Wait(300), + Step::Shot("folded"), Step::Rest, ]; @@ -33,6 +50,9 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { div() .child(core::plain(cx)) .child(core::orders(window, cx)) + .child(features::wide(cx)) + .child(features::rows(window, cx)) + .child(features::grouped(cx)) .child(core::long(window, cx)) .child(core::heatmap(cx)) .into_any_element() diff --git a/src/tables/body.rs b/src/tables/body.rs index 8da0d5d..28f3e28 100644 --- a/src/tables/body.rs +++ b/src/tables/body.rs @@ -1,24 +1,29 @@ -use std::rc::Rc; +use std::{collections::HashSet, rc::Rc}; use gpui::{ - AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Rems, - SharedString, StatefulInteractiveElement, Styled, div, prelude::*, + AnyElement, App, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, Window, div, + prelude::*, }; -use super::{Align, Column, Row, cell::draw, model::tint}; +use super::{Align, Column, Row, cell::draw, model::tint, table::View}; use crate::{ - forms::{CheckState, check_mark}, + forms::{CheckState, Editing, Input, check_mark}, lists::Pick, - theme::{ActiveTheme, TextSize}, + primitives::Disclosure, + theme::{ActiveTheme, ControlSize, TextSize}, }; -pub(super) type Select = Rc; +pub(super) type Select = Rc; +pub(super) type Detail = Rc AnyElement>; +pub(super) type OnEdit = + Rc; /// Gives a cell its column's width: fixed, or a share of what is left. -pub(super) fn sized(cell: E, column: &Column) -> E { +pub(super) fn sized(cell: E, column: &Column, narrowest: Rems) -> E { let cell = match column.width { Some(width) => cell.w(width).flex_none(), - None => cell.flex_1().min_w_0(), + None => cell.flex_1().min_w(narrowest), }; if column.align == Align::End { cell.justify_end() @@ -27,35 +32,154 @@ pub(super) fn sized(cell: E, column: &Column) -> E { } } -/// What drawing a row needs, shared by pages and by a long table's list. +/// What the body draws, one after another. +#[derive(Clone, Debug, PartialEq)] +pub(super) enum Line { + Group { + name: SharedString, + count: usize, + folded: bool, + }, + Row { + ix: usize, + position: usize, + }, + Detail { + ix: usize, + }, +} + +/// What drawing lines needs, shared by pages and by a long table's list. pub(super) struct Body { pub id: ElementId, + pub view: Entity, pub columns: Rc>, + pub widths: Rc>>, pub rows: Rc>, - pub keys: Rc>, - pub shown: Rc>, - pub offset: usize, + pub lines: Rc>, pub selected: Option>>, pub select: Option = selected.clone().map(|selected| { let (view, ordered, on_select, id) = ( @@ -202,174 +240,223 @@ impl RenderOnce for DataTable { ) as Select }); let theme = cx.theme(); - let colors = &theme.colors; let height = theme.table_row(self.density); - let check = theme.control_height(crate::theme::ControlSize::Md); + let widths: Rc>> = Rc::new( + columns + .iter() + .map(|column| view.read(cx).widths.get(&column.key).copied()) + .collect(), + ); + let body = Rc::new(Body { + id: id.clone(), + view: view.clone(), + columns: columns.clone(), + widths: widths.clone(), + rows: rows.clone(), + lines: Rc::new(lines), + selected: selected.clone(), + select, + detail: self.detail, + open: Rc::new(open), + editing: view.read(cx).editing.clone(), + editor, + on_edit: self.on_edit, + ranges: Rc::new( + columns + .iter() + .enumerate() + .map(|(col, column)| column.scale.then(|| range(&rows, col)).flatten()) + .collect(), + ), + height, + lead: theme.control_height(ControlSize::Md), + narrowest, + }); let all = selected.as_ref().map(|selected| { - let on = ordered.iter().filter(|key| selected.contains(key)).count(); - match on { + match ordered.iter().filter(|key| selected.contains(key)).count() { 0 => CheckState::Off, on if on == ordered.len() => CheckState::On, _ => CheckState::Mixed, } }); - let header = div() - .flex() - .items_center() - .h(height) - .border_b_1() - .border_color(colors.border) - .text_size(theme.text_size(TextSize::Xs)) - .font_weight(FontWeight::MEDIUM) - .text_color(colors.fg_muted) - .when_some(all.zip(selected.clone()), |header, (state, selected)| { - let (ordered, on_select) = (ordered.clone(), self.on_select.clone()); - header.child( + let on_select = self.on_select; + let heads = |cols: &[usize], lead: bool, cx: &App| { + let theme = cx.theme(); + div() + .flex() + .items_center() + .h(height) + .border_b_1() + .border_color(theme.colors.border) + .when(lead, |row| { + row.when_some(all.zip(selected.clone()), |row, (state, selected)| { + let (ordered, on_select) = (ordered.clone(), on_select.clone()); + row.child( + div() + .id((id.clone(), "all")) + .flex_none() + .w(body.lead) + .flex() + .justify_center() + .cursor_pointer() + .on_click(move |_, window, cx| { + let next: Vec = if state == CheckState::On { + selected + .iter() + .filter(|key| !ordered.contains(key)) + .cloned() + .collect() + } else { + let mut next = (*selected).clone(); + next.extend( + ordered + .iter() + .filter(|key| !selected.contains(key)) + .cloned(), + ); + next + }; + if let Some(on_select) = &on_select { + on_select(&next, window, cx); + } + }) + .child(check_mark(state, false, false, 0, cx)), + ) + }) + .when(body.detail.is_some(), |row| { + row.child(div().flex_none().w(body.lead)) + }) + }) + .children(cols.iter().map(|col| { + header( + &id, + &view, + &sorting, + &columns[*col], + widths[*col], + narrowest, + cx, + ) + })) + }; + let figures = |cols: &[usize], lead: bool, cx: &App| { + columns + .iter() + .any(|column| column.aggregate.is_some()) + .then(|| { + let theme = cx.theme(); div() - .id((id.clone(), "all")) - .flex_none() - .w(check) - .flex() - .justify_center() - .cursor_pointer() - .on_click(move |_, window, cx| { - let next: Vec = if state == CheckState::On { - selected - .iter() - .filter(|key| !ordered.contains(key)) - .cloned() - .collect() - } else { - let mut next = (*selected).clone(); - next.extend( - ordered - .iter() - .filter(|key| !selected.contains(key)) - .cloned(), - ); - next - }; - if let Some(on_select) = &on_select { - on_select(&next, window, cx); - } - }) - .child(check_mark(state, false, false, 0, cx)), - ) - }) - .children(columns.iter().enumerate().map(|(col, column)| { - let here = sort - .filter(|(sorted, _)| *sorted == col) - .map(|(_, rising)| rising); - let (view, id) = (view.clone(), id.clone()); - sized( - div() - .id((self.id.clone(), format!("head-{}", column.key))) - .h_full() .flex() .items_center() - .gap_1() - .px_3(), - column, - ) - .when(column.sortable, |head| { - head.cursor_pointer() - .hover(|style| style.text_color(colors.fg)) - .on_click(move |_, _, cx| { - view.update(cx, |view, cx| { - view.sort = next_sort(view.sort, col); - view.page = 0; - log::info!("data table {id:?}: sort {:?}", view.sort); - cx.notify(); - }) + .h(height) + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .when(lead, |row| { + row.child(div().flex_none().w(body.lead_width())) }) - }) - .child(column.title.clone()) - .children(here.map(|rising| { - Icon::new(if rising { - IconName::ArrowUp - } else { - IconName::ArrowDown - }) - .size(IconSize::Xs) - .color(colors.fg_muted) - })) - })); - let figures = columns - .iter() - .any(|column| column.aggregate.is_some()) - .then(|| { - div() - .flex() - .items_center() - .h(height) - .text_size(theme.text_size(TextSize::Sm)) - .font_weight(FontWeight::MEDIUM) - .when(selected.is_some(), |footer| { - footer.child(div().flex_none().w(check)) - }) - .children(columns.iter().enumerate().map(|(col, column)| { - let shown = column.aggregate.map(|how| { - let shares = rows - .first() - .is_some_and(|row| matches!(row.cells[col], Cell::Progress(_))); - let value = - figure(&rows, &order, col, how).map_or("—".to_string(), |value| { - match how { + .children(cols.iter().map(|col| { + let column = &columns[*col]; + let shown = column.aggregate.map(|how| { + let shares = rows.first().is_some_and(|row| { + matches!(row.cells[*col], Cell::Progress(_)) + }); + let value = figure(&rows, &order, *col, how).map_or( + "—".to_string(), + |value| match how { Aggregate::Count => format!("{}", value as usize), _ if shares => format::percent(value, 0, false), _ => column.reads(value), - } - }); - div() - .flex() - .items_baseline() - .gap_1() - .child( - div() - .text_size(theme.text_size(TextSize::Xs)) - .text_color(colors.fg_subtle) - .child(how.label()), - ) - .child(value) - }); - sized(div().px_3().flex().items_center(), column).children(shown) - })) - }); - let body = Body { - id: id.clone(), - columns: columns.clone(), - rows, - keys, - shown: Rc::new(shown), - offset, - selected, - select, - ranges, - height, - check, + }, + ); + div() + .flex() + .items_baseline() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_subtle) + .child(how.label()), + ) + .child(value) + }); + let cell = div().px_3().flex().items_center(); + match widths[*col] { + Some(width) => cell.w(width).flex_none(), + None => sized(cell, column, narrowest), + } + .children(shown) + })) + }) }; - let rows: AnyElement = if total == 0 { + let part = |cols: &[usize], lead: bool, window: &mut Window, cx: &mut App| -> Div { + let lines: Vec = (0..body.lines.len()) + .map(|at| body.line(at, cols, lead, window, cx)) + .collect(); div() .flex() - .justify_center() - .py_6() - .text_size(theme.text_size(TextSize::Sm)) - .text_color(colors.fg_subtle) - .child("Nothing matches.") + .flex_col() + .child(heads(cols, lead, cx)) + .children(lines) + .children(figures(cols, lead, cx)) + }; + let table: AnyElement = if total == 0 { + let theme = cx.theme(); + div() + .flex() + .flex_col() + .child(heads(&shown_columns, true, cx)) + .child( + div() + .flex() + .justify_center() + .py_6() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_subtle) + .child("Nothing matches."), + ) .into_any_element() } else if self.virtualized { - let count = body.shown.len(); - uniform_list((id.clone(), "rows"), count, move |range, _, cx| { - range.map(|at| body.row(at, cx)).collect() - }) - .track_scroll(scroll) - .flex_1() - .min_h_0() - .into_any_element() - } else { + let (scroll, count, cols, drawn) = ( + view.read(cx).scroll.clone(), + body.lines.len(), + shown_columns.clone(), + body.clone(), + ); div() - .children((0..body.shown.len()).map(|at| body.row(at, cx))) + .flex() + .flex_col() + .size_full() + .child(heads(&shown_columns, true, cx)) + .child( + uniform_list((id.clone(), "rows"), count, move |range, window, cx| { + range + .map(|at| drawn.line(at, &cols, true, window, cx)) + .collect() + }) + .track_scroll(scroll) + .flex_1() + .min_h_0(), + ) + .children(figures(&shown_columns, true, cx)) .into_any_element() + } else if pinned_any { + let (held, moving): (Vec, Vec) = + shown_columns.iter().partition(|col| columns[**col].pinned); + div() + .flex() + .child(part(&held, true, window, cx).flex_none()) + .child( + div() + .id((id.clone(), "sideways")) + .flex_1() + .min_w_0() + .overflow_x_scroll() + .child(part(&moving, false, window, cx)), + ) + .into_any_element() + } else { + part(&shown_columns, true, window, cx).into_any_element() }; let pager = pages.filter(|pages| *pages > 1).map(|pages| { let size = self.page_size.expect("pages come from a page size"); @@ -393,25 +480,6 @@ impl RenderOnce for DataTable { ), ) }); - self.base - .flex() - .flex_col() - .child(header) - .child(rows) - .children(figures) - .children(pager) - } -} - -#[cfg(test)] -mod tests { - use super::next_sort; - - #[test] - fn a_header_sorts_rising_then_falling_then_not() { - assert_eq!(next_sort(None, 2), Some((2, true))); - assert_eq!(next_sort(Some((2, true)), 2), Some((2, false))); - assert_eq!(next_sort(Some((2, false)), 2), None); - assert_eq!(next_sort(Some((1, false)), 2), Some((2, true))); + self.base.flex().flex_col().child(table).children(pager) } } diff --git a/src/tables/tests.rs b/src/tables/tests.rs index e80ee25..1063d6e 100644 --- a/src/tables/tests.rs +++ b/src/tables/tests.rs @@ -1,7 +1,7 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ - Context, IntoElement, Modifiers, Render, SharedString, Styled, TestAppContext, + Context, IntoElement, Modifiers, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, point, px, }; @@ -67,3 +67,118 @@ fn boxes_toggle_shift_takes_a_range_and_the_header_takes_all(cx: &mut TestAppCon settle(cx); assert!(picks(&seen).is_empty(), "a full one clears"); } + +/// Six rows in two groups, measured as they draw. +struct Grouped(Rc>); + +impl Render for Grouped { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let tall = self.0.clone(); + let rows: Vec = (0..6) + .map(|ix| { + Row::new( + format!("r{ix}"), + [ + if ix < 4 { "Europe" } else { "Asia" }.into(), + format!("Row {ix}").into(), + ], + ) + }) + .collect(); + crate::primitives::Measure::new("measure", move |bounds, _, _| tall.set(bounds.size.height)) + .w(px(400.0)) + .child( + DataTable::new( + "grouped", + [Column::new("region", "Region"), Column::new("name", "Name")], + ) + .rows(rows) + .group_by("region"), + ) + } +} + +#[gpui::test] +fn a_press_on_a_group_folds_its_rows(cx: &mut TestAppContext) { + cx.update(Theme::init); + let tall = Rc::new(std::cell::Cell::new(gpui::Pixels::ZERO)); + let seen = tall.clone(); + let (_, cx) = cx.add_window_view(|_, _| Grouped(seen)); + settle(cx); + let open = tall.get(); + cx.simulate_click(point(px(60.0), px(54.0)), Modifiers::none()); + settle(cx); + assert_eq!( + open - tall.get(), + px(36.0 * 4.0), + "Europe's four rows fold away" + ); +} + +/// Two rows whose names edit in place and whose detail opens; it keeps what it hears. +struct Editable(Rc>>); + +impl Render for Editable { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (heard, opened) = (self.0.clone(), self.0.clone()); + DataTable::new("editable", [Column::new("name", "Name").editable()]) + .rows(vec![ + Row::new("a", ["Ada".into()]), + Row::new("b", ["Alan".into()]), + ]) + .detail(move |key, _, _| { + opened.borrow_mut().push(format!("detail {key}")); + gpui::div().h(px(40.0)).into_any_element() + }) + .on_edit(move |row, column, text, _, _| { + heard + .borrow_mut() + .push(format!("edit {row} {column} {text}")) + }) + .w(px(400.0)) + } +} + +#[gpui::test] +fn a_row_opens_its_detail_and_a_double_press_edits_a_cell(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + crate::forms::bind_keys(cx); + }); + let heard = Rc::new(RefCell::new(Vec::new())); + let seen = heard.clone(); + let (_, cx) = cx.add_window_view(|_, _| Editable(seen)); + settle(cx); + cx.simulate_click(point(px(14.0), px(54.0)), Modifiers::none()); + settle(cx); + assert_eq!(heard.borrow().last().map(String::as_str), Some("detail a")); + let name = point(px(120.0), px(54.0 + 36.0 + 40.0 + 24.0)); + for count in [1, 2] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: name, + modifiers: Modifiers::none(), + click_count: count, + first_mouse: false, + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: name, + modifiers: Modifiers::none(), + click_count: count, + }); + } + settle(cx); + cx.simulate_input("Alan Kay"); + cx.simulate_keystrokes("enter"); + settle(cx); + assert_eq!( + heard + .borrow() + .iter() + .filter(|line| line.starts_with("edit")) + .cloned() + .collect::>(), + ["edit b name Alan Kay"] + ); +} diff --git a/src/tables/toolbar.rs b/src/tables/toolbar.rs new file mode 100644 index 0000000..f5100b9 --- /dev/null +++ b/src/tables/toolbar.rs @@ -0,0 +1,170 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, + div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Run, SearchInput, TextInput}, + menus::{DropdownMenu, Menu, MenuItem}, + primitives::IconName, + theme::{ActiveTheme, ControlSize, Density}, +}; + +type OnHidden = Rc; +type OnDensity = Rc; + +/// A table's tools in one row: a search and a filter button that counts its rules, then at the end a menu that shows and hides columns, the density, and an export. Each tool appears once it is given. +#[derive(IntoElement)] +pub struct TableToolbar { + id: ElementId, + search: Option>, + filter: Option<(usize, Run)>, + columns: Vec<(SharedString, SharedString)>, + hidden: Vec, + on_hidden: Option, + density: Option<(Density, OnDensity)>, + on_export: Option, +} + +impl TableToolbar { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + search: None, + filter: None, + columns: Vec::new(), + hidden: Vec::new(), + on_hidden: None, + density: None, + on_export: None, + } + } + + /// A search field over the owner's text; hand its text to the table's `query`. + pub fn search(mut self, field: &Entity) -> Self { + self.search = Some(field.clone()); + self + } + + /// A filter button that counts the rules; a press runs `on_press`, such as opening a `FilterBuilder`. + pub fn filter( + mut self, + rules: usize, + on_press: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.filter = Some((rules, Rc::new(on_press))); + self + } + + /// A menu with a check for each column; `on_change` gets the columns now hidden. + pub fn columns( + mut self, + columns: impl IntoIterator, impl Into)>, + hidden: impl IntoIterator>, + on_change: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { + self.columns = columns + .into_iter() + .map(|(key, title)| (key.into(), title.into())) + .collect(); + self.hidden = hidden.into_iter().map(Into::into).collect(); + self.on_hidden = Some(Rc::new(on_change)); + self + } + + pub fn density( + mut self, + density: Density, + on_change: impl Fn(Density, &mut Window, &mut App) + 'static, + ) -> Self { + self.density = Some((density, Rc::new(on_change))); + self + } + + pub fn export(mut self, on_press: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_export = Some(Rc::new(on_press)); + self + } +} + +impl RenderOnce for TableToolbar { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let columns = self.on_hidden.map(|on_hidden| { + let menu = self.columns.iter().fold(Menu::new(), |menu, (key, title)| { + let shown = !self.hidden.contains(key); + let (key, hidden, on_hidden) = + (key.clone(), self.hidden.clone(), on_hidden.clone()); + menu.item( + MenuItem::check(title.clone(), shown).on_click(move |window, cx| { + let next: Vec = if shown { + hidden.iter().cloned().chain([key.clone()]).collect() + } else { + hidden + .iter() + .filter(|known| **known != key) + .cloned() + .collect() + }; + on_hidden(&next, window, cx) + }), + ) + }); + DropdownMenu::new((id.clone(), "columns"), "Columns", menu) + .icon(IconName::Table) + .variant(ButtonVariant::Ghost) + }); + let density = self.density.map(|(now, on_density)| { + let menu = [ + (Density::Compact, "Compact"), + (Density::Standard, "Standard"), + (Density::Comfortable, "Comfortable"), + ] + .into_iter() + .fold(Menu::new(), |menu, (density, label)| { + let on_density = on_density.clone(); + menu.item( + MenuItem::radio(label, now == density) + .on_click(move |window, cx| on_density(density, window, cx)), + ) + }); + DropdownMenu::new((id.clone(), "density"), "Density", menu) + .icon(IconName::List) + .variant(ButtonVariant::Ghost) + }); + div() + .flex() + .items_center() + .gap_1() + .children(self.search.map(|field| { + div() + .w(cx.theme().label_width() * 1.6) + .child(SearchInput::new((id.clone(), "search"), &field).size(ControlSize::Sm)) + })) + .children(self.filter.map(|(rules, on_press)| { + let label: SharedString = if rules == 0 { + "Filter".into() + } else { + format!("Filter · {rules}").into() + }; + Button::new((id.clone(), "filter"), label) + .icon(IconName::Filter) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| on_press(window, cx)) + })) + .child(div().flex_1()) + .children(columns) + .children(density) + .children(self.on_export.map(|on_export| { + Button::new((id.clone(), "export"), "Export") + .icon(IconName::Download) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| on_export(window, cx)) + })) + } +} diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index f99ccf4..bf4eac6 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -131,16 +131,16 @@ ## T14b Tables · Columns & rows -- [ ] ColumnResizer -- [ ] ColumnReorder -- [ ] ColumnVisibility -- [ ] ColumnPinning -- [ ] RowExpansion -- [ ] RowGrouping -- [ ] InlineRowEdit -- [ ] TableToolbar -- [ ] FilterBuilder -- [ ] SortBuilder +- [x] ColumnResizer → `tables::DataTable`: a header's edge drags +- [x] ColumnReorder → `tables::DataTable`: a header drags onto another +- [x] ColumnVisibility → `tables::DataTable`, `hidden`; the toolbar's Columns menu +- [x] ColumnPinning → `tables::Column`, `pinned` +- [x] RowExpansion → `tables::DataTable`, `detail` +- [x] RowGrouping → `tables::DataTable`, `group_by` +- [x] InlineRowEdit → `tables::Column`, `editable`; `DataTable::on_edit` +- [x] TableToolbar → `tables::TableToolbar`; `tables::to_csv` for export +- [x] FilterBuilder → `tables::FilterBuilder`, `FilterRule` +- [x] SortBuilder → `tables::SortBuilder`, `SortKey` ## T14c Tables · Grids