table: Add drag to move col. (#76)
https://github.com/user-attachments/assets/9964f3da-0376-4b53-b64e-c9652c324aa3
This commit is contained in:
parent
79958fc95c
commit
7bfdfcb2ec
3 changed files with 245 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -25,26 +25,6 @@ struct Customer {
|
|||
verified: 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> {
|
||||
(0..size)
|
||||
|
|
@ -67,80 +47,106 @@ fn randome_customers(size: usize) -> Vec<Customer> {
|
|||
}
|
||||
struct CustomerTableDelegate {
|
||||
customers: Vec<Customer>,
|
||||
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<Pixels> {
|
||||
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<Option<Pixels>>) {
|
||||
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<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(
|
||||
&mut self,
|
||||
_: View<Table<CustomerTableDelegate>>,
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
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<D: TableDelegate> {
|
||||
focus_handle: FocusHandle,
|
||||
delegate: D,
|
||||
/// The bounds of the table.
|
||||
bounds: Bounds<Pixels>,
|
||||
horizontal_scroll_handle: ScrollHandle,
|
||||
vertical_scroll_handle: UniformListScrollHandle,
|
||||
col_groups: Vec<ColGroup>,
|
||||
|
|
@ -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<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.
|
||||
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<D> Table<D>
|
||||
|
|
@ -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<DragCol>, cx| match e.drag(cx) {
|
||||
DragCol((entity_id, ix)) => {
|
||||
.on_drag_move(cx.listener(move |view, e: &DragMoveEvent<ResizeCol>, 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<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,
|
||||
/// and the `size` is the new size for the col.
|
||||
fn resize_cols(&mut self, ix: usize, size: Pixels, cx: &mut ViewContext<Self>) {
|
||||
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<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)
|
||||
.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::<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
|
||||
.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<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>
|
||||
|
|
@ -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),
|
||||
|_, _, _| {},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue