From 7bfdfcb2ec5ce7c4ed53ddbc9aa86d240751d40c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sun, 28 Jul 2024 22:20:23 +0800 Subject: [PATCH] table: Add drag to move col. (#76) https://github.com/user-attachments/assets/9964f3da-0376-4b53-b64e-c9652c324aa3 --- README.md | 4 +- crates/story/src/table_story.rs | 178 ++++++++++++++++++++------------ crates/ui/src/table.rs | 147 +++++++++++++++++++++++--- 3 files changed, 245 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index c401b7aa..6da7c095 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,8 @@ A UI components for building desktop application by using [GPUI](https://gpui.rs - [x] Left, Right / Up, Down to selection column or row. - [x] Horizontal scroll - [x] Vertical scroll - - [ ] Column resizing - - [ ] Column ordering + - [x] Column resizing + - [x] Column ordering - [ ] Sort event emit - [ ] Drawer - [ ] Modal diff --git a/crates/story/src/table_story.rs b/crates/story/src/table_story.rs index 2b980c9a..1cebaf39 100644 --- a/crates/story/src/table_story.rs +++ b/crates/story/src/table_story.rs @@ -25,26 +25,6 @@ struct Customer { verified: bool, confirmed: bool, } -impl Customer { - fn col_names() -> Vec { - vec![ - "ID".into(), - "Login".into(), - "First Name".into(), - "Last Name".into(), - "Company".into(), - "City".into(), - "Country".into(), - "Email".into(), - "Phone".into(), - "Gender".into(), - "Age".into(), - "Verified".into(), - "Confirmed".into(), - "Twitter".into(), - ] - } -} fn randome_customers(size: usize) -> Vec { (0..size) @@ -67,80 +47,106 @@ fn randome_customers(size: usize) -> Vec { } struct CustomerTableDelegate { customers: Vec, + col_names: Vec<(SharedString, SharedString)>, loop_selection: bool, + col_resize: bool, + col_order: 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()), + ], loop_selection: true, + col_resize: true, + col_order: true, } } } impl TableDelegate for CustomerTableDelegate { fn cols_count(&self) -> usize { - Customer::col_names().len() + self.col_names.len() } fn rows_count(&self) -> usize { self.customers.len() } - fn column_name(&self, col_ix: usize) -> SharedString { - if let Some(name) = Customer::col_names().get(col_ix) { - name.clone() + fn col_name(&self, col_ix: usize) -> SharedString { + if let Some(col) = self.col_names.get(col_ix) { + col.1.clone() } else { "--".into() } } fn col_width(&self, col_ix: usize) -> Option { - match col_ix { - 0 => Some(50.0), - 1 => Some(220.0), - 2 => Some(150.0), - 3 => Some(150.0), - 4 => Some(300.0), - 5 => Some(200.0), - 6 => Some(200.0), - 7 => Some(350.0), - 8 => Some(240.0), - 9 => Some(80.0), - 10 => Some(90.0), - 11 => Some(90.0), - 12 => Some(90.0), - 13 => Some(90.0), - _ => None, + if let Some(col) = self.col_names.get(col_ix) { + Some( + match col.0.as_ref() { + "id" => 50.0, + "login" => 220.0, + "first_name" => 150.0, + "last_name" => 150.0, + "company" => 300.0, + "city" => 200.0, + "country" => 200.0, + "email" => 350.0, + "phone" => 240.0, + "gender" => 80.0, + "age" => 90.0, + "verified" => 90.0, + "confirmed" => 90.0, + "twitter" => 90.0, + _ => 200.0, + } + .into(), + ) + } else { + None } - .map(Pixels::from) } fn can_resize_col(&self, col_ix: usize) -> bool { - return col_ix > 1; - } - - fn on_col_widths_changed(&mut self, col_widths: Vec>) { - println!("Col widths changed: {:?}", col_widths); + return self.col_resize && col_ix > 1; } fn render_td(&self, row_ix: usize, col_ix: usize) -> impl gpui::IntoElement { let customer = self.customers.get(row_ix).unwrap(); - let text = match col_ix { - 0 => customer.id.to_string(), - 1 => customer.login.clone(), - 2 => customer.first_name.clone(), - 3 => customer.last_name.clone(), - 4 => customer.company.clone(), - 5 => customer.city.clone(), - 6 => customer.country.clone(), - 7 => customer.email.clone(), - 8 => customer.phone.clone(), - 9 => customer.gender.to_string(), - 10 => customer.age.to_string(), - 11 => customer.verified.to_string(), - 12 => customer.confirmed.to_string(), + + let col = self.col_names.get(col_ix).unwrap(); + let text = match col.0.as_ref() { + "id" => customer.id.to_string(), + "login" => customer.login.clone(), + "first_name" => customer.first_name.clone(), + "last_name" => customer.last_name.clone(), + "company" => customer.company.clone(), + "city" => customer.city.clone(), + "country" => customer.country.clone(), + "email" => customer.email.clone(), + "phone" => customer.phone.clone(), + "gender" => customer.gender.to_string(), + "age" => customer.age.to_string(), + "verified" => customer.verified.to_string(), + "confirmed" => customer.confirmed.to_string(), + "twitter" => "twitter".to_string(), _ => "--".to_string(), }; @@ -150,6 +156,15 @@ impl TableDelegate for CustomerTableDelegate { fn can_loop_select(&self) -> bool { self.loop_selection } + + fn can_move_col(&self, _: usize) -> bool { + self.col_order + } + + 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); + } } pub struct TableStory { @@ -178,6 +193,22 @@ impl TableStory { }); } + fn toggle_col_resize(&mut self, s: &Selection, cx: &mut ViewContext) { + let table = self.table.clone(); + table.update(cx, |table, cx| { + table.delegate_mut().col_resize = s.is_selected(); + cx.notify(); + }); + } + + fn toggle_col_order(&mut self, s: &Selection, cx: &mut ViewContext) { + let table = self.table.clone(); + table.update(cx, |table, cx| { + table.delegate_mut().col_order = s.is_selected(); + cx.notify(); + }); + } + fn on_table_event( &mut self, _: View>, @@ -202,12 +233,27 @@ impl Render for TableStory { .size_full() .gap_2() .child( - h_flex().items_center().child( - Checkbox::new("loop-selection") - .label("Loop Selection") - .selected(delegate.loop_selection) - .on_click(cx.listener(Self::toggle_loop_selection)), - ), + h_flex() + .items_center() + .gap_2() + .child( + Checkbox::new("loop-selection") + .label("Loop Selection") + .selected(delegate.loop_selection) + .on_click(cx.listener(Self::toggle_loop_selection)), + ) + .child( + Checkbox::new("col-resize") + .label("Column Resize") + .selected(delegate.col_resize) + .on_click(cx.listener(Self::toggle_col_resize)), + ) + .child( + Checkbox::new("col-order") + .label("Column Order") + .selected(delegate.col_order) + .on_click(cx.listener(Self::toggle_col_order)), + ), ) .child(self.table.clone()) } diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs index 446a518f..204c601d 100644 --- a/crates/ui/src/table.rs +++ b/crates/ui/src/table.rs @@ -9,7 +9,7 @@ use crate::{ use gpui::{ actions, canvas, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Bounds, Div, DragMoveEvent, EntityId, EventEmitter, FocusHandle, FocusableView, InteractiveElement as _, - IntoElement, KeyBinding, MouseButton, ParentElement as _, Pixels, Render, ScrollHandle, + IntoElement, KeyBinding, MouseButton, ParentElement as _, Pixels, Point, Render, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled, UniformListScrollHandle, ViewContext, VisualContext as _, WindowContext, }; @@ -36,13 +36,38 @@ pub fn init(cx: &mut AppContext) { ]); } +#[derive(Debug, Clone, Copy)] struct ColGroup { width: Option, bounds: Bounds, } +#[derive(Clone)] +pub(crate) struct DragCol { + pub(crate) entity_id: EntityId, + pub(crate) name: SharedString, + pub(crate) width: Option, + pub(crate) col_ix: usize, +} + +impl Render for DragCol { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .px_4() + .py_1() + .bg(cx.theme().table_head) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .when_some(self.width.clone(), |this, width| this.w(width)) + .min_w(px(100.)) + .max_w(px(450.)) + .child(self.name.clone()) + } +} + #[derive(Clone, Render)] -pub struct DragCol(pub (EntityId, usize)); +pub struct ResizeCol(pub (EntityId, usize)); #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SelectionState { @@ -60,6 +85,8 @@ pub enum TableEvent { pub struct Table { focus_handle: FocusHandle, delegate: D, + /// The bounds of the table. + bounds: Bounds, horizontal_scroll_handle: ScrollHandle, vertical_scroll_handle: UniformListScrollHandle, col_groups: Vec, @@ -82,7 +109,7 @@ pub trait TableDelegate: Sized + 'static { fn rows_count(&self) -> usize; /// Returns the name of the column at the given index. - fn column_name(&self, col_ix: usize) -> SharedString; + fn col_name(&self, col_ix: usize) -> SharedString; /// Returns whether the column at the given index can be resized. Default: true fn can_resize_col(&self, col_ix: usize) -> bool { @@ -95,12 +122,9 @@ pub trait TableDelegate: Sized + 'static { /// This is only called when the table initializes. fn col_width(&self, col_ix: usize) -> Option; - /// When the column has resized, this method is called. - fn on_col_widths_changed(&mut self, col_widths: Vec>) {} - /// 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.column_name(col_ix)) + div().size_full().child(self.col_name(col_ix)) } /// Render cell at the given row and column. @@ -114,6 +138,14 @@ pub trait TableDelegate: Sized + 'static { fn can_loop_select(&self) -> bool { true } + + /// Return true to enable column order change. + fn can_move_col(&self, col_ix: usize) -> bool { + false + } + + /// Move the column at the given `col_ix` to insert before the column at the given `to_ix`. + fn move_col(&mut self, col_ix: usize, to_ix: usize) {} } impl Table @@ -132,6 +164,7 @@ where selected_row: None, selected_col: None, resizing_col: None, + bounds: Bounds::default(), }; this.prepare_col_groups(cx); @@ -316,9 +349,9 @@ where ) .hover(|this| this.bg(cx.theme().drag_border)) .when(is_resizing, |this| this.bg(cx.theme().drag_border)) - .on_drag_move(cx.listener( - move |view, e: &DragMoveEvent, cx| match e.drag(cx) { - DragCol((entity_id, ix)) => { + .on_drag_move(cx.listener(move |view, e: &DragMoveEvent, cx| { + match e.drag(cx) { + ResizeCol((entity_id, ix)) => { if cx.entity_id() != *entity_id { return; } @@ -331,16 +364,24 @@ where let ix = *ix; view.resizing_col = Some(ix); - let col_group = view.col_groups.get(ix).expect("BUG: invalid col index"); + let col_group = view + .col_groups + .get(ix) + .expect("BUG: invalid col index") + .clone(); + view.resize_cols( ix, e.event.position.x - HANDLE_SIZE - col_group.bounds.left(), cx, ); + + // scroll the table if the drag is near the edge + view.scroll_table_by_col_resizing(e.event.position, col_group, cx); } - }, - )) - .on_drag(DragCol((cx.entity_id(), ix)), |drag, cx| { + }; + })) + .on_drag(ResizeCol((cx.entity_id(), ix)), |drag, cx| { cx.stop_propagation(); cx.new_view(|_| drag.clone()) }) @@ -361,10 +402,32 @@ where .into_any_element() } + /// Scroll table when mouse position is near the edge of the table bounds. + fn scroll_table_by_col_resizing( + &mut self, + pos: Point, + col_group: ColGroup, + _: &mut ViewContext, + ) { + let mut offset = self.horizontal_scroll_handle.offset(); + let col_bounds = col_group.bounds; + + if pos.x < self.bounds.left() && col_bounds.right() < self.bounds.left() + px(20.) { + offset.x += px(1.); + } else if pos.x > self.bounds.right() && col_bounds.right() > self.bounds.right() - px(20.) + { + offset.x -= px(1.); + } + + self.horizontal_scroll_handle.set_offset(offset); + } + /// The `ix`` is the index of the col to resize, /// and the `size` is the new size for the col. fn resize_cols(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext) { const MIN_WIDTH: Pixels = px(10.0); + const MAX_WIDTH: Pixels = px(1200.0); + if !self.delegate.can_resize_col(ix) { return; } @@ -380,7 +443,7 @@ where if changed_width > px(-1.0) && changed_width < px(1.0) { return; } - self.col_groups[ix].width = Some(new_width); + self.col_groups[ix].width = Some(new_width.min(MAX_WIDTH)); // Resize next col, table not need to resize the right cols. // let next_width = self.col_groups[ix + 1].width.unwrap_or_default(); @@ -395,16 +458,51 @@ where /// Becuase the horizontal scroll handle will use the child_item_bounds to /// calculate the item position for itself's `scroll_to_item` method. fn render_th(&self, col_ix: usize, cx: &mut ViewContext) -> impl IntoElement { + let entity_id = cx.entity_id(); + let col_group = self.col_groups.get(col_ix).expect("BUG: invalid col index"); + + let name = self.delegate.col_name(col_ix); self.col_wrap(col_ix, cx) .child( self.render_cell(col_ix, cx) + .id(("col-header", col_ix)) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, cx| { this.on_col_head_click(col_ix, cx); }), ) - .child(self.delegate.render_th(col_ix)), + .child(self.delegate.render_th(col_ix)) + .when(self.delegate.can_move_col(col_ix), |this| { + this.on_drag( + DragCol { + entity_id, + col_ix, + name, + width: col_group.width, + }, + |drag, cx| { + cx.stop_propagation(); + cx.new_view(|_| drag.clone()) + }, + ) + .drag_over::(|this, _, cx| { + this.rounded_l_none() + .border_l_2() + .border_r_0() + .border_color(cx.theme().drag_border) + }) + .on_drop(cx.listener( + move |table, drag: &DragCol, cx| { + // If the drag col is not the same as the drop col, then swap the cols. + if drag.entity_id != cx.entity_id() { + return; + } + + table.move_col(drag.col_ix, col_ix, cx); + }, + )) + }), ) // resize handle .child(self.render_resize_handle(col_ix, cx)) @@ -419,6 +517,18 @@ where .size_full() }) } + + fn move_col(&mut self, col_ix: usize, to_ix: usize, cx: &mut ViewContext) { + if col_ix == to_ix { + return; + } + + self.delegate.move_col(col_ix, to_ix); + let col_group = self.col_groups.remove(col_ix); + self.col_groups.insert(to_ix, col_group); + + cx.notify(); + } } impl FocusableView for Table @@ -558,6 +668,7 @@ where ), ); + let view = cx.view().clone(); div() .size_full() .rounded_md() @@ -571,5 +682,9 @@ where ScrollableAxis::Horizontal, &horizontal_scroll_handle, )) + .child(canvas( + move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds), + |_, _, _| {}, + )) } }