T14b Tables: resize, reorder, visibility, pinning, expansion, grouping, inline edit, toolbar, filter and sort builders (review pending: codex 401)
This commit is contained in:
17 files changed
+2292
-420
No files matched your search
+273
-62
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"]
|
||||
);
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user