444 lines
17 KiB
Rust
444 lines
17 KiB
Rust
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 in the rules' order, 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()
|
|
}
|
|
|
|
/// The columns a sort row may take: its own, and those no other row holds.
|
|
fn open(
|
|
columns: &[(SharedString, SharedString)],
|
|
keys: &[SortKey],
|
|
own: &SharedString,
|
|
) -> Vec<(SharedString, SharedString)> {
|
|
columns
|
|
.iter()
|
|
.filter(|(key, _)| key == own || !keys.iter().any(|sort| sort.column == *key))
|
|
.cloned()
|
|
.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 field = cx.new(|cx| TextInput::new(window, cx));
|
|
let (latest, owner) = (latest.clone(), fields.downgrade());
|
|
let heard = window.subscribe(&field, cx, move |field, event, window, cx| {
|
|
if !matches!(event, InputEvent::Changed) {
|
|
return;
|
|
}
|
|
let Some(ix) = owner.upgrade().and_then(|owner| {
|
|
let held = &owner.read(cx).fields;
|
|
held.iter()
|
|
.position(|(held, _)| held.entity_id() == field.entity_id())
|
|
}) else {
|
|
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, held) =
|
|
(emit.clone(), emit.clone(), emit.clone(), fields.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);
|
|
held.update(cx, |held, _| drop(held.fields.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,
|
|
}
|
|
}
|
|
|
|
/// The keys, each on its own column.
|
|
pub fn keys(mut self, keys: impl IntoIterator<Item = SortKey>) -> Self {
|
|
self.keys = keys.into_iter().collect();
|
|
assert!(
|
|
self.keys.iter().enumerate().all(|(ix, key)| self.keys[..ix]
|
|
.iter()
|
|
.all(|before| before.column != key.column)),
|
|
"sort keys take a column each"
|
|
);
|
|
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(&open(&self.columns, &keys, &sort.column)),
|
|
)
|
|
.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)
|
|
})
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use gpui::SharedString;
|
|
|
|
use super::{SortKey, open};
|
|
|
|
#[test]
|
|
fn a_sort_row_offers_its_own_column_and_the_unused_ones() {
|
|
let columns: Vec<(SharedString, SharedString)> = ["a", "b", "c"]
|
|
.map(|key| (key.into(), key.to_uppercase().into()))
|
|
.to_vec();
|
|
let keys = [
|
|
SortKey {
|
|
column: "a".into(),
|
|
rising: true,
|
|
},
|
|
SortKey {
|
|
column: "b".into(),
|
|
rising: false,
|
|
},
|
|
];
|
|
let offered = |own: &'static str| {
|
|
open(&columns, &keys, &own.into())
|
|
.into_iter()
|
|
.map(|(key, _)| key.to_string())
|
|
.collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(offered("a"), ["a", "c"]);
|
|
assert_eq!(offered("b"), ["b", "c"]);
|
|
}
|
|
}
|