table: Add sort button to table column. (#77)

https://github.com/user-attachments/assets/d6a3dc9b-758f-4108-8d1d-d8a3d2dc0dc2
This commit is contained in:
Jason Lee 2024-07-28 23:24:55 +08:00 committed by GitHub
parent 7bfdfcb2ec
commit 15e9804f08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 301 additions and 67 deletions

View file

@ -61,7 +61,7 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs
- [x] Vertical scroll
- [x] Column resizing
- [x] Column ordering
- [ ] Sort event emit
- [x] Column sorting
- [ ] Drawer
- [ ] Modal

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>

After

Width:  |  Height:  |  Size: 267 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>

After

Width:  |  Height:  |  Size: 264 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>

After

Width:  |  Height:  |  Size: 247 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down">
<path opacity="0.5" d="m7 15 5 5 5-5"/>
<path d="m7 9 5-5 5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down">
<path d="m7 15 5 5 5-5"/>
<path opacity="0.5" d="m7 9 5-5 5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 297 B

View file

@ -6,7 +6,7 @@ use gpui::{
use ui::{
checkbox::Checkbox,
h_flex,
table::{Table, TableDelegate, TableEvent},
table::{ColSort, Table, TableDelegate, TableEvent},
v_flex, Selectable, Selection,
};
@ -45,44 +45,67 @@ fn randome_customers(size: usize) -> Vec<Customer> {
})
.collect()
}
struct Column {
id: SharedString,
name: SharedString,
sort: Option<ColSort>,
}
impl Column {
fn new(
id: impl Into<SharedString>,
name: impl Into<SharedString>,
sort: Option<ColSort>,
) -> Self {
Self {
id: id.into(),
name: name.into(),
sort,
}
}
}
struct CustomerTableDelegate {
customers: Vec<Customer>,
col_names: Vec<(SharedString, SharedString)>,
columns: Vec<Column>,
loop_selection: bool,
col_resize: bool,
col_order: bool,
col_sort: bool,
}
impl CustomerTableDelegate {
fn new(size: usize) -> Self {
Self {
customers: randome_customers(size),
col_names: vec![
("id".into(), "ID".into()),
("login".into(), "Login".into()),
("first_name".into(), "First Name".into()),
("last_name".into(), "Last Name".into()),
("company".into(), "Company".into()),
("city".into(), "City".into()),
("country".into(), "Country".into()),
("email".into(), "Email".into()),
("phone".into(), "Phone".into()),
("gender".into(), "Gender".into()),
("age".into(), "Age".into()),
("verified".into(), "Verified".into()),
("confirmed".into(), "Confirmed".into()),
("twitter".into(), "Twitter".into()),
columns: vec![
Column::new("id", "ID", Some(ColSort::Ascending)),
Column::new("login", "Login", Some(ColSort::Default)),
Column::new("first_name", "First Name", Some(ColSort::Default)),
Column::new("last_name", "Last Name", Some(ColSort::Default)),
Column::new("company", "Company", Some(ColSort::Default)),
Column::new("city", "City", Some(ColSort::Default)),
Column::new("country", "Country", Some(ColSort::Default)),
Column::new("email", "Email", Some(ColSort::Default)),
Column::new("phone", "Phone", None),
Column::new("gender", "Gender", None),
Column::new("age", "Age", Some(ColSort::Default)),
Column::new("verified", "Verified", None),
Column::new("confirmed", "Confirmed", None),
Column::new("twitter", "Twitter", None),
],
loop_selection: true,
col_resize: true,
col_order: true,
col_sort: true,
}
}
}
impl TableDelegate for CustomerTableDelegate {
fn cols_count(&self) -> usize {
self.col_names.len()
self.columns.len()
}
fn rows_count(&self) -> usize {
@ -90,17 +113,17 @@ impl TableDelegate for CustomerTableDelegate {
}
fn col_name(&self, col_ix: usize) -> SharedString {
if let Some(col) = self.col_names.get(col_ix) {
col.1.clone()
if let Some(col) = self.columns.get(col_ix) {
col.name.clone()
} else {
"--".into()
}
}
fn col_width(&self, col_ix: usize) -> Option<Pixels> {
if let Some(col) = self.col_names.get(col_ix) {
if let Some(col) = self.columns.get(col_ix) {
Some(
match col.0.as_ref() {
match col.id.as_ref() {
"id" => 50.0,
"login" => 220.0,
"first_name" => 150.0,
@ -131,8 +154,8 @@ impl TableDelegate for CustomerTableDelegate {
fn render_td(&self, row_ix: usize, col_ix: usize) -> impl gpui::IntoElement {
let customer = self.customers.get(row_ix).unwrap();
let col = self.col_names.get(col_ix).unwrap();
let text = match col.0.as_ref() {
let col = self.columns.get(col_ix).unwrap();
let text = match col.id.as_ref() {
"id" => customer.id.to_string(),
"login" => customer.login.clone(),
"first_name" => customer.first_name.clone(),
@ -162,8 +185,92 @@ impl TableDelegate for CustomerTableDelegate {
}
fn move_col(&mut self, col_ix: usize, to_ix: usize) {
let col = self.col_names.remove(col_ix);
self.col_names.insert(to_ix, col);
let col = self.columns.remove(col_ix);
self.columns.insert(to_ix, col);
}
fn col_sort(&self, col_ix: usize) -> Option<ColSort> {
self.columns.get(col_ix).map(|c| c.sort).flatten()
}
fn perform_sort(&mut self, col_ix: usize, sort: ColSort, _: &mut WindowContext) {
if let Some(col) = self.columns.get_mut(col_ix) {
col.sort = Some(sort);
let asc = matches!(sort, ColSort::Ascending);
match col.id.as_ref() {
"id" => self.customers.sort_by(|a, b| {
if asc {
a.id.cmp(&b.id)
} else {
b.id.cmp(&a.id)
}
}),
"login" => self.customers.sort_by(|a, b| {
if asc {
a.login.cmp(&b.login)
} else {
b.login.cmp(&a.login)
}
}),
"first_name" => self.customers.sort_by(|a, b| {
if asc {
a.first_name.cmp(&b.first_name)
} else {
b.first_name.cmp(&a.first_name)
}
}),
"last_name" => self.customers.sort_by(|a, b| {
if asc {
a.last_name.cmp(&b.last_name)
} else {
b.last_name.cmp(&a.last_name)
}
}),
"company" => self.customers.sort_by(|a, b| {
if asc {
a.company.cmp(&b.company)
} else {
b.company.cmp(&a.company)
}
}),
"city" => self.customers.sort_by(|a, b| {
if asc {
a.city.cmp(&b.city)
} else {
b.city.cmp(&a.city)
}
}),
"country" => self.customers.sort_by(|a, b| {
if asc {
a.country.cmp(&b.country)
} else {
b.country.cmp(&a.country)
}
}),
"email" => self.customers.sort_by(|a, b| {
if asc {
a.email.cmp(&b.email)
} else {
b.email.cmp(&a.email)
}
}),
"age" => self.customers.sort_by(|a, b| {
if asc {
a.age.cmp(&b.age)
} else {
b.age.cmp(&a.age)
}
}),
_ => {}
}
for col in self.columns.iter_mut() {
if let Some(ColSort::Ascending) = col.sort {
col.sort = Some(ColSort::Default);
}
}
}
}
}
@ -209,6 +316,14 @@ impl TableStory {
});
}
fn toggle_col_sort(&mut self, s: &Selection, cx: &mut ViewContext<Self>) {
let table = self.table.clone();
table.update(cx, |table, cx| {
table.delegate_mut().col_sort = s.is_selected();
cx.notify();
});
}
fn on_table_event(
&mut self,
_: View<Table<CustomerTableDelegate>>,
@ -253,6 +368,12 @@ impl Render for TableStory {
.label("Column Order")
.selected(delegate.col_order)
.on_click(cx.listener(Self::toggle_col_order)),
)
.child(
Checkbox::new("col-sort")
.label("Column Sort")
.selected(delegate.col_sort)
.on_click(cx.listener(Self::toggle_col_sort)),
),
)
.child(self.table.clone())

View file

@ -6,71 +6,81 @@ use gpui::{
#[derive(IntoElement, Clone)]
pub enum IconName {
Check,
Minus,
Dash,
Maximize,
Minimize,
Close,
ChevronDown,
ChevronsUpDown,
Plus,
Info,
Ellipsis,
EllipsisVertical,
Search,
Delete,
CircleX,
Loader,
LoaderCircle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronsUpDown,
CircleX,
Close,
Dash,
Delete,
Ellipsis,
EllipsisVertical,
Eye,
EyeOff,
Inbox,
Heart,
HeartOff,
Inbox,
Info,
Loader,
LoaderCircle,
Maximize,
Minimize,
Minus,
Plus,
Search,
Star,
StarOff,
ThumbsUp,
SortAscending,
SortDescending,
ThumbsDown,
ThumbsUp,
}
impl IconName {
pub fn path(self) -> SharedString {
match self {
IconName::Check => "icons/check.svg",
IconName::Minus => "icons/minus.svg",
IconName::Dash => "icons/dash.svg",
IconName::Maximize => "icons/maximize.svg",
IconName::Minimize => "icons/minimize.svg",
IconName::Close => "icons/close.svg",
IconName::ChevronDown => "icons/chevron-down.svg",
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
IconName::Plus => "icons/plus.svg",
IconName::Info => "icons/info.svg",
IconName::Ellipsis => "icons/ellipsis.svg",
IconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
IconName::Search => "icons/search.svg",
IconName::Delete => "icons/delete.svg",
IconName::CircleX => "icons/circle-x.svg",
IconName::Loader => "icons/loader.svg",
IconName::LoaderCircle => "icons/loader-circle.svg",
IconName::ArrowDown => "icons/arrow-down.svg",
IconName::ArrowLeft => "icons/arrow-left.svg",
IconName::ArrowRight => "icons/arrow-right.svg",
IconName::ArrowUp => "icons/arrow-up.svg",
IconName::Check => "icons/check.svg",
IconName::ChevronDown => "icons/chevron-down.svg",
IconName::ChevronLeft => "icons/chevron-left.svg",
IconName::ChevronRight => "icons/chevron-right.svg",
IconName::ChevronUp => "icons/chevron-up.svg",
IconName::ChevronsUpDown => "icons/chevrons-up-down.svg",
IconName::CircleX => "icons/circle-x.svg",
IconName::Close => "icons/close.svg",
IconName::Dash => "icons/dash.svg",
IconName::Delete => "icons/delete.svg",
IconName::Ellipsis => "icons/ellipsis.svg",
IconName::EllipsisVertical => "icons/ellipsis-vertical.svg",
IconName::Eye => "icons/eye.svg",
IconName::EyeOff => "icons/eye-off.svg",
IconName::Inbox => "icons/inbox.svg",
IconName::Heart => "icons/heart.svg",
IconName::HeartOff => "icons/heart-off.svg",
IconName::Inbox => "icons/inbox.svg",
IconName::Info => "icons/info.svg",
IconName::Loader => "icons/loader.svg",
IconName::LoaderCircle => "icons/loader-circle.svg",
IconName::Maximize => "icons/maximize.svg",
IconName::Minimize => "icons/minimize.svg",
IconName::Minus => "icons/minus.svg",
IconName::Plus => "icons/plus.svg",
IconName::Search => "icons/search.svg",
IconName::Star => "icons/star.svg",
IconName::StarOff => "icons/star-off.svg",
IconName::ThumbsUp => "icons/thumbs-up.svg",
IconName::SortAscending => "icons/sort-ascending.svg",
IconName::SortDescending => "icons/sort-descending.svg",
IconName::ThumbsDown => "icons/thumbs-down.svg",
IconName::ThumbsUp => "icons/thumbs-up.svg",
}
.into()
}

View file

@ -4,7 +4,7 @@ use crate::{
h_flex,
scroll::{ScrollableAxis, ScrollableMask, Scrollbar, ScrollbarState},
theme::ActiveTheme,
v_flex,
v_flex, Icon, IconName,
};
use gpui::{
actions, canvas, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Bounds, Div,
@ -40,6 +40,7 @@ pub fn init(cx: &mut AppContext) {
struct ColGroup {
width: Option<Pixels>,
bounds: Bounds<Pixels>,
sort: Option<ColSort>,
}
#[derive(Clone)]
@ -50,6 +51,16 @@ pub(crate) struct DragCol {
pub(crate) col_ix: usize,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum ColSort {
/// No sorting.
Default,
/// Sort in ascending order.
Ascending,
/// Sort in descending order.
Descending,
}
impl Render for DragCol {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div()
@ -122,6 +133,16 @@ pub trait TableDelegate: Sized + 'static {
/// This is only called when the table initializes.
fn col_width(&self, col_ix: usize) -> Option<Pixels>;
/// Return the sort state of the column at the given index.
///
/// This is only called when the table initializes.
fn col_sort(&self, col_ix: usize) -> Option<ColSort> {
None
}
/// Perform sort on the column at the given index.
fn perform_sort(&mut self, col_ix: usize, sort: ColSort, cx: &mut WindowContext) {}
/// Render the header cell at the given column index, default to the column name.
fn render_th(&self, col_ix: usize) -> impl IntoElement {
div().size_full().child(self.col_name(col_ix))
@ -184,6 +205,7 @@ where
.map(|col_ix| ColGroup {
width: self.delegate.col_width(col_ix),
bounds: Bounds::default(),
sort: self.delegate.col_sort(col_ix),
})
.collect();
cx.notify();
@ -453,6 +475,69 @@ where
cx.notify();
}
fn perform_sort(&mut self, col_ix: usize, cx: &mut ViewContext<Self>) {
let sort = self.col_groups.get(col_ix).and_then(|g| g.sort);
if sort.is_none() {
return;
}
let sort = sort.unwrap();
let sort = match sort {
ColSort::Ascending => ColSort::Descending,
ColSort::Descending => ColSort::Ascending,
ColSort::Default => ColSort::Ascending,
};
for (ix, col_group) in self.col_groups.iter_mut().enumerate() {
if ix == col_ix {
col_group.sort = Some(sort);
} else {
col_group.sort = Some(ColSort::Default);
}
}
self.delegate_mut().perform_sort(col_ix, sort, cx);
cx.notify();
}
fn render_sort_icon(
&self,
col_ix: usize,
cx: &mut ViewContext<Self>,
) -> Option<impl IntoElement> {
let sort = self.col_groups.get(col_ix).and_then(|g| g.sort);
if sort.is_none() {
return None;
}
let sort = sort.unwrap();
let icon = match sort {
ColSort::Ascending => IconName::SortAscending,
ColSort::Descending => IconName::SortDescending,
ColSort::Default => IconName::ChevronsUpDown,
};
Some(
div()
.id(("icon-sort", col_ix))
.cursor_pointer()
.ml_2()
.p(px(2.))
.rounded_sm()
.hover(|this| this.bg(cx.theme().secondary))
.active(|this| this.bg(cx.theme().secondary_active))
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
.on_click(cx.listener(move |table, _, cx| table.perform_sort(col_ix, cx)))
.child(
Icon::new(icon)
.size_3()
.text_color(cx.theme().secondary_foreground),
),
)
}
/// Render the column header.
/// The children must be one by one items.
/// Becuase the horizontal scroll handle will use the child_item_bounds to
@ -472,7 +557,14 @@ where
this.on_col_head_click(col_ix, cx);
}),
)
.child(self.delegate.render_th(col_ix))
.child(
h_flex()
.size_full()
.justify_between()
.items_center()
.child(self.delegate.render_th(col_ix))
.children(self.render_sort_icon(col_ix, cx)),
)
.when(self.delegate.can_move_col(col_ix), |this| {
this.on_drag(
DragCol {