T14b Tables: resize, reorder, visibility, pinning, expansion, grouping, inline edit, toolbar, filter and sort builders (review pending: codex 401)

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 20:30:41 -04:00
1 parent dcd988e4b8
commit 0bfde95888
17 files changed
+2292 -420

No files matched your search

+2
View File
@@ -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()`.
+1
View File
@@ -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 |
+111 -40
View File
@@ -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<Row> {
pub(super) fn order_rows() -> Vec<Row> {
let mut next = noise(7);
(0..24)
.map(|ix| {
@@ -82,7 +86,7 @@ fn order_rows() -> Vec<Row> {
.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::<SharedString>::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::<SharedString>::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::<usize>, 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<> {
+125
View File
@@ -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::<Vec<_>>())))
}
/// 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<Row> = 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::<Vec<_>>())
.group_by("region"),
),
))
.child(Caption::new(
"Groups keep the order their first rows come in; sort to reorder them.",
))
}
+20
View File
@@ -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()
+273 -62
View File
@@ -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<dyn Fn(usize, Pick, &mut gpui::Window, &mut App)>;
pub(super) type Select = Rc<dyn Fn(usize, Pick, &mut Window, &mut App)>;
pub(super) type Detail = Rc<dyn Fn(&SharedString, &mut Window, &mut App) -> AnyElement>;
pub(super) type OnEdit =
Rc<dyn Fn(&SharedString, &SharedString, &SharedString, &mut Window, &mut App)>;
/// Gives a cell its column's width: fixed, or a share of what is left.
pub(super) fn sized<E: Styled>(cell: E, column: &Column) -> E {
pub(super) fn sized<E: Styled>(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<E: Styled>(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<View>,
pub columns: Rc<Vec<Column>>,
pub widths: Rc<Vec<Option<Pixels>>>,
pub rows: Rc<Vec<Row>>,
pub keys: Rc<Vec<SharedString>>,
pub shown: Rc<Vec<usize>>,
pub offset: usize,
pub lines: Rc<Vec<Line>>,
pub selected: Option<Rc<Vec<SharedString>>>,
pub select: Option<Select>,
pub detail: Option<Detail>,
pub open: Rc<HashSet<SharedString>>,
pub editing: Option<(SharedString, SharedString)>,
pub editor: Entity<Editing>,
pub on_edit: Option<OnEdit>,
pub ranges: Rc<Vec<Option<(f64, f64)>>>,
pub height: Rems,
pub check: Rems,
pub lead: Rems,
pub narrowest: Rems,
}
impl Body {
pub(super) fn row(&self, at: usize, cx: &App) -> AnyElement {
let ix = self.shown[at];
let key = self.keys[ix].clone();
/// How wide the leading cells stand: a box to select, a disclosure to expand.
pub(super) fn lead_width(&self) -> Rems {
self.lead * (usize::from(self.select.is_some()) + usize::from(self.detail.is_some())) as f32
}
/// Line `at` over columns `cols`; `lead` draws the leading cells.
pub(super) fn line(
&self,
at: usize,
cols: &[usize],
lead: bool,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
match self.lines[at].clone() {
Line::Group {
name,
count,
folded,
} => self.group(name, count, folded, lead, cx),
Line::Row { ix, position } => self.row(ix, position, cols, lead, cx),
Line::Detail { ix } => {
let key = self.rows[ix].key.clone();
let detail = self
.detail
.clone()
.expect("a detail line comes from a detail");
let content = detail(&key, window, cx);
let colors = &cx.theme().colors;
div()
.w_full()
.pl(self.lead_width())
.pr_3()
.py_3()
.bg(colors.sunken)
.border_b_1()
.border_color(colors.border)
.child(content)
.into_any_element()
}
}
}
fn group(
&self,
name: SharedString,
count: usize,
folded: bool,
lead: bool,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let row = div()
.id((self.id.clone(), format!("group-{name}-{lead}")))
.w_full()
.h(self.height)
.flex()
.items_center()
.gap_2()
.px_3()
.bg(colors.sunken)
.border_b_1()
.border_color(colors.border)
.text_size(theme.text_size(TextSize::Sm));
if !lead {
return row.into_any_element();
}
let view = self.view.clone();
let title = if name.is_empty() {
SharedString::from("—")
} else {
name.clone()
};
row.cursor_pointer()
.on_click(move |_, _, cx| {
view.update(cx, |view, cx| {
if !view.folded.remove(&name) {
view.folded.insert(name.clone());
}
cx.notify();
})
})
.child(Disclosure::new(
(self.id.clone(), format!("fold-{title}")),
!folded,
))
.child(
div()
.font_weight(gpui::FontWeight::MEDIUM)
.text_color(colors.fg)
.child(title),
)
.child(div().text_color(colors.fg_subtle).child(count.to_string()))
.into_any_element()
}
fn row(&self, ix: usize, position: usize, cols: &[usize], lead: bool, cx: &App) -> AnyElement {
let key = self.rows[ix].key.clone();
let on = self
.selected
.as_ref()
.is_some_and(|selected| selected.contains(&key));
let theme = cx.theme();
let colors = &theme.colors;
let position = self.offset + at;
let press = self.select.clone();
let open = self.open.contains(&key);
div()
.id((self.id.clone(), format!("row-{key}")))
.id((self.id.clone(), format!("row-{key}-{lead}")))
.flex()
.items_center()
.w_full()
@@ -66,52 +190,139 @@ impl Body {
.text_color(colors.fg)
.when(on, |row| row.bg(colors.active))
.when(!on, |row| row.hover(|style| style.bg(colors.hover)))
.when_some(self.select.clone(), |row, select| {
row.cursor_pointer()
.child(
div()
.flex_none()
.w(self.check)
.flex()
.justify_center()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
select(position, Pick::Toggle, window, cx);
.when_some(self.select.clone().filter(|_| lead), |row, select| {
row.child(
div()
.flex_none()
.w(self.lead)
.flex()
.justify_center()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
cx.stop_propagation();
select(position, Pick::Toggle, window, cx);
})
.child(check_mark(
if on { CheckState::On } else { CheckState::Off },
false,
false,
0,
cx,
)),
)
})
.when(self.detail.is_some() && lead, |row| {
let (view, key, name) = (self.view.clone(), key.clone(), key.clone());
row.child(
div()
.flex_none()
.w(self.lead)
.flex()
.justify_center()
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, _, cx| {
cx.stop_propagation();
view.update(cx, |view, cx| {
if !view.open.remove(&key) {
view.open.insert(key.clone());
}
cx.notify();
})
.child(check_mark(
if on { CheckState::On } else { CheckState::Off },
false,
false,
0,
cx,
)),
)
.on_click(move |event, window, cx| {
let held = event.modifiers();
let how = match (held.shift, held.platform) {
(true, _) => Pick::Range,
(false, true) => Pick::Toggle,
_ => Pick::One,
};
if let Some(press) = &press {
press(position, how, window, cx);
})
.child(Disclosure::new(
(self.id.clone(), format!("open-{name}")),
open,
)),
)
})
.when_some(press, |row, press| {
row.cursor_pointer().on_click(move |event, window, cx| {
let held = event.modifiers();
let how = match (held.shift, held.platform) {
(true, _) => Pick::Range,
(false, true) => Pick::Toggle,
_ => Pick::One,
};
press(position, how, window, cx);
})
})
.children(cols.iter().map(|col| self.cell(ix, *col, cx)))
.into_any_element()
}
fn cell(&self, ix: usize, col: usize, cx: &App) -> AnyElement {
let (row, column) = (&self.rows[ix], &self.columns[col]);
let cell = &row.cells[col];
let colors = &cx.theme().colors;
let editing = self
.editing
.as_ref()
.is_some_and(|(key, at)| *key == row.key && *at == column.key);
let tinted = self.ranges[col]
.zip(cell.number())
.map(|(range, value)| tint(value, range, colors));
let frame = div()
.id((self.id.clone(), format!("cell-{}-{col}", row.key)))
.h_full()
.flex()
.items_center()
.px_3();
let frame = match self.widths[col] {
Some(width) => frame
.w(width)
.flex_none()
.when(column.align == Align::End, |cell| cell.justify_end()),
None => sized(frame, column, self.narrowest),
};
let content = match editing.then(|| self.editor.read(cx).field()).flatten() {
Some(field) => {
let editor = self.editor.clone();
div()
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
editor.update(cx, |editing, cx| editing.finish(true, window, cx));
}
})
})
.children(self.columns.iter().enumerate().map(|(col, column)| {
let cell = &self.rows[ix].cells[col];
let tinted = self.ranges[col]
.zip(cell.number())
.map(|(range, value)| tint(value, range, colors));
sized(div().h_full().flex().items_center().px_3(), column)
.when_some(tinted, |cell, bg| cell.bg(bg))
.child(draw(
cell,
column,
(self.id.clone(), format!("cell-{key}-{col}")).into(),
cx,
))
}))
.child(Input::new(&field).size(ControlSize::Sm))
.into_any_element()
}
None => draw(
cell,
column,
(self.id.clone(), format!("draw-{}-{col}", row.key)).into(),
cx,
),
};
frame
.when_some(tinted, |cell, bg| cell.bg(bg))
.when(
column.editable && !editing && self.on_edit.is_some(),
|frame| {
let (view, editor, on_edit) = (
self.view.clone(),
self.editor.clone(),
self.on_edit.clone().expect("checked"),
);
let (key, at, words) = (row.key.clone(), column.key.clone(), cell.words());
frame.cursor_text().on_click(move |event, window, cx| {
if event.click_count() != 2 {
return;
}
cx.stop_propagation();
let (key, at, on_edit) = (key.clone(), at.clone(), on_edit.clone());
view.update(cx, |view, _| view.editing = Some((key.clone(), at.clone())));
editor.update(cx, |editing, _| {
editing.on_commit = Some(Rc::new(move |text, window, cx| {
on_edit(&key, &at, text, window, cx)
}))
});
Editing::begin(&editor, words.to_string(), window, cx);
})
},
)
.child(content)
.into_any_element()
}
}
+383
View File
@@ -0,0 +1,383 @@
use std::{cell::RefCell, rc::Rc};
use gpui::{
App, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Subscription, Window, div, prelude::*,
};
use super::{FilterRule, SortKey, Test};
use crate::{
buttons::{Button, ButtonVariant, IconButton, SegmentedControl},
forms::{Choice, Input, InputEvent, Select, TextInput},
motion::Reorder,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize},
};
type OnFilters = Rc<dyn Fn(&[FilterRule], bool, &mut Window, &mut App)>;
type OnSorts = Rc<dyn Fn(&[SortKey], &mut Window, &mut App)>;
/// The newest rules and hearer, for value fields whose edits come later.
type Latest = Rc<RefCell<(Vec<FilterRule>, bool, Option<OnFilters>)>>;
/// A builder's value fields, one per rule, and what their edits report to.
#[derive(Default)]
struct Fields {
fields: Vec<(Entity<TextInput>, Subscription)>,
latest: Latest,
}
fn choices(columns: &[(SharedString, SharedString)]) -> Vec<Choice> {
columns
.iter()
.map(|(key, title)| Choice::new(key.clone(), title.clone()))
.collect()
}
/// Filter rules to build: each a column, a test and a value, and a switch between keeping rows that pass all and rows that pass any. The owner keeps the rules and hands them to a `DataTable`.
#[derive(IntoElement)]
pub struct FilterBuilder {
id: ElementId,
columns: Vec<(SharedString, SharedString)>,
rules: Vec<FilterRule>,
any: bool,
on_change: Option<OnFilters>,
}
impl FilterBuilder {
/// `columns` are each a key and a title.
pub fn new(
id: impl Into<ElementId>,
columns: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
let columns: Vec<_> = columns
.into_iter()
.map(|(key, title)| (key.into(), title.into()))
.collect();
assert!(!columns.is_empty(), "a filter builder needs a column");
Self {
id: id.into(),
columns,
rules: Vec::new(),
any: false,
on_change: None,
}
}
pub fn rules(mut self, rules: impl IntoIterator<Item = FilterRule>, any: bool) -> Self {
self.rules = rules.into_iter().collect();
self.any = any;
self
}
pub fn on_change(
mut self,
handler: impl Fn(&[FilterRule], bool, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for FilterBuilder {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let fields =
window.use_keyed_state((self.id.clone(), "fields"), cx, |_, _| Fields::default());
let latest = fields.read(cx).latest.clone();
*latest.borrow_mut() = (self.rules.clone(), self.any, self.on_change.clone());
while fields.read(cx).fields.len() < self.rules.len() {
let ix = fields.read(cx).fields.len();
let field = cx.new(|cx| TextInput::new(window, cx));
let latest = latest.clone();
let heard = window.subscribe(&field, cx, move |field, event, window, cx| {
if !matches!(event, InputEvent::Changed) {
return;
}
let (mut rules, any, on_change) = latest.borrow().clone();
let Some(rule) = rules.get_mut(ix) else {
return;
};
rule.value = field.read(cx).text().to_string().into();
if let Some(on_change) = on_change {
on_change(&rules, any, window, cx);
}
});
fields.update(cx, |fields, _| fields.fields.push((field, heard)));
}
fields.update(cx, |fields, _| fields.fields.truncate(self.rules.len()));
let entities: Vec<Entity<TextInput>> = fields
.read(cx)
.fields
.iter()
.map(|(field, _)| field.clone())
.collect();
for (field, rule) in entities.iter().zip(&self.rules) {
let typing = field.read(cx).focus().is_focused(window);
if !typing && field.read(cx).text() != rule.value.as_ref() {
let value = rule.value.to_string();
field.update(cx, |field, cx| field.set_text(value, cx));
}
}
let emit = move |rules: Vec<FilterRule>, any: bool, window: &mut Window, cx: &mut App| {
let hearer = latest.borrow().2.clone();
log::info!(
"filter builder: {} rules, {}",
rules.len(),
if any { "any" } else { "all" }
);
if let Some(on_change) = hearer {
on_change(&rules, any, window, cx);
}
};
let emit = Rc::new(emit);
let tests: Vec<Choice> = Test::ALL
.iter()
.map(|test| Choice::new(test.label(), test.label()))
.collect();
let theme = cx.theme();
let (rules, any, id) = (self.rules.clone(), self.any, self.id.clone());
let rows: Vec<_> = self
.rules
.iter()
.enumerate()
.map(|(ix, rule)| {
let (column, test, remove) = (emit.clone(), emit.clone(), emit.clone());
let (for_column, for_test, for_remove) =
(rules.clone(), rules.clone(), rules.clone());
div()
.flex()
.items_center()
.gap_2()
.child(
div().w(theme.label_width()).child(
Select::new(
(id.clone(), format!("column-{ix}")),
choices(&self.columns),
)
.selected(rule.column.clone())
.size(ControlSize::Sm)
.on_change(move |key, window, cx| {
let mut next = for_column.clone();
next[ix].column = key.clone();
column(next, any, window, cx)
}),
),
)
.child(
div().w(theme.label_width() * 0.75).child(
Select::new((id.clone(), format!("test-{ix}")), tests.clone())
.selected(rule.test.label())
.size(ControlSize::Sm)
.on_change(move |label, window, cx| {
let mut next = for_test.clone();
next[ix].test = *Test::ALL
.iter()
.find(|test| test.label() == label.as_ref())
.expect("a listed test");
test(next, any, window, cx)
}),
),
)
.child(div().flex_1().when(rule.test.takes_value(), |slot| {
slot.child(Input::new(&entities[ix]).size(ControlSize::Sm))
}))
.child(
IconButton::new((id.clone(), format!("remove-{ix}")), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
let mut next = for_remove.clone();
next.remove(ix);
remove(next, any, window, cx)
}),
)
})
.collect();
let (add, join, first) = (emit.clone(), emit, self.columns[0].0.clone());
let (to_add, to_join) = (self.rules.clone(), self.rules.clone());
div().flex().flex_col().gap_2().children(rows).child(
div()
.flex()
.items_center()
.justify_between()
.child(
Button::new((self.id.clone(), "add"), "Add rule")
.icon(IconName::Plus)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
let mut next = to_add.clone();
next.push(FilterRule {
column: first.clone(),
..FilterRule::default()
});
add(next, any, window, cx)
}),
)
.when(self.rules.len() > 1, |footer| {
footer.child(
SegmentedControl::new(
(self.id.clone(), "join"),
if any { "any" } else { "all" },
)
.segment("all", "Match all", None)
.segment("any", "Match any", None)
.size(ControlSize::Sm)
.on_change(move |value, window, cx| {
join(to_join.clone(), value.as_ref() == "any", window, cx)
}),
)
}),
)
}
}
/// Sort keys to build, the first deciding most: each a column and a direction. Add one, flip it, remove it, or drag it to a new place. The owner keeps the keys and hands them to a `DataTable`.
#[derive(IntoElement)]
pub struct SortBuilder {
id: ElementId,
columns: Vec<(SharedString, SharedString)>,
keys: Vec<SortKey>,
on_change: Option<OnSorts>,
}
impl SortBuilder {
/// `columns` are each a key and a title.
pub fn new(
id: impl Into<ElementId>,
columns: impl IntoIterator<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
) -> Self {
Self {
id: id.into(),
columns: columns
.into_iter()
.map(|(key, title)| (key.into(), title.into()))
.collect(),
keys: Vec::new(),
on_change: None,
}
}
pub fn keys(mut self, keys: impl IntoIterator<Item = SortKey>) -> Self {
self.keys = keys.into_iter().collect();
self
}
pub fn on_change(
mut self,
handler: impl Fn(&[SortKey], &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SortBuilder {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let emit: OnSorts = {
let on_change = self.on_change.clone();
Rc::new(move |keys: &[SortKey], window: &mut Window, cx: &mut App| {
log::info!("sort builder: {} keys", keys.len());
if let Some(on_change) = &on_change {
on_change(keys, window, cx);
}
})
};
let theme = cx.theme();
let (keys, id, grip) = (self.keys.clone(), self.id.clone(), theme.colors.fg_subtle);
let unused = self
.columns
.iter()
.find(|(key, _)| !keys.iter().any(|sort| sort.column == *key))
.map(|(key, _)| key.clone());
let moved = (emit.clone(), keys.clone());
let list = self.keys.iter().enumerate().fold(
Reorder::new((self.id.clone(), "keys")).on_reorder(move |from, to, window, cx| {
let mut next = moved.1.clone();
let key = next.remove(from);
next.insert(to, key);
(moved.0)(&next, window, cx)
}),
|list, (ix, sort)| {
let (column, flip, remove) = (emit.clone(), emit.clone(), emit.clone());
let (for_column, for_flip, for_remove) = (keys.clone(), keys.clone(), keys.clone());
list.row(
sort.column.clone(),
div()
.flex()
.items_center()
.gap_2()
.py_1()
.child(
Icon::new(IconName::GripVertical)
.size(IconSize::Sm)
.color(grip),
)
.child(
div().w(theme.label_width()).child(
Select::new(
(id.clone(), format!("sort-column-{ix}")),
choices(&self.columns),
)
.selected(sort.column.clone())
.size(ControlSize::Sm)
.on_change(
move |key, window, cx| {
let mut next = for_column.clone();
next[ix].column = key.clone();
column(&next, window, cx)
},
),
),
)
.child(
SegmentedControl::new(
(id.clone(), format!("sort-way-{ix}")),
if sort.rising { "up" } else { "down" },
)
.segment("up", "Ascending", Some(IconName::ArrowUp))
.segment("down", "Descending", Some(IconName::ArrowDown))
.size(ControlSize::Sm)
.on_change(move |way, window, cx| {
let mut next = for_flip.clone();
next[ix].rising = way.as_ref() == "up";
flip(&next, window, cx)
}),
)
.child(
IconButton::new((id.clone(), format!("sort-remove-{ix}")), IconName::X)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
let mut next = for_remove.clone();
next.remove(ix);
remove(&next, window, cx)
}),
),
)
},
);
let add = (emit, keys);
div()
.flex()
.flex_col()
.gap_1()
.child(list)
.children(unused.map(|column| {
Button::new((self.id.clone(), "add"), "Add sort")
.icon(IconName::Plus)
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.on_click(move |_, window, cx| {
let mut next = add.1.clone();
next.push(SortKey {
column: column.clone(),
rising: true,
});
(add.0)(&next, window, cx)
})
}))
}
}
+24
View File
@@ -108,6 +108,8 @@ pub struct Column {
pub(crate) suffix: Option<SharedString>,
pub(crate) aggregate: Option<Aggregate>,
pub(crate) scale: bool,
pub(crate) editable: bool,
pub(crate) pinned: bool,
}
impl Column {
@@ -123,6 +125,8 @@ impl Column {
suffix: None,
aggregate: None,
scale: false,
editable: false,
pinned: false,
}
}
@@ -171,6 +175,26 @@ impl Column {
self
}
/// A double press on a cell edits its text; the table's `on_edit` gets it.
pub fn editable(mut self) -> Self {
self.editable = true;
self
}
/// Holds still at the start while the other columns scroll sideways. Needs a width.
pub fn pinned(mut self) -> Self {
self.pinned = true;
self
}
pub fn key(&self) -> &SharedString {
&self.key
}
pub fn title(&self) -> &SharedString {
&self.title
}
/// A number as this column reads it.
pub(crate) fn reads(&self, value: f64) -> String {
format!(
+285
View File
@@ -0,0 +1,285 @@
use gpui::{
App, DragMoveEvent, ElementId, Entity, EntityId, FontWeight, InteractiveElement, IntoElement,
ParentElement, Pixels, Rems, SharedString, StatefulInteractiveElement, Styled, canvas, div,
prelude::*,
};
use super::{Column, body::sized, table::View};
use crate::layout::seeded::Seeded;
use crate::{
primitives::{DragGhost, Icon, IconName},
theme::{ActiveTheme, IconSize, TextSize},
};
/// A header's right edge being dragged, to resize its column.
pub(crate) struct Resize {
owner: EntityId,
key: SharedString,
}
/// A header being dragged, to move its column.
pub(crate) struct Move {
owner: EntityId,
key: SharedString,
}
/// The sort after a press on column `key`: alone it replaces the sort, with Shift it joins it; each press turns it rising, falling, then off.
pub(crate) fn next_sort(
sort: &[(SharedString, bool)],
key: &SharedString,
join: bool,
) -> Vec<(SharedString, bool)> {
let now = sort
.iter()
.find(|(known, _)| known == key)
.map(|(_, rising)| *rising);
let turned = match now {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
let mut next: Vec<(SharedString, bool)> = if join {
sort.iter()
.filter(|(known, _)| known != key)
.cloned()
.collect()
} else {
Vec::new()
};
let at = if join {
sort.iter()
.position(|(known, _)| known == key)
.unwrap_or(next.len())
} else {
0
};
if let Some(rising) = turned {
next.insert(at.min(next.len()), (key.clone(), rising));
}
next
}
/// The column order after moving `key` in front of `target`.
pub(crate) fn moved(
order: &[SharedString],
key: &SharedString,
target: &SharedString,
) -> Vec<SharedString> {
let mut next: Vec<SharedString> = order
.iter()
.filter(|known| *known != key)
.cloned()
.collect();
let at = next
.iter()
.position(|known| known == target)
.unwrap_or(next.len());
next.insert(at, key.clone());
next
}
/// One header cell: its title and sort mark; a press sorts, a drag moves it, and its right edge resizes.
pub(crate) fn header(
id: &ElementId,
view: &Entity<View>,
sorting: &Entity<Seeded<Vec<(SharedString, bool)>>>,
column: &Column,
width: Option<Pixels>,
narrowest: Rems,
cx: &App,
) -> impl IntoElement + use<> {
let sort = sorting.read(cx).value.clone();
let theme = cx.theme();
let colors = &theme.colors;
let owner = view.entity_id();
let place = sort.iter().position(|(known, _)| *known == column.key);
let mark = place.map(|place| (sort[place].1, (sort.len() > 1).then_some(place + 1)));
let key = column.key.clone();
let (sorter, pager, mover, sizer, edges) = (
sorting.clone(),
view.clone(),
view.clone(),
view.clone(),
view.clone(),
);
let title = column.title.clone();
let cell = div()
.id((id.clone(), format!("head-{key}")))
.relative()
.h_full()
.flex()
.items_center()
.gap_1()
.px_3()
.text_size(theme.text_size(TextSize::Xs))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted);
let cell = match width {
Some(width) => cell.w(width).flex_none(),
None => sized(cell, column, narrowest),
};
cell.when(column.sortable, |cell| {
let key = key.clone();
cell.cursor_pointer()
.hover(|style| style.text_color(colors.fg))
.on_click(move |event, _, cx| {
let join = event.modifiers().shift;
sorter.update(cx, |sorting, cx| {
sorting.value = next_sort(&sorting.value, &key, join);
log::info!("data table: sort {:?}", sorting.value);
cx.notify();
});
pager.update(cx, |view, _| view.page = 0);
})
})
.on_drag(
Move {
owner,
key: key.clone(),
},
move |_, _, _, cx| DragGhost::new(title.clone(), None, cx),
)
.on_drop({
let key = key.clone();
move |drag: &Move, _, cx| {
if drag.owner != owner || drag.key == key {
return;
}
mover.update(cx, |view, cx| {
view.order = moved(&view.order, &drag.key, &key);
log::info!("data table: moved {} before {key}", drag.key);
cx.notify();
})
}
})
.child(column.title.clone())
.children(mark.map(|(rising, rank)| {
div()
.flex()
.items_center()
.child(
Icon::new(if rising {
IconName::ArrowUp
} else {
IconName::ArrowDown
})
.size(IconSize::Xs)
.color(colors.fg_muted),
)
.children(rank.map(|rank| {
div()
.text_size(theme.text_size(TextSize::Xs))
.child(rank.to_string())
}))
}))
.child(grip(id, &key, owner, sizer, theme.handle_hit(), cx))
.child(
canvas(
move |bounds, _, cx| {
if edges.read(cx).edges.get(&key) != Some(&bounds.left()) {
edges.update(cx, |view, _| view.edges.insert(key.clone(), bounds.left()));
}
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
)
}
/// The resize grip on a header's right edge.
fn grip(
id: &ElementId,
key: &SharedString,
owner: EntityId,
view: Entity<View>,
hit: Rems,
cx: &App,
) -> impl IntoElement + use<> {
let border = cx.theme().colors.border_strong;
let key = key.clone();
div()
.id((id.clone(), format!("grip-{key}")))
.absolute()
.top_1()
.bottom_1()
.right_0()
.w(hit)
.flex()
.justify_center()
.cursor_col_resize()
.hover(|style| style.bg(border.opacity(0.2)))
.child(
div()
.h_full()
.border_r_1()
.border_color(border.opacity(0.5)),
)
.on_drag(
Resize {
owner,
key: key.clone(),
},
|_, _, _, cx| cx.new(|_| gpui::EmptyView),
)
.on_drag_move(move |event: &DragMoveEvent<Resize>, _, cx| {
let drag = event.drag(cx);
if drag.owner != owner || drag.key != key {
return;
}
let Some(left) = view.read(cx).edges.get(&key).copied() else {
return;
};
let wide = (event.event.position.x - left).max(view.read(cx).narrowest);
view.update(cx, |view, cx| {
view.widths.insert(key.clone(), wide);
cx.notify();
})
})
}
#[cfg(test)]
mod tests {
use gpui::SharedString;
use super::{moved, next_sort};
fn keys(list: &[&'static str]) -> Vec<SharedString> {
list.iter().map(|key| SharedString::from(*key)).collect()
}
#[test]
fn a_press_replaces_the_sort_and_shift_joins_it() {
let a = SharedString::from("a");
let b = SharedString::from("b");
assert_eq!(next_sort(&[], &a, false), [(a.clone(), true)]);
assert_eq!(
next_sort(&[(a.clone(), true)], &b, true),
[(a.clone(), true), (b.clone(), true)]
);
assert_eq!(
next_sort(&[(a.clone(), true), (b.clone(), true)], &a, true),
[(a.clone(), false), (b.clone(), true)]
);
assert_eq!(
next_sort(&[(a.clone(), false), (b.clone(), true)], &a, true),
[(b.clone(), true)]
);
assert_eq!(
next_sort(&[(a.clone(), true), (b.clone(), true)], &b, false),
[(b, false)]
);
}
#[test]
fn a_column_moves_in_front_of_its_target() {
assert_eq!(
moved(&keys(&["a", "b", "c"]), &"c".into(), &"a".into()),
keys(&["c", "a", "b"])
);
assert_eq!(
moved(&keys(&["a", "b", "c"]), &"a".into(), &"c".into()),
keys(&["b", "a", "c"])
);
}
}
+8
View File
@@ -1,11 +1,19 @@
mod body;
mod builders;
mod cell;
mod header;
mod model;
mod options;
mod rules;
mod simple;
mod table;
#[cfg(test)]
mod tests;
mod toolbar;
pub use builders::{FilterBuilder, SortBuilder};
pub use cell::{Aggregate, Align, Cell, Column};
pub use rules::{FilterRule, SortKey, Test, to_csv};
pub use simple::{HeatmapTable, Table};
pub use table::{DataTable, Row};
pub use toolbar::TableToolbar;
+6 -22
View File
@@ -1,5 +1,3 @@
use std::cmp::Ordering;
use gpui::Hsla;
use super::{Aggregate, Row};
@@ -19,19 +17,6 @@ pub(crate) fn filtered(rows: &[Row], query: &str) -> Vec<usize> {
.collect()
}
/// `order` sorted by column `col`, rising or falling: numbers by value, the rest by their words; ties keep their order.
pub(crate) fn sorted(rows: &[Row], mut order: Vec<usize>, col: usize, rising: bool) -> Vec<usize> {
order.sort_by(|a, b| {
let (a, b) = (&rows[*a].cells[col], &rows[*b].cells[col]);
let by = match (a.number(), b.number()) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
_ => a.words().to_lowercase().cmp(&b.words().to_lowercase()),
};
if rising { by } else { by.reverse() }
});
order
}
/// Column `col`'s figure over `order`; none when no cell counts.
pub(crate) fn figure(rows: &[Row], order: &[usize], col: usize, how: Aggregate) -> Option<f64> {
let values: Vec<f64> = order
@@ -128,13 +113,12 @@ mod tests {
#[test]
fn numbers_sort_by_value_and_words_ignore_case() {
let rows = rows();
assert_eq!(
sorted(&rows, vec![0, 1, 2, 3], 1, true),
[1, 2, 0, 3],
"ties keep their order"
);
assert_eq!(sorted(&rows, vec![0, 1, 2, 3], 1, false), [3, 0, 1, 2]);
assert_eq!(sorted(&rows, vec![0, 1, 2, 3], 0, true), [2, 1, 0, 3]);
let sorted = |col: usize, rising: bool| {
crate::tables::rules::sorted_by(&rows, vec![0, 1, 2, 3], &[(col, rising)])
};
assert_eq!(sorted(1, true), [1, 2, 0, 3], "ties keep their order");
assert_eq!(sorted(1, false), [3, 0, 1, 2]);
assert_eq!(sorted(0, true), [2, 1, 0, 3]);
}
#[test]
+120
View File
@@ -0,0 +1,120 @@
use std::rc::Rc;
use gpui::{AnyElement, App, ElementId, SharedString, Window, div};
use super::{Column, FilterRule, Row, SortKey, table::DataTable};
use crate::theme::Density;
impl DataTable {
pub fn new(id: impl Into<ElementId>, columns: impl IntoIterator<Item = Column>) -> Self {
Self {
id: id.into(),
base: div(),
columns: columns.into_iter().collect(),
rows: Rc::default(),
query: SharedString::default(),
filters: Vec::new(),
any: false,
sorts: Vec::new(),
hidden: Vec::new(),
page_size: None,
virtualized: false,
selected: None,
on_select: None,
detail: None,
group_by: None,
on_edit: None,
density: Density::Standard,
}
}
/// The rows, owned or shared; a shared list is not copied.
pub fn rows(mut self, rows: impl Into<Rc<Vec<Row>>>) -> Self {
self.rows = rows.into();
self
}
/// Keeps the rows holding this text in any cell.
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
/// Rules from a `FilterBuilder`: rows pass them all, or any when `any`.
pub fn filters(mut self, rules: impl IntoIterator<Item = FilterRule>, any: bool) -> Self {
self.filters = rules.into_iter().collect();
self.any = any;
self
}
/// Sort keys from the owner, such as a `SortBuilder`'s; header presses change them until the owner sends new ones.
pub fn sorts(mut self, keys: impl IntoIterator<Item = SortKey>) -> Self {
self.sorts = keys
.into_iter()
.map(|key| (key.column, key.rising))
.collect();
self
}
/// Columns left out, such as a toolbar's menu hides.
pub fn hidden(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.hidden = keys.into_iter().map(Into::into).collect();
self
}
/// Splits the rows into pages of `size`.
pub fn paged(mut self, size: usize) -> Self {
assert!(size > 0, "a page holds at least a row");
self.page_size = Some(size);
self
}
/// Draws only the rows in view, for long tables. Give it a height.
pub fn virtualized(mut self) -> Self {
self.virtualized = true;
self
}
/// Shows a box on each row; `keys` are the rows selected.
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = Some(keys.into_iter().map(Into::into).collect());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
/// A disclosure on each row opens what `detail` draws for it, under the row.
pub fn detail(
mut self,
detail: impl Fn(&SharedString, &mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.detail = Some(Rc::new(detail));
self
}
/// Gathers rows under their value in column `key`; each group folds.
pub fn group_by(mut self, key: impl Into<SharedString>) -> Self {
self.group_by = Some(key.into());
self
}
/// Gets a row's key, a column's key and the new text, after a double press on an editable cell.
pub fn on_edit(
mut self,
handler: impl Fn(&SharedString, &SharedString, &SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_edit = Some(Rc::new(handler));
self
}
pub fn density(mut self, density: Density) -> Self {
self.density = density;
self
}
}
+285
View File
@@ -0,0 +1,285 @@
use std::cmp::Ordering;
use gpui::SharedString;
use super::{Cell, Column, Row};
/// How a filter rule compares a cell with its value.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Test {
#[default]
Contains,
Equals,
NotEquals,
Above,
Below,
Empty,
}
impl Test {
pub const ALL: [Test; 6] = [
Test::Contains,
Test::Equals,
Test::NotEquals,
Test::Above,
Test::Below,
Test::Empty,
];
pub fn label(self) -> &'static str {
match self {
Self::Contains => "contains",
Self::Equals => "is",
Self::NotEquals => "is not",
Self::Above => "is above",
Self::Below => "is below",
Self::Empty => "is empty",
}
}
/// Whether the test reads a value at all.
pub fn takes_value(self) -> bool {
self != Self::Empty
}
}
/// One filter rule: a column by key, a test, and the value it compares with.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FilterRule {
pub column: SharedString,
pub test: Test,
pub value: SharedString,
}
/// One sort key: a column by key, and which way.
#[derive(Clone, Debug, PartialEq)]
pub struct SortKey {
pub column: SharedString,
pub rising: bool,
}
/// Whether a cell passes a test against `value`; numbers compare as numbers when both read as one.
pub(crate) fn passes(cell: &Cell, test: Test, value: &str) -> bool {
let words = cell.words().to_lowercase();
let value = value.trim().to_lowercase();
let number = value.parse::<f64>().ok();
match (test, cell.number(), number) {
(Test::Empty, _, _) => words.is_empty(),
(Test::Contains, _, _) => words.contains(&value),
(Test::Equals, Some(cell), Some(value)) => cell == value,
(Test::Equals, _, _) => words == value,
(Test::NotEquals, Some(cell), Some(value)) => cell != value,
(Test::NotEquals, _, _) => words != value,
(Test::Above, Some(cell), Some(value)) => cell > value,
(Test::Below, Some(cell), Some(value)) => cell < value,
(Test::Above | Test::Below, _, _) => false,
}
}
/// `order` with the rows that pass every rule, or any when `any`. A rule with no value yet, or for a column not shown, passes.
pub(crate) fn kept(
rows: &[Row],
columns: &[Column],
order: Vec<usize>,
rules: &[FilterRule],
any: bool,
) -> Vec<usize> {
let tests: Vec<(usize, &FilterRule)> = rules
.iter()
.filter(|rule| !rule.test.takes_value() || !rule.value.trim().is_empty())
.filter_map(|rule| {
columns
.iter()
.position(|column| column.key == rule.column)
.map(|col| (col, rule))
})
.collect();
if tests.is_empty() {
return order;
}
order
.into_iter()
.filter(|ix| {
let pass = |(col, rule): &(usize, &FilterRule)| {
passes(&rows[*ix].cells[*col], rule.test, &rule.value)
};
if any {
tests.iter().any(pass)
} else {
tests.iter().all(pass)
}
})
.collect()
}
/// `order` sorted by each key in turn, the first deciding most; numbers by value, the rest by their words.
pub(crate) fn sorted_by(rows: &[Row], mut order: Vec<usize>, keys: &[(usize, bool)]) -> Vec<usize> {
order.sort_by(|a, b| {
keys.iter().fold(Ordering::Equal, |so_far, (col, rising)| {
so_far.then_with(|| {
let (a, b) = (&rows[*a].cells[*col], &rows[*b].cells[*col]);
let by = match (a.number(), b.number()) {
(Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
_ => a.words().to_lowercase().cmp(&b.words().to_lowercase()),
};
if *rising { by } else { by.reverse() }
})
})
});
order
}
/// `order` gathered by column `col`'s words, groups in the order they first appear.
pub(crate) fn groups(rows: &[Row], order: &[usize], col: usize) -> Vec<(SharedString, Vec<usize>)> {
let mut groups: Vec<(SharedString, Vec<usize>)> = Vec::new();
for ix in order {
let name = rows[*ix].cells[col].words();
match groups.iter_mut().find(|(seen, _)| *seen == name) {
Some((_, members)) => members.push(*ix),
None => groups.push((name, vec![*ix])),
}
}
groups
}
/// Rows as comma-separated text, the titles first; fields with commas, quotes or breaks are quoted.
pub fn to_csv(columns: &[Column], rows: &[Row]) -> String {
let field = |text: &str| {
if text.contains([',', '"', '\n']) {
format!("\"{}\"", text.replace('"', "\"\""))
} else {
text.to_string()
}
};
let head = columns
.iter()
.map(|column| field(&column.title))
.collect::<Vec<_>>()
.join(",");
let body = rows.iter().map(|row| {
row.cells
.iter()
.map(|cell| field(&cell.words()))
.collect::<Vec<_>>()
.join(",")
});
std::iter::once(head)
.chain(body)
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn table() -> (Vec<Column>, Vec<Row>) {
let columns = vec![
Column::new("name", "Name"),
Column::new("region", "Region"),
Column::new("sales", "Sales"),
];
let rows = [
("Ada", "Europe", 30.0),
("Alan", "Europe", 12.0),
("Grace", "Americas", 30.0),
("Kay", "", 5.0),
]
.into_iter()
.map(|(name, region, sales)| Row::new(name, [name.into(), region.into(), sales.into()]))
.collect();
(columns, rows)
}
fn rule(column: &'static str, test: Test, value: &'static str) -> FilterRule {
FilterRule {
column: column.into(),
test,
value: value.into(),
}
}
#[test]
fn rules_keep_all_or_any_and_skip_the_unfinished() {
let (columns, rows) = table();
let all = || (0..4).collect::<Vec<_>>();
assert_eq!(
kept(
&rows,
&columns,
all(),
&[
rule("sales", Test::Above, "10"),
rule("region", Test::Equals, "europe")
],
false
),
[0, 1]
);
assert_eq!(
kept(
&rows,
&columns,
all(),
&[
rule("sales", Test::Below, "10"),
rule("name", Test::Contains, "gra")
],
true
),
[2, 3]
);
assert_eq!(
kept(
&rows,
&columns,
all(),
&[rule("region", Test::Empty, "")],
false
),
[3]
);
assert_eq!(
kept(
&rows,
&columns,
all(),
&[rule("sales", Test::Above, " ")],
false
),
[0, 1, 2, 3],
"no value yet"
);
}
#[test]
fn keys_sort_in_turn_and_groups_keep_first_seen_order() {
let (_, rows) = table();
assert_eq!(
sorted_by(&rows, vec![0, 1, 2, 3], &[(2, false), (0, false)]),
[2, 0, 1, 3]
);
let named: Vec<(String, Vec<usize>)> = groups(&rows, &[0, 1, 2, 3], 1)
.into_iter()
.map(|(name, rows)| (name.to_string(), rows))
.collect();
assert_eq!(
named,
[
("Europe".into(), vec![0, 1]),
("Americas".into(), vec![2]),
("".into(), vec![3])
]
);
}
#[test]
fn csv_quotes_what_needs_it() {
let columns = vec![Column::new("a", "Name"), Column::new("b", "Note")];
let rows = vec![Row::new("r", ["Ada".into(), "said \"hi\", twice".into()])];
assert_eq!(
to_csv(&columns, &rows),
"Name,Note\nAda,\"said \"\"hi\"\", twice\""
);
}
}
+353 -285
View File
@@ -1,22 +1,27 @@
use std::rc::Rc;
use std::{
collections::{HashMap, HashSet},
rc::Rc,
};
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement, Styled,
UniformListScrollHandle, Window, div, prelude::*, uniform_list,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, StyleRefinement,
Styled, UniformListScrollHandle, Window, div, prelude::*, uniform_list,
};
use super::{
Aggregate, Cell, Column,
body::{Body, Select, sized},
model::{figure, filtered, page, range, sorted},
Aggregate, Cell, Column, FilterRule,
body::{Body, Detail, Line, OnEdit, Select, sized},
header::header,
model::{figure, filtered, page, range},
rules::{groups, kept, sorted_by},
};
use crate::{
forms::{CheckState, check_mark},
forms::{CheckState, Editing, check_mark},
layout::seeded::use_seeded,
lists::{Pick, picked},
navigation::Pagination,
primitives::{Icon, IconName},
theme::{ActiveTheme, Density, IconSize, TextSize},
theme::{ActiveTheme, ControlSize, Density, TextSize},
typography::{Caption, format},
};
@@ -36,90 +41,43 @@ impl Row {
}
}
/// The sort, the page, where a Shift range starts, and the scroll of a long table.
/// A table's own state: the page, a Shift range's start, the scroll, column widths, edges and order, opened rows, folded groups, and the cell being edited.
#[derive(Default)]
struct View {
sort: Option<(usize, bool)>,
page: usize,
anchor: usize,
scroll: UniformListScrollHandle,
pub(crate) struct View {
pub page: usize,
pub anchor: usize,
pub scroll: UniformListScrollHandle,
pub widths: HashMap<SharedString, Pixels>,
pub edges: HashMap<SharedString, Pixels>,
pub narrowest: Pixels,
pub order: Vec<SharedString>,
pub open: HashSet<SharedString>,
pub folded: HashSet<SharedString>,
pub editing: Option<(SharedString, SharedString)>,
}
type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Rows under headers that sort, rising then falling then as given. A query keeps rows holding it; pages split long results, or a long table draws only the rows in view. With a selection, a box leads each row: a press picks one, Cmd adds, Shift takes a range, the header box takes all. Footers show figures; tinted columns show where each number sits.
pub(super) type OnSelect = Rc<dyn Fn(&[SharedString], &mut Window, &mut App)>;
/// Rows under headers. A press on a header sorts, rising then falling then off; Shift adds it to the sort. A header drags to move its column and its edge drags to resize it. A query and filter rules keep rows; pages split long results, or a long table draws only the rows in view. Rows can gather into folding groups, open a detail, or edit a cell in place. With a selection, a box leads each row. Footers show figures; tinted columns show where each number sits.
#[derive(IntoElement)]
pub struct DataTable {
id: ElementId,
base: Div,
columns: Vec<Column>,
rows: Rc<Vec<Row>>,
query: SharedString,
page_size: Option<usize>,
virtualized: bool,
selected: Option<Vec<SharedString>>,
on_select: Option<OnSelect>,
density: Density,
}
impl DataTable {
pub fn new(id: impl Into<ElementId>, columns: impl IntoIterator<Item = Column>) -> Self {
Self {
id: id.into(),
base: div(),
columns: columns.into_iter().collect(),
rows: Rc::default(),
query: SharedString::default(),
page_size: None,
virtualized: false,
selected: None,
on_select: None,
density: Density::Standard,
}
}
/// The rows, owned or shared; a shared list is not copied.
pub fn rows(mut self, rows: impl Into<Rc<Vec<Row>>>) -> Self {
self.rows = rows.into();
self
}
/// Keeps the rows holding this text in any cell.
pub fn query(mut self, query: impl Into<SharedString>) -> Self {
self.query = query.into();
self
}
/// Splits the rows into pages of `size`.
pub fn paged(mut self, size: usize) -> Self {
assert!(size > 0, "a page holds at least a row");
self.page_size = Some(size);
self
}
/// Draws only the rows in view, for long tables. Give it a height.
pub fn virtualized(mut self) -> Self {
self.virtualized = true;
self
}
/// Shows a box on each row; `keys` are the rows selected.
pub fn selected(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.selected = Some(keys.into_iter().map(Into::into).collect());
self
}
pub fn on_select(
mut self,
handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static,
) -> Self {
self.on_select = Some(Rc::new(handler));
self
}
pub fn density(mut self, density: Density) -> Self {
self.density = density;
self
}
pub(super) id: ElementId,
pub(super) base: Div,
pub(super) columns: Vec<Column>,
pub(super) rows: Rc<Vec<Row>>,
pub(super) query: SharedString,
pub(super) filters: Vec<FilterRule>,
pub(super) any: bool,
pub(super) sorts: Vec<(SharedString, bool)>,
pub(super) hidden: Vec<SharedString>,
pub(super) page_size: Option<usize>,
pub(super) virtualized: bool,
pub(super) selected: Option<Vec<SharedString>>,
pub(super) on_select: Option<OnSelect>,
pub(super) detail: Option<Detail>,
pub(super) group_by: Option<SharedString>,
pub(super) on_edit: Option<OnEdit>,
pub(super) density: Density,
}
impl Styled for DataTable {
@@ -128,15 +86,6 @@ impl Styled for DataTable {
}
}
/// The next sort after a press on column `col`: rising, then falling, then none.
fn next_sort(sort: Option<(usize, bool)>, col: usize) -> Option<(usize, bool)> {
match sort {
Some((was, true)) if was == col => Some((col, false)),
Some((was, false)) if was == col => None,
_ => Some((col, true)),
}
}
impl RenderOnce for DataTable {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id.clone();
@@ -149,35 +98,124 @@ impl RenderOnce for DataTable {
row.key
);
}
assert!(
!(self.detail.is_some() && self.virtualized),
"a virtualized table draws rows of one height, so rows cannot open"
);
assert!(
!(self.group_by.is_some() && self.page_size.is_some()),
"a grouped table is not paged"
);
let pinned_any = self.columns.iter().any(|column| column.pinned);
assert!(
!(pinned_any && (self.virtualized || self.detail.is_some())),
"pinned columns need rows of one height in one list"
);
assert!(
self.columns
.iter()
.all(|column| !column.pinned || column.width.is_some()),
"a pinned column needs a width"
);
let view: Entity<View> =
window.use_keyed_state((id.clone(), "view"), cx, |_, _| View::default());
let (sort, page_at, scroll) = {
let view = view.read(cx);
(view.sort, view.page, view.scroll.clone())
};
let sorting = use_seeded((id.clone(), "sort"), self.sorts.clone(), window, cx);
let editor = window.use_keyed_state((id.clone(), "editor"), cx, |_, _| Editing::default());
let narrowest = cx.theme().label_width() * 0.5;
let rem = window.rem_size();
let keys_now: Vec<SharedString> = self
.columns
.iter()
.map(|column| column.key.clone())
.collect();
view.update(cx, |view, _| {
view.narrowest = narrowest.to_pixels(rem);
view.order.retain(|key| keys_now.contains(key));
for key in &keys_now {
if !view.order.contains(key) {
view.order.push(key.clone());
}
}
});
if view.read(cx).editing.is_some() && editor.read(cx).field().is_none() {
view.update(cx, |view, _| view.editing = None);
}
let rows = self.rows;
let keys: Rc<Vec<SharedString>> = Rc::new(rows.iter().map(|row| row.key.clone()).collect());
let mut order = filtered(&rows, &self.query);
if let Some((col, rising)) = sort {
order = sorted(&rows, order, col, rising);
let columns = Rc::new(self.columns);
let index = |key: &SharedString| columns.iter().position(|column| column.key == *key);
let shown_columns: Vec<usize> = view
.read(cx)
.order
.iter()
.filter(|key| !self.hidden.contains(key))
.filter_map(index)
.collect();
let sort: Vec<(usize, bool)> = sorting
.read(cx)
.value
.iter()
.filter_map(|(key, rising)| index(key).map(|col| (col, *rising)))
.collect();
let mut order = kept(
&rows,
&columns,
filtered(&rows, &self.query),
&self.filters,
self.any,
);
if !sort.is_empty() {
order = sorted_by(&rows, order, &sort);
}
let total = order.len();
let (page_at, open, folded) = {
let view = view.read(cx);
(view.page, view.open.clone(), view.folded.clone())
};
let pages = self.page_size.map(|size| total.div_ceil(size).max(1));
let page_at = pages.map_or(0, |pages| page_at.min(pages - 1));
let (shown, offset) = match self.page_size {
Some(size) => (page(&order, page_at, size).to_vec(), page_at * size),
None => (order.clone(), 0),
let opens = self.detail.is_some();
let mut lines = Vec::new();
let mut ordered: Vec<SharedString> = Vec::new();
let push_row = |ix: usize, position: usize, lines: &mut Vec<Line>| {
lines.push(Line::Row { ix, position });
if opens && open.contains(&rows[ix].key) {
lines.push(Line::Detail { ix });
}
};
let columns = Rc::new(self.columns);
let ranges: Rc<Vec<_>> = Rc::new(
columns
.iter()
.enumerate()
.map(|(col, column)| column.scale.then(|| range(&rows, col)).flatten())
.collect(),
);
let ordered: Rc<Vec<SharedString>> =
Rc::new(order.iter().map(|ix| keys[*ix].clone()).collect());
match self
.group_by
.as_ref()
.map(|key| index(key).unwrap_or_else(|| panic!("no column {key} to group by")))
{
Some(col) => {
for (name, members) in groups(&rows, &order, col) {
let shut = folded.contains(&name);
lines.push(Line::Group {
name: name.clone(),
count: members.len(),
folded: shut,
});
if !shut {
for ix in members {
push_row(ix, ordered.len(), &mut lines);
ordered.push(rows[ix].key.clone());
}
}
}
}
None => {
ordered.extend(order.iter().map(|ix| rows[*ix].key.clone()));
let offset = self.page_size.map_or(0, |size| page_at * size);
let visible = match self.page_size {
Some(size) => page(&order, page_at, size).to_vec(),
None => order.clone(),
};
for (at, ix) in visible.into_iter().enumerate() {
push_row(ix, offset + at, &mut lines);
}
}
}
let ordered = Rc::new(ordered);
let selected = self.selected.map(Rc::new);
let select: Option<Select> = 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<Vec<Option<Pixels>>> = 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<SharedString> = 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<SharedString> = 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<AnyElement> = (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<usize>, Vec<usize>) =
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)
}
}
+116 -1
View File
@@ -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<std::cell::Cell<gpui::Pixels>>);
impl Render for Grouped {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let tall = self.0.clone();
let rows: Vec<Row> = (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<RefCell<Vec<String>>>);
impl Render for Editable {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> 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::<Vec<_>>(),
["edit b name Alan Kay"]
);
}
+170
View File
@@ -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<dyn Fn(&[SharedString], &mut Window, &mut App)>;
type OnDensity = Rc<dyn Fn(Density, &mut Window, &mut App)>;
/// 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<Entity<TextInput>>,
filter: Option<(usize, Run)>,
columns: Vec<(SharedString, SharedString)>,
hidden: Vec<SharedString>,
on_hidden: Option<OnHidden>,
density: Option<(Density, OnDensity)>,
on_export: Option<Run>,
}
impl TableToolbar {
pub fn new(id: impl Into<ElementId>) -> 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<TextInput>) -> 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<Item = (impl Into<SharedString>, impl Into<SharedString>)>,
hidden: impl IntoIterator<Item = impl Into<SharedString>>,
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<SharedString> = 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))
}))
}
}
+10 -10
View File
@@ -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