diff --git a/README.md b/README.md
index 6da7c095..97e0d295 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/assets/icons/arrow-down.svg b/assets/icons/arrow-down.svg
new file mode 100644
index 00000000..bb5b04ca
--- /dev/null
+++ b/assets/icons/arrow-down.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/arrow-up.svg b/assets/icons/arrow-up.svg
new file mode 100644
index 00000000..ad125196
--- /dev/null
+++ b/assets/icons/arrow-up.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/chevron-up.svg b/assets/icons/chevron-up.svg
new file mode 100644
index 00000000..f1bb1ad6
--- /dev/null
+++ b/assets/icons/chevron-up.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/sort-ascending.svg b/assets/icons/sort-ascending.svg
new file mode 100644
index 00000000..9be1b977
--- /dev/null
+++ b/assets/icons/sort-ascending.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/icons/sort-descending.svg b/assets/icons/sort-descending.svg
new file mode 100644
index 00000000..ab650568
--- /dev/null
+++ b/assets/icons/sort-descending.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/story/src/table_story.rs b/crates/story/src/table_story.rs
index 1cebaf39..d9040fc4 100644
--- a/crates/story/src/table_story.rs
+++ b/crates/story/src/table_story.rs
@@ -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 {
})
.collect()
}
+
+struct Column {
+ id: SharedString,
+ name: SharedString,
+ sort: Option,
+}
+
+impl Column {
+ fn new(
+ id: impl Into,
+ name: impl Into,
+ sort: Option,
+ ) -> Self {
+ Self {
+ id: id.into(),
+ name: name.into(),
+ sort,
+ }
+ }
+}
+
struct CustomerTableDelegate {
customers: Vec,
- col_names: Vec<(SharedString, SharedString)>,
+ columns: Vec,
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 {
- 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 {
+ 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) {
+ 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>,
@@ -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())
diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs
index aceac9ab..b2531684 100644
--- a/crates/ui/src/icon.rs
+++ b/crates/ui/src/icon.rs
@@ -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()
}
diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs
index 204c601d..2d4684df 100644
--- a/crates/ui/src/table.rs
+++ b/crates/ui/src/table.rs
@@ -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,
bounds: Bounds,
+ sort: Option,
}
#[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) -> 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;
+ /// 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 {
+ 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) {
+ 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,
+ ) -> Option {
+ 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 {