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

+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))
}))
}
}