table: Add drag to move col. (#76)

https://github.com/user-attachments/assets/9964f3da-0376-4b53-b64e-c9652c324aa3
This commit is contained in:
Jason Lee 2024-07-28 22:20:23 +08:00 committed by GitHub
parent 79958fc95c
commit 7bfdfcb2ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 245 additions and 84 deletions

View file

@ -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] Left, Right / Up, Down to selection column or row.
- [x] Horizontal scroll - [x] Horizontal scroll
- [x] Vertical scroll - [x] Vertical scroll
- [ ] Column resizing - [x] Column resizing
- [ ] Column ordering - [x] Column ordering
- [ ] Sort event emit - [ ] Sort event emit
- [ ] Drawer - [ ] Drawer
- [ ] Modal - [ ] Modal

View file

@ -25,26 +25,6 @@ struct Customer {
verified: bool, verified: bool,
confirmed: bool, confirmed: bool,
} }
impl Customer {
fn col_names() -> Vec<SharedString> {
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<Customer> { fn randome_customers(size: usize) -> Vec<Customer> {
(0..size) (0..size)
@ -67,80 +47,106 @@ fn randome_customers(size: usize) -> Vec<Customer> {
} }
struct CustomerTableDelegate { struct CustomerTableDelegate {
customers: Vec<Customer>, customers: Vec<Customer>,
col_names: Vec<(SharedString, SharedString)>,
loop_selection: bool, loop_selection: bool,
col_resize: bool,
col_order: bool,
} }
impl CustomerTableDelegate { impl CustomerTableDelegate {
fn new(size: usize) -> Self { fn new(size: usize) -> Self {
Self { Self {
customers: randome_customers(size), 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, loop_selection: true,
col_resize: true,
col_order: true,
} }
} }
} }
impl TableDelegate for CustomerTableDelegate { impl TableDelegate for CustomerTableDelegate {
fn cols_count(&self) -> usize { fn cols_count(&self) -> usize {
Customer::col_names().len() self.col_names.len()
} }
fn rows_count(&self) -> usize { fn rows_count(&self) -> usize {
self.customers.len() self.customers.len()
} }
fn column_name(&self, col_ix: usize) -> SharedString { fn col_name(&self, col_ix: usize) -> SharedString {
if let Some(name) = Customer::col_names().get(col_ix) { if let Some(col) = self.col_names.get(col_ix) {
name.clone() col.1.clone()
} else { } else {
"--".into() "--".into()
} }
} }
fn col_width(&self, col_ix: usize) -> Option<Pixels> { fn col_width(&self, col_ix: usize) -> Option<Pixels> {
match col_ix { if let Some(col) = self.col_names.get(col_ix) {
0 => Some(50.0), Some(
1 => Some(220.0), match col.0.as_ref() {
2 => Some(150.0), "id" => 50.0,
3 => Some(150.0), "login" => 220.0,
4 => Some(300.0), "first_name" => 150.0,
5 => Some(200.0), "last_name" => 150.0,
6 => Some(200.0), "company" => 300.0,
7 => Some(350.0), "city" => 200.0,
8 => Some(240.0), "country" => 200.0,
9 => Some(80.0), "email" => 350.0,
10 => Some(90.0), "phone" => 240.0,
11 => Some(90.0), "gender" => 80.0,
12 => Some(90.0), "age" => 90.0,
13 => Some(90.0), "verified" => 90.0,
_ => None, "confirmed" => 90.0,
"twitter" => 90.0,
_ => 200.0,
}
.into(),
)
} else {
None
} }
.map(Pixels::from)
} }
fn can_resize_col(&self, col_ix: usize) -> bool { fn can_resize_col(&self, col_ix: usize) -> bool {
return col_ix > 1; return self.col_resize && col_ix > 1;
}
fn on_col_widths_changed(&mut self, col_widths: Vec<Option<Pixels>>) {
println!("Col widths changed: {:?}", col_widths);
} }
fn render_td(&self, row_ix: usize, col_ix: usize) -> impl gpui::IntoElement { fn render_td(&self, row_ix: usize, col_ix: usize) -> impl gpui::IntoElement {
let customer = self.customers.get(row_ix).unwrap(); let customer = self.customers.get(row_ix).unwrap();
let text = match col_ix {
0 => customer.id.to_string(), let col = self.col_names.get(col_ix).unwrap();
1 => customer.login.clone(), let text = match col.0.as_ref() {
2 => customer.first_name.clone(), "id" => customer.id.to_string(),
3 => customer.last_name.clone(), "login" => customer.login.clone(),
4 => customer.company.clone(), "first_name" => customer.first_name.clone(),
5 => customer.city.clone(), "last_name" => customer.last_name.clone(),
6 => customer.country.clone(), "company" => customer.company.clone(),
7 => customer.email.clone(), "city" => customer.city.clone(),
8 => customer.phone.clone(), "country" => customer.country.clone(),
9 => customer.gender.to_string(), "email" => customer.email.clone(),
10 => customer.age.to_string(), "phone" => customer.phone.clone(),
11 => customer.verified.to_string(), "gender" => customer.gender.to_string(),
12 => customer.confirmed.to_string(), "age" => customer.age.to_string(),
"verified" => customer.verified.to_string(),
"confirmed" => customer.confirmed.to_string(),
"twitter" => "twitter".to_string(),
_ => "--".to_string(), _ => "--".to_string(),
}; };
@ -150,6 +156,15 @@ impl TableDelegate for CustomerTableDelegate {
fn can_loop_select(&self) -> bool { fn can_loop_select(&self) -> bool {
self.loop_selection 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 { pub struct TableStory {
@ -178,6 +193,22 @@ impl TableStory {
}); });
} }
fn toggle_col_resize(&mut self, s: &Selection, cx: &mut ViewContext<Self>) {
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<Self>) {
let table = self.table.clone();
table.update(cx, |table, cx| {
table.delegate_mut().col_order = s.is_selected();
cx.notify();
});
}
fn on_table_event( fn on_table_event(
&mut self, &mut self,
_: View<Table<CustomerTableDelegate>>, _: View<Table<CustomerTableDelegate>>,
@ -202,12 +233,27 @@ impl Render for TableStory {
.size_full() .size_full()
.gap_2() .gap_2()
.child( .child(
h_flex().items_center().child( h_flex()
Checkbox::new("loop-selection") .items_center()
.label("Loop Selection") .gap_2()
.selected(delegate.loop_selection) .child(
.on_click(cx.listener(Self::toggle_loop_selection)), 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()) .child(self.table.clone())
} }

View file

@ -9,7 +9,7 @@ use crate::{
use gpui::{ use gpui::{
actions, canvas, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Bounds, Div, actions, canvas, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Bounds, Div,
DragMoveEvent, EntityId, EventEmitter, FocusHandle, FocusableView, InteractiveElement as _, 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, SharedString, StatefulInteractiveElement as _, Styled, UniformListScrollHandle, ViewContext,
VisualContext as _, WindowContext, VisualContext as _, WindowContext,
}; };
@ -36,13 +36,38 @@ pub fn init(cx: &mut AppContext) {
]); ]);
} }
#[derive(Debug, Clone, Copy)]
struct ColGroup { struct ColGroup {
width: Option<Pixels>, width: Option<Pixels>,
bounds: Bounds<Pixels>, bounds: Bounds<Pixels>,
} }
#[derive(Clone)]
pub(crate) struct DragCol {
pub(crate) entity_id: EntityId,
pub(crate) name: SharedString,
pub(crate) width: Option<Pixels>,
pub(crate) col_ix: usize,
}
impl Render for DragCol {
fn render(&mut self, cx: &mut ViewContext<Self>) -> 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)] #[derive(Clone, Render)]
pub struct DragCol(pub (EntityId, usize)); pub struct ResizeCol(pub (EntityId, usize));
#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum SelectionState { enum SelectionState {
@ -60,6 +85,8 @@ pub enum TableEvent {
pub struct Table<D: TableDelegate> { pub struct Table<D: TableDelegate> {
focus_handle: FocusHandle, focus_handle: FocusHandle,
delegate: D, delegate: D,
/// The bounds of the table.
bounds: Bounds<Pixels>,
horizontal_scroll_handle: ScrollHandle, horizontal_scroll_handle: ScrollHandle,
vertical_scroll_handle: UniformListScrollHandle, vertical_scroll_handle: UniformListScrollHandle,
col_groups: Vec<ColGroup>, col_groups: Vec<ColGroup>,
@ -82,7 +109,7 @@ pub trait TableDelegate: Sized + 'static {
fn rows_count(&self) -> usize; fn rows_count(&self) -> usize;
/// Returns the name of the column at the given index. /// 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 /// Returns whether the column at the given index can be resized. Default: true
fn can_resize_col(&self, col_ix: usize) -> bool { 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. /// This is only called when the table initializes.
fn col_width(&self, col_ix: usize) -> Option<Pixels>; fn col_width(&self, col_ix: usize) -> Option<Pixels>;
/// When the column has resized, this method is called.
fn on_col_widths_changed(&mut self, col_widths: Vec<Option<Pixels>>) {}
/// Render the header cell at the given column index, default to the column name. /// Render the header cell at the given column index, default to the column name.
fn render_th(&self, col_ix: usize) -> impl IntoElement { 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. /// Render cell at the given row and column.
@ -114,6 +138,14 @@ pub trait TableDelegate: Sized + 'static {
fn can_loop_select(&self) -> bool { fn can_loop_select(&self) -> bool {
true 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<D> Table<D> impl<D> Table<D>
@ -132,6 +164,7 @@ where
selected_row: None, selected_row: None,
selected_col: None, selected_col: None,
resizing_col: None, resizing_col: None,
bounds: Bounds::default(),
}; };
this.prepare_col_groups(cx); this.prepare_col_groups(cx);
@ -316,9 +349,9 @@ where
) )
.hover(|this| this.bg(cx.theme().drag_border)) .hover(|this| this.bg(cx.theme().drag_border))
.when(is_resizing, |this| this.bg(cx.theme().drag_border)) .when(is_resizing, |this| this.bg(cx.theme().drag_border))
.on_drag_move(cx.listener( .on_drag_move(cx.listener(move |view, e: &DragMoveEvent<ResizeCol>, cx| {
move |view, e: &DragMoveEvent<DragCol>, cx| match e.drag(cx) { match e.drag(cx) {
DragCol((entity_id, ix)) => { ResizeCol((entity_id, ix)) => {
if cx.entity_id() != *entity_id { if cx.entity_id() != *entity_id {
return; return;
} }
@ -331,16 +364,24 @@ where
let ix = *ix; let ix = *ix;
view.resizing_col = Some(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( view.resize_cols(
ix, ix,
e.event.position.x - HANDLE_SIZE - col_group.bounds.left(), e.event.position.x - HANDLE_SIZE - col_group.bounds.left(),
cx, 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.stop_propagation();
cx.new_view(|_| drag.clone()) cx.new_view(|_| drag.clone())
}) })
@ -361,10 +402,32 @@ where
.into_any_element() .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<Pixels>,
col_group: ColGroup,
_: &mut ViewContext<Self>,
) {
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, /// The `ix`` is the index of the col to resize,
/// and the `size` is the new size for the col. /// and the `size` is the new size for the col.
fn resize_cols(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext<Self>) { fn resize_cols(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext<Self>) {
const MIN_WIDTH: Pixels = px(10.0); const MIN_WIDTH: Pixels = px(10.0);
const MAX_WIDTH: Pixels = px(1200.0);
if !self.delegate.can_resize_col(ix) { if !self.delegate.can_resize_col(ix) {
return; return;
} }
@ -380,7 +443,7 @@ where
if changed_width > px(-1.0) && changed_width < px(1.0) { if changed_width > px(-1.0) && changed_width < px(1.0) {
return; 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. // Resize next col, table not need to resize the right cols.
// let next_width = self.col_groups[ix + 1].width.unwrap_or_default(); // 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 /// Becuase the horizontal scroll handle will use the child_item_bounds to
/// calculate the item position for itself's `scroll_to_item` method. /// calculate the item position for itself's `scroll_to_item` method.
fn render_th(&self, col_ix: usize, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_th(&self, col_ix: usize, cx: &mut ViewContext<Self>) -> 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) self.col_wrap(col_ix, cx)
.child( .child(
self.render_cell(col_ix, cx) self.render_cell(col_ix, cx)
.id(("col-header", col_ix))
.on_mouse_down( .on_mouse_down(
MouseButton::Left, MouseButton::Left,
cx.listener(move |this, _, cx| { cx.listener(move |this, _, cx| {
this.on_col_head_click(col_ix, 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::<DragCol>(|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 // resize handle
.child(self.render_resize_handle(col_ix, cx)) .child(self.render_resize_handle(col_ix, cx))
@ -419,6 +517,18 @@ where
.size_full() .size_full()
}) })
} }
fn move_col(&mut self, col_ix: usize, to_ix: usize, cx: &mut ViewContext<Self>) {
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<D> FocusableView for Table<D> impl<D> FocusableView for Table<D>
@ -558,6 +668,7 @@ where
), ),
); );
let view = cx.view().clone();
div() div()
.size_full() .size_full()
.rounded_md() .rounded_md()
@ -571,5 +682,9 @@ where
ScrollableAxis::Horizontal, ScrollableAxis::Horizontal,
&horizontal_scroll_handle, &horizontal_scroll_handle,
)) ))
.child(canvas(
move |bounds, cx| view.update(cx, |r, _| r.bounds = bounds),
|_, _, _| {},
))
} }
} }