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 {