From dcd988e4b8a5710508f95b3b1e1d7ec151be64e3 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:14:58 -0400 Subject: [PATCH] T14a Tables core: DataTable, Table, HeatmapTable, cells, Sparkline (review pending: codex 401) --- TASKS.md | 5 +- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/tables/core.rs | 247 +++++++++++++++ examples/gallery/pages/tables/mod.rs | 39 +++ src/data_display/mod.rs | 2 + src/data_display/spark.rs | 163 ++++++++++ src/lib.rs | 1 + src/lists/mod.rs | 1 + src/lists/select.rs | 4 +- src/tables/body.rs | 117 ++++++++ src/tables/cell.rs | 228 ++++++++++++++ src/tables/mod.rs | 11 + src/tables/model.rs | 159 ++++++++++ src/tables/simple.rs | 192 ++++++++++++ src/tables/table.rs | 417 ++++++++++++++++++++++++++ src/tables/tests.rs | 69 +++++ src/theme/tokens.rs | 14 + tasks/ch11-20.md | 38 ++- 18 files changed, 1690 insertions(+), 19 deletions(-) create mode 100644 examples/gallery/pages/tables/core.rs create mode 100644 examples/gallery/pages/tables/mod.rs create mode 100644 src/data_display/spark.rs create mode 100644 src/tables/body.rs create mode 100644 src/tables/cell.rs create mode 100644 src/tables/mod.rs create mode 100644 src/tables/model.rs create mode 100644 src/tables/simple.rs create mode 100644 src/tables/table.rs create mode 100644 src/tables/tests.rs diff --git a/TASKS.md b/TASKS.md index a740555..0686ce6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -49,7 +49,9 @@ Per-component lines live in `tasks/`. Tags there: - [ ] T13a Lists & Trees · Lists - [ ] T13b Lists & Trees · Trees - [ ] T13c Lists & Trees · Files -- [ ] T14 Tables +- [ ] T14a Tables · Core +- [ ] T14b Tables · Columns & rows +- [ ] T14c Tables · Grids - [ ] T15 Charts - [ ] T16a Finance · Market charts - [ ] T16b Finance · Technical analysis @@ -191,3 +193,4 @@ Per-component lines live in `tasks/`. Tags there: | T13a | 1 | — | pending: codex still logged out | | T13b | 1 | — | pending: codex still logged out | | T13c | 1 | — | pending: codex still logged out | +| T14a | 1 | — | pending: codex still logged out | diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index ce4a08e..d170d59 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -10,6 +10,7 @@ mod navigation; mod overlays; mod primitives; mod shell; +mod tables; mod theme; mod typography; @@ -41,6 +42,7 @@ pub const ALL: &[Page] = &[ motion::PAGE, data::PAGE, lists::PAGE, + tables::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/tables/core.rs b/examples/gallery/pages/tables/core.rs new file mode 100644 index 0000000..02814f3 --- /dev/null +++ b/examples/gallery/pages/tables/core.rs @@ -0,0 +1,247 @@ +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}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px, rems}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +pub fn plain(cx: &mut App) -> impl IntoElement + use<> { + section( + "Table", + "A header and rows, hairlines between. Nothing more.", + cx, + ) + .child( + div().w(px(560.)).child( + Table::new(["Name", "Role", "City"]) + .row(["Ada Lovelace", "Analyst", "London"]) + .row(["Grace Hopper", "Engineer", "Arlington"]) + .row(["Katherine Johnson", "Mathematician", "Hampton"]), + ), + ) +} + +const PEOPLE: [&str; 10] = [ + "Ada Lovelace", + "Alan Turing", + "Grace Hopper", + "Katherine Johnson", + "Tim Berners-Lee", + "Radia Perlman", + "Sophie Wilson", + "Barbara Liskov", + "Ken Thompson", + "Margaret Hamilton", +]; +const REGIONS: [&str; 4] = ["Europe", "Americas", "Asia", "Oceania"]; + +/// A steady pseudo-random series, so captures repeat. +fn noise(seed: u64) -> impl FnMut() -> f64 { + let mut state = seed; + move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as f64 / (1u64 << 31) as f64 + } +} + +fn order_rows() -> Vec { + let mut next = noise(7); + (0..24) + .map(|ix| { + let (status, tone) = match (next() * 3.0) as u32 { + 0 => ("Live", Tone::Success), + 1 => ("Draft", Tone::Neutral), + _ => ("Review", Tone::Warning), + }; + let spark: Vec = (0..8).map(|_| next() as f32).collect(); + Row::new( + format!("order-{ix}"), + [ + Cell::Person(PEOPLE[ix % PEOPLE.len()].into()), + Cell::Tag(status.into(), tone), + REGIONS[(next() * 4.0) as usize].into(), + (next() * 90_000.0 + 8_000.0).round().into(), + ((next() - 0.35) * 40.0).into(), + Cell::Spark(spark), + Cell::Progress(next() as f32), + ], + ) + }) + .collect() +} + +fn order_columns() -> [Column; 7] { + [ + Column::new("owner", "Owner"), + Column::new("status", "Status").width(rems(6.)), + Column::new("region", "Region").width(rems(6.)), + Column::new("revenue", "Revenue") + .end() + .prefix("$") + .aggregate(Aggregate::Sum) + .width(rems(8.)), + Column::new("growth", "Growth") + .end() + .decimals(1) + .suffix("%") + .scale() + .aggregate(Aggregate::Average) + .width(rems(6.)), + Column::new("trend", "Trend").unsorted().width(rems(6.)), + Column::new("quota", "Quota") + .width(rems(9.)) + .aggregate(Aggregate::Average), + ] +} + +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"), + 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.", + cx, + ) + .child( + 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))) + .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)), + ), + ) + .child(probe( + "orders", + div().w(px(900.)).child( + DataTable::new("orders", order_columns()) + .rows(order_rows()) + .query(query) + .paged(8) + .density(spacing) + .selected(chosen.clone()) + .on_select(move |keys, _, cx| set(&store, keys.to_vec(), cx)), + ), + )) + .child(Caption::new(format!("{} selected", chosen.len()))) +} + +pub fn long(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let rows = keep( + "long-rows", + || { + let mut next = noise(11); + Rc::new( + (0..100_000) + .map(|ix| { + Row::new( + format!("long-{ix}"), + [ + format!("Event {}", ix + 1).into(), + REGIONS[ix % 4].into(), + (next() * 1000.0).round().into(), + ], + ) + }) + .collect::>(), + ) + }, + window, + cx, + ) + .read(cx) + .clone(); + let theme = cx.theme(); + section( + "VirtualTable", + "A hundred thousand rows; only the ones in view are drawn, so it scrolls as lightly as ten.", + cx, + ) + .child( + div() + .w(px(560.)) + .h(px(300.)) + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + DataTable::new( + "long", + [ + Column::new("event", "Event"), + Column::new("region", "Region"), + Column::new("value", "Value").end().aggregate(Aggregate::Sum), + ], + ) + .rows(rows) + .virtualized() + .density(Density::Compact) + .size_full(), + ), + ) +} + +pub fn heatmap(cx: &mut App) -> impl IntoElement + use<> { + let mut next = noise(3); + let days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + let table = days.iter().enumerate().fold( + HeatmapTable::new(["6", "9", "12", "15", "18", "21"]), + |table, (day, name)| { + let busy = if day >= 5 { 0.5 } else { 1.0 }; + table.row( + *name, + (0..6).map(|slot| { + (busy + * (40.0 + 60.0 * next()) + * if (1..=4).contains(&slot) { 1.6 } else { 0.7 }) + .round() + }), + ) + }, + ); + section( + "HeatmapTable", + "Visits by weekday and hour; each cell is shaded by where it sits between the quietest and the busiest.", + cx, + ) + .child(div().w(px(560.)).child(table)) + .child(Caption::new("ComparisonTable is Comparison, in Data Display.")) +} diff --git a/examples/gallery/pages/tables/mod.rs b/examples/gallery/pages/tables/mod.rs new file mode 100644 index 0000000..2243dee --- /dev/null +++ b/examples/gallery/pages/tables/mod.rs @@ -0,0 +1,39 @@ +mod core; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 14, + slug: "tables", + title: "Tables", + summary: "Rows and columns that sort, filter, page and select, and stay quick at a hundred thousand rows.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::Rest, + Step::DownAt("orders", 440.0, 16.0), + Step::UpAt("orders", 440.0, 16.0), + Step::Wait(200), + Step::Shot("sorted"), + Step::DownAt("orders", 18.0, 54.0), + Step::UpAt("orders", 18.0, 54.0), + Step::DownAt("orders", 18.0, 126.0), + Step::UpAt("orders", 18.0, 126.0), + Step::Wait(200), + Step::Shot("selected"), + Step::Rest, +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(core::plain(cx)) + .child(core::orders(window, cx)) + .child(core::long(window, cx)) + .child(core::heatmap(cx)) + .into_any_element() +} diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index 507cc14..676b6e1 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -9,6 +9,7 @@ mod feed; mod gallery; mod measures; mod records; +mod spark; mod stars; mod stat; #[cfg(test)] @@ -27,6 +28,7 @@ pub use feed::{Activity, ActivityFeed}; pub use gallery::Gallery; pub use measures::{Gauge, Meter, UsageBar}; pub use records::{DescriptionList, PropertyGrid, PropertyGroup}; +pub use spark::Sparkline; pub use stars::Stars; pub(crate) use stars::star; pub use stat::{KpiCard, Statistic, TrendIndicator}; diff --git a/src/data_display/spark.rs b/src/data_display/spark.rs new file mode 100644 index 0000000..810b207 --- /dev/null +++ b/src/data_display/spark.rs @@ -0,0 +1,163 @@ +use gpui::{ + App, Bounds, Div, Hsla, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, + RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, point, size, +}; + +use crate::theme::ActiveTheme; + +/// How a sparkline draws its series. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Spark { + Line, + Area, + Bars, +} + +/// Each value's height as a share of the series' own range; a flat series sits in the middle. +fn shares(values: &[f32]) -> Vec { + let (low, high) = values.iter().fold((f32::MAX, f32::MIN), |(low, high), v| { + (low.min(*v), high.max(*v)) + }); + let span = high - low; + values + .iter() + .map(|value| { + if span > 0.0 { + (value - low) / span + } else { + 0.5 + } + }) + .collect() +} + +/// A word-sized chart of a series, scaled to its own range: a line, a filled area, or bars. +#[derive(IntoElement)] +pub struct Sparkline { + base: Div, + values: Vec, + spark: Spark, + color: Option, +} + +impl Sparkline { + pub fn new(values: impl IntoIterator) -> Self { + let values: Vec = values.into_iter().collect(); + assert!(values.len() >= 2, "a sparkline needs two values"); + assert!( + values.iter().all(|value| value.is_finite()), + "a sparkline needs finite values" + ); + Self { + base: div(), + values, + spark: Spark::Line, + color: None, + } + } + + /// The line with the area under it lightly filled. + pub fn area(mut self) -> Self { + self.spark = Spark::Area; + self + } + + pub fn bars(mut self) -> Self { + self.spark = Spark::Bars; + self + } + + /// Its ink; the foreground unless set. + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl Styled for Sparkline { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for Sparkline { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let ink = self.color.unwrap_or(theme.colors.fg_muted); + let (heights, spark) = (shares(&self.values), self.spark); + let mut frame = div() + .flex_none() + .w(theme.spark_size().width) + .h(theme.spark_size().height); + frame.style().refine(self.base.style()); + frame.child( + canvas( + |_, _, _| {}, + move |bounds: Bounds, _, window, _| { + let stroke = bounds.size.height / 12.0; + let inner = bounds.size.height - stroke * 2.0; + let count = heights.len() as f32; + if spark == Spark::Bars { + let slot = bounds.size.width / count; + for (ix, share) in heights.iter().enumerate() { + let tall = (inner * *share).max(stroke); + let corner = point( + bounds.left() + slot * ix as f32 + slot * 0.15, + bounds.bottom() - tall, + ); + window + .paint_quad(fill(Bounds::new(corner, size(slot * 0.7, tall)), ink)); + } + return; + } + let at = |ix: usize, share: f32| { + point( + bounds.left() + bounds.size.width * ix as f32 / (count - 1.0), + bounds.top() + stroke + inner * (1.0 - share), + ) + }; + if spark == Spark::Area { + let mut area = PathBuilder::fill(); + area.move_to(point(bounds.left(), bounds.bottom())); + for (ix, share) in heights.iter().enumerate() { + area.line_to(at(ix, *share)); + } + area.line_to(point(bounds.right(), bounds.bottom())); + area.close(); + if let Ok(area) = area.build() { + window.paint_path(area, ink.opacity(0.16)); + } + } + let mut line = PathBuilder::stroke(stroke); + for (ix, share) in heights.iter().enumerate() { + if ix == 0 { + line.move_to(at(ix, *share)); + } else { + line.line_to(at(ix, *share)); + } + } + match line.build() { + Ok(line) => window.paint_path(line, ink), + Err(error) => log::error!("sparkline: the line failed to build: {error:#}"), + } + }, + ) + .size_full(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::shares; + + #[test] + fn values_scale_to_their_own_range() { + assert_eq!(shares(&[2.0, 4.0, 3.0]), [0.0, 1.0, 0.5]); + assert_eq!( + shares(&[7.0, 7.0]), + [0.5, 0.5], + "a flat series sits in the middle" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f850039..60c9aa5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,7 @@ pub mod navigation; pub mod overlays; pub mod primitives; pub mod shell; +pub mod tables; pub mod theme; pub mod typography; diff --git a/src/lists/mod.rs b/src/lists/mod.rs index 8c18e2f..20ea0bb 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -18,6 +18,7 @@ pub use item::{List, ListItem}; pub use long::{InfiniteList, VirtualList}; pub use outline::Outline; pub use select::SelectableList; +pub(crate) use select::{Pick, picked}; pub use sortable::SortableList; pub use swipe::{SwipeAction, SwipeableListItem}; pub use tree::{DropAt, Tree, TreeNode, TreeSelect}; diff --git a/src/lists/select.rs b/src/lists/select.rs index 400ec31..ec60f1f 100644 --- a/src/lists/select.rs +++ b/src/lists/select.rs @@ -10,7 +10,7 @@ use crate::primitives::tab_stop; /// How a press or a key picks rows. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum Pick { +pub(crate) enum Pick { One, Toggle, Range, @@ -18,7 +18,7 @@ pub(super) enum Pick { } /// The selection after picking row `at`, a range reaching back to `anchor`; in row order. -pub(super) fn picked( +pub(crate) fn picked( keys: &[SharedString], selected: &[SharedString], anchor: usize, diff --git a/src/tables/body.rs b/src/tables/body.rs new file mode 100644 index 0000000..8da0d5d --- /dev/null +++ b/src/tables/body.rs @@ -0,0 +1,117 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, Rems, + SharedString, StatefulInteractiveElement, Styled, div, prelude::*, +}; + +use super::{Align, Column, Row, cell::draw, model::tint}; +use crate::{ + forms::{CheckState, check_mark}, + lists::Pick, + theme::{ActiveTheme, TextSize}, +}; + +pub(super) type Select = 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 { + let cell = match column.width { + Some(width) => cell.w(width).flex_none(), + None => cell.flex_1().min_w_0(), + }; + if column.align == Align::End { + cell.justify_end() + } else { + cell + } +} + +/// What drawing a row needs, shared by pages and by a long table's list. +pub(super) struct Body { + pub id: ElementId, + pub columns: Rc>, + pub rows: Rc>, + pub keys: Rc>, + pub shown: Rc>, + pub offset: usize, + pub selected: Option>>, + pub select: Option = selected.clone().map(|selected| { + let (view, ordered, on_select, id) = ( + view.clone(), + ordered.clone(), + self.on_select.clone(), + id.clone(), + ); + Rc::new( + move |position: usize, how: Pick, window: &mut Window, cx: &mut App| { + let next = picked(&ordered, &selected, view.read(cx).anchor, position, how); + view.update(cx, |view, _| { + if how != Pick::Range { + view.anchor = position; + } + }); + log::info!("data table {id:?}: {} selected", next.len()); + if let Some(on_select) = &on_select { + on_select(&next, window, cx); + } + }, + ) 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 all = selected.as_ref().map(|selected| { + let on = ordered.iter().filter(|key| selected.contains(key)).count(); + match on { + 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( + 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(); + }) + }) + }) + .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 { + 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, + }; + let rows: AnyElement = if total == 0 { + div() + .flex() + .justify_center() + .py_6() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(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 { + div() + .children((0..body.shown.len()).map(|at| body.row(at, 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"); + let (first, last) = (page_at * size + 1, ((page_at + 1) * size).min(total)); + let (view, id) = (view.clone(), id.clone()); + div() + .flex() + .items_center() + .justify_between() + .pt_3() + .child(Caption::new(format!("{first}–{last} of {total}"))) + .child( + Pagination::new((id.clone(), "pages"), page_at + 1, pages).on_change( + move |page, _, cx| { + view.update(cx, |view, cx| { + view.page = page - 1; + log::info!("data table {id:?}: page {page}"); + cx.notify(); + }) + }, + ), + ) + }); + 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))); + } +} diff --git a/src/tables/tests.rs b/src/tables/tests.rs new file mode 100644 index 0000000..e80ee25 --- /dev/null +++ b/src/tables/tests.rs @@ -0,0 +1,69 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, Render, SharedString, Styled, TestAppContext, + VisualTestContext, Window, point, px, +}; + +use super::{Column, DataTable, Row}; +use crate::theme::Theme; + +/// Five selectable rows, 36 tall under a 36 tall header; it keeps what the table reports. +struct Picks(Rc>>); + +impl Render for Picks { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (now, store) = (self.0.borrow().clone(), self.0.clone()); + DataTable::new("picks", [Column::new("name", "Name")]) + .rows( + (0..5) + .map(|ix| Row::new(format!("r{ix}"), [format!("Row {ix}").into()])) + .collect::>(), + ) + .selected(now) + .on_select(move |keys, _, _| *store.borrow_mut() = keys.to_vec()) + .w(px(400.0)) + } +} + +fn settle(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); +} + +fn picks(seen: &Rc>>) -> Vec { + seen.borrow().iter().map(|key| key.to_string()).collect() +} + +#[gpui::test] +fn boxes_toggle_shift_takes_a_range_and_the_header_takes_all(cx: &mut TestAppContext) { + cx.update(Theme::init); + let seen = Rc::new(RefCell::new(Vec::new())); + let store = seen.clone(); + let (_, cx) = cx.add_window_view(|_, _| Picks(store)); + settle(cx); + let row = |ix: usize, x: f32| point(px(x), px(54.0 + 36.0 * ix as f32)); + cx.simulate_click(row(0, 14.0), Modifiers::none()); + settle(cx); + assert_eq!(picks(&seen), ["r0"]); + cx.simulate_click( + row(3, 200.0), + Modifiers { + shift: true, + ..Modifiers::none() + }, + ); + settle(cx); + assert_eq!(picks(&seen), ["r0", "r1", "r2", "r3"]); + cx.simulate_click(point(px(14.0), px(18.0)), Modifiers::none()); + settle(cx); + assert_eq!( + picks(&seen), + ["r0", "r1", "r2", "r3", "r4"], + "a mixed header box takes all" + ); + cx.simulate_click(point(px(14.0), px(18.0)), Modifiers::none()); + settle(cx); + assert!(picks(&seen).is_empty(), "a full one clears"); +} diff --git a/src/theme/tokens.rs b/src/theme/tokens.rs index 067404c..3fbf26c 100644 --- a/src/theme/tokens.rs +++ b/src/theme/tokens.rs @@ -284,6 +284,20 @@ impl Theme { px_to_rems(128.0) } + /// A table row's height at each density. + pub fn table_row(&self, density: Density) -> Rems { + px_to_rems(match density { + Density::Compact => 28.0, + Density::Standard => 36.0, + Density::Comfortable => 44.0, + }) + } + + /// A sparkline beside text. + pub fn spark_size(&self) -> Size { + size(px_to_rems(64.0), px_to_rems(18.0)) + } + /// How far each level of a tree steps in. pub fn tree_indent(&self) -> Rems { px_to_rems(16.0) diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index 01b5152..f99ccf4 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -116,32 +116,38 @@ - [x] Menu List → `menus::Menu` - [x] Transfer List → `forms::TransferList` -## T14 Tables — `src/tables` +## T14a Tables · Core — `src/tables` + +- [x] Table → `tables::Table` +- [x] DataTable — sort, filter, page, select → `tables::DataTable` +- [x] VirtualTable → `tables::DataTable`, `virtualized` +- [x] CellRenderer → `tables::Cell`: text, number, tag, progress, sparkline, person, link +- [x] RowSelection → `tables::DataTable`, `selected` +- [x] TableDensity → `tables::DataTable`, `density` +- [x] AggregationFooter → `tables::Column`, `aggregate` +- [x] ConditionalFormatting → `tables::Column`, `scale` +- [x] HeatmapTable → `tables::HeatmapTable` +- [x] ComparisonTable → `data_display::Comparison` + +## T14b Tables · Columns & rows -- [ ] Table -- [ ] DataTable — sort, filter, page, select -- [ ] VirtualTable → `tables::DataTable` -- [ ] DataGrid -- [ ] Spreadsheet — formula subset, merge, freeze, fill handle -- [ ] TreeTable -- [ ] PivotTable - [ ] ColumnResizer - [ ] ColumnReorder - [ ] ColumnVisibility - [ ] ColumnPinning -- [ ] RowSelection - [ ] RowExpansion - [ ] RowGrouping - [ ] InlineRowEdit -- [ ] CellRenderer - [ ] TableToolbar - [ ] FilterBuilder - [ ] SortBuilder -- [ ] AggregationFooter -- [ ] TableDensity -- [ ] ConditionalFormatting -- [ ] HeatmapTable -- [ ] ComparisonTable + +## T14c Tables · Grids + +- [ ] DataGrid +- [ ] Spreadsheet — formula subset, merge, freeze, fill handle +- [ ] TreeTable +- [ ] PivotTable ## T15 Charts — `src/charts` @@ -166,7 +172,7 @@ - [ ] NetworkGraph / ForceGraph - [ ] ChordDiagram - [ ] ParallelCoordinates -- [ ] Sparkline / SparkBar / SparkArea +- [x] Sparkline / SparkBar / SparkArea → `data_display::Sparkline`, landed with T14a for table cells - [ ] Bullet Chart - [ ] ProgressChart - [ ] RealtimeChart