From 79958fc95cbb2c46f72c5944476aab02a1e84b45 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sun, 28 Jul 2024 00:51:37 +0800 Subject: [PATCH] Add to support Table column resizing. (#75) https://github.com/user-attachments/assets/d56f2cd7-df08-437b-9eea-849d68094e1f --- crates/story/src/table_story.rs | 36 +++- crates/ui/src/table.rs | 322 +++++++++++++++++++++++--------- crates/ui/src/theme.rs | 10 + 3 files changed, 276 insertions(+), 92 deletions(-) diff --git a/crates/story/src/table_story.rs b/crates/story/src/table_story.rs index 576926c1..2b980c9a 100644 --- a/crates/story/src/table_story.rs +++ b/crates/story/src/table_story.rs @@ -1,13 +1,12 @@ - use fake::Fake; use gpui::{ - ParentElement, Render, SharedString, Styled, View, ViewContext, - VisualContext as _, WindowContext, + ParentElement, Pixels, Render, SharedString, Styled, View, ViewContext, VisualContext as _, + WindowContext, }; use ui::{ checkbox::Checkbox, h_flex, - table::{Table, TableDelegate}, + table::{Table, TableDelegate, TableEvent}, v_flex, Selectable, Selection, }; @@ -97,7 +96,7 @@ impl TableDelegate for CustomerTableDelegate { } } - fn col_width(&self, col_ix: usize) -> Option { + fn col_width(&self, col_ix: usize) -> Option { match col_ix { 0 => Some(50.0), 1 => Some(220.0), @@ -115,6 +114,15 @@ impl TableDelegate for CustomerTableDelegate { 13 => Some(90.0), _ => 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); } fn render_td(&self, row_ix: usize, col_ix: usize) -> impl gpui::IntoElement { @@ -156,6 +164,9 @@ impl TableStory { fn new(cx: &mut ViewContext) -> Self { let delegate = CustomerTableDelegate::new(2000); let table = cx.new_view(|cx| Table::new(delegate, cx)); + + cx.subscribe(&table, Self::on_table_event).detach(); + Self { table } } @@ -166,6 +177,21 @@ impl TableStory { cx.notify(); }); } + + fn on_table_event( + &mut self, + _: View>, + event: &TableEvent, + _cx: &mut ViewContext, + ) { + match event { + TableEvent::ColWidthsChanged(col_widths) => { + println!("Col widths changed: {:?}", col_widths) + } + TableEvent::SelectCol(ix) => println!("Select col: {}", ix), + TableEvent::SelectRow(ix) => println!("Select row: {}", ix), + } + } } impl Render for TableStory { diff --git a/crates/ui/src/table.rs b/crates/ui/src/table.rs index 0db6ab93..446a518f 100644 --- a/crates/ui/src/table.rs +++ b/crates/ui/src/table.rs @@ -3,14 +3,15 @@ use std::{cell::Cell, rc::Rc}; use crate::{ h_flex, scroll::{ScrollableAxis, ScrollableMask, Scrollbar, ScrollbarState}, - theme::{ActiveTheme, Colorize}, + theme::ActiveTheme, v_flex, }; use gpui::{ - actions, div, prelude::FluentBuilder as _, px, uniform_list, AppContext, Div, FocusHandle, - FocusableView, InteractiveElement as _, IntoElement, KeyBinding, MouseButton, - ParentElement as _, Render, ScrollHandle, SharedString, StatefulInteractiveElement as _, - Styled, UniformListScrollHandle, ViewContext, WindowContext, + 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, + SharedString, StatefulInteractiveElement as _, Styled, UniformListScrollHandle, ViewContext, + VisualContext as _, WindowContext, }; actions!( @@ -36,15 +37,26 @@ pub fn init(cx: &mut AppContext) { } struct ColGroup { - width: Option, + width: Option, + bounds: Bounds, } +#[derive(Clone, Render)] +pub struct DragCol(pub (EntityId, usize)); + #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SelectionState { Column, Row, } +#[derive(Clone)] +pub enum TableEvent { + SelectRow(usize), + SelectCol(usize), + ColWidthsChanged(Vec>), +} + pub struct Table { focus_handle: FocusHandle, delegate: D, @@ -57,6 +69,9 @@ pub struct Table { selection_state: SelectionState, selected_row: Option, selected_col: Option, + + /// The column index that is being resized. + resizing_col: Option, } #[allow(unused)] @@ -76,10 +91,12 @@ pub trait TableDelegate: Sized + 'static { /// Returns the width of the column at the given index. /// Return None, use auto width. - fn col_width(&self, col_ix: usize) -> Option; + /// + /// This is only called when the table initializes. + fn col_width(&self, col_ix: usize) -> Option; - /// Set the width of the column at the given index. - fn on_col_width_changed(&mut self, col_ix: usize, width: 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 { @@ -114,9 +131,10 @@ where selection_state: SelectionState::Row, selected_row: None, selected_col: None, + resizing_col: None, }; - this.update_col_groups(cx); + this.prepare_col_groups(cx); this } @@ -128,38 +146,42 @@ where &mut self.delegate } - fn update_col_groups(&mut self, cx: &mut ViewContext) { + fn prepare_col_groups(&mut self, cx: &mut ViewContext) { self.col_groups = (0..self.delegate.cols_count()) .map(|col_ix| ColGroup { width: self.delegate.col_width(col_ix), + bounds: Bounds::default(), }) .collect(); cx.notify(); } - fn scroll_to_selected_row(&mut self, _cx: &mut ViewContext) { + fn set_selected_row(&mut self, row_ix: usize, cx: &mut ViewContext) { + self.selection_state = SelectionState::Row; + self.selected_row = Some(row_ix); if let Some(row_ix) = self.selected_row { self.vertical_scroll_handle.scroll_to_item(row_ix); } + cx.emit(TableEvent::SelectRow(row_ix)); + cx.notify(); } - fn scroll_to_selected_column(&mut self, cx: &mut ViewContext) { + fn set_selected_col(&mut self, col_ix: usize, cx: &mut ViewContext) { + self.selection_state = SelectionState::Column; + self.selected_col = Some(col_ix); if let Some(col_ix) = self.selected_col { self.horizontal_scroll_handle.scroll_to_item(col_ix); - cx.notify(); } + cx.emit(TableEvent::SelectCol(col_ix)); + cx.notify(); } fn on_row_click(&mut self, row_ix: usize, cx: &mut ViewContext) { - self.selection_state = SelectionState::Row; - self.selected_row = Some(row_ix); - self.scroll_to_selected_row(cx); + self.set_selected_row(row_ix, cx) } fn on_col_head_click(&mut self, col_ix: usize, cx: &mut ViewContext) { - self.selection_state = SelectionState::Column; - self.selected_col = Some(col_ix); - self.scroll_to_selected_column(cx); + self.set_selected_col(col_ix, cx) } fn action_cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { @@ -170,72 +192,63 @@ where } fn action_select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - let selected_row = self.selected_row.unwrap_or(0); + let mut selected_row = self.selected_row.unwrap_or(0); let rows_count = self.delegate.rows_count(); if selected_row > 0 { - self.selected_row = Some(selected_row - 1); + selected_row = selected_row - 1; } else { if self.delegate.can_loop_select() { - self.selected_row = Some(rows_count - 1); + selected_row = rows_count - 1; } } - self.selection_state = SelectionState::Row; - self.scroll_to_selected_row(cx); - cx.notify(); + self.set_selected_row(selected_row, cx); } fn action_select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let selected_row = self.selected_row.unwrap_or(0); + let mut selected_row = self.selected_row.unwrap_or(0); if selected_row < self.delegate.rows_count() - 1 { - self.selected_row = Some(selected_row + 1); + selected_row += 1; } else { if self.delegate.can_loop_select() { - self.selected_row = Some(0); + selected_row = 0; } } - self.selection_state = SelectionState::Row; - self.scroll_to_selected_row(cx); - cx.notify(); + self.set_selected_row(selected_row, cx); } - fn action_select_prev_column(&mut self, _: &SelectPrevColumn, cx: &mut ViewContext) { - let selected_col = self.selected_col.unwrap_or(0); + fn action_select_prev_col(&mut self, _: &SelectPrevColumn, cx: &mut ViewContext) { + let mut selected_col = self.selected_col.unwrap_or(0); let cols_count = self.delegate.cols_count(); if selected_col > 0 { - self.selected_col = Some(selected_col - 1); + selected_col -= 1; } else { if self.delegate.can_loop_select() { - self.selected_col = Some(cols_count - 1); + selected_col = cols_count - 1; } } - - self.selection_state = SelectionState::Column; - self.scroll_to_selected_column(cx); - cx.notify(); + self.set_selected_col(selected_col, cx); } - fn action_select_next_column(&mut self, _: &SelectNextColumn, cx: &mut ViewContext) { - let selected_col = self.selected_col.unwrap_or(0); + fn action_select_next_col(&mut self, _: &SelectNextColumn, cx: &mut ViewContext) { + let mut selected_col = self.selected_col.unwrap_or(0); if selected_col < self.delegate.cols_count() - 1 { - self.selected_col = Some(selected_col + 1); + selected_col += 1; } else { if self.delegate.can_loop_select() { - self.selected_col = Some(0); + selected_col = 0; } } - self.selection_state = SelectionState::Column; - self.scroll_to_selected_column(cx); - cx.notify(); + self.set_selected_col(selected_col, cx); } fn render_cell(&self, col_ix: usize, _cx: &mut ViewContext) -> Div { let col_width = self.col_groups[col_ix].width; div() - .when_some(col_width, |this, width| this.w(px(width))) + .when_some(col_width, |this, width| this.w(width)) .overflow_hidden() .whitespace_nowrap() .py_1() @@ -245,9 +258,9 @@ where /// Show Column selection style, when the column is selected and the selection state is Column. fn col_wrap(&self, col_ix: usize, cx: &mut ViewContext) -> Div { if self.selected_col == Some(col_ix) && self.selection_state == SelectionState::Column { - div().bg(cx.theme().accent.opacity(0.5)) + h_flex().bg(cx.theme().table_active) } else { - div() + h_flex() } } @@ -270,6 +283,142 @@ where )), ) } + + fn render_resize_handle(&self, ix: usize, cx: &mut ViewContext) -> impl IntoElement { + const HANDLE_SIZE: Pixels = px(3.); + + if !self.delegate.can_resize_col(ix) { + return div().into_any_element(); + } + + let group_id: SharedString = format!("resizable-handle-{}", ix).into(); + let is_resizing = self.resizing_col == Some(ix); + + h_flex() + .id(("resizable-handle", ix)) + .group(group_id.clone()) + .occlude() + .cursor_col_resize() + .h_full() + .w(HANDLE_SIZE) + .ml(-(HANDLE_SIZE)) + .justify_end() + .items_center() + .child( + div() + .h_full() + .h_5() + .justify_center() + .bg(cx.theme().border) + .when(is_resizing, |this| this.bg(cx.theme().drag_border)) + .group_hover(group_id, |this| this.bg(cx.theme().drag_border)) + .w(px(1.)), + ) + .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)) => { + if cx.entity_id() != *entity_id { + return; + } + + // sync col widths into real widths + for (_, col_group) in view.col_groups.iter_mut().enumerate() { + col_group.width = Some(col_group.bounds.size.width); + } + + let ix = *ix; + view.resizing_col = Some(ix); + + let col_group = view.col_groups.get(ix).expect("BUG: invalid col index"); + view.resize_cols( + ix, + e.event.position.x - HANDLE_SIZE - col_group.bounds.left(), + cx, + ); + } + }, + )) + .on_drag(DragCol((cx.entity_id(), ix)), |drag, cx| { + cx.stop_propagation(); + cx.new_view(|_| drag.clone()) + }) + .on_mouse_up_out( + MouseButton::Left, + cx.listener(|view, _, cx| { + if view.resizing_col.is_none() { + return; + } + + view.resizing_col = None; + + let new_widths = view.col_groups.iter().map(|g| g.width).collect(); + cx.emit(TableEvent::ColWidthsChanged(new_widths)); + cx.notify(); + }), + ) + .into_any_element() + } + + /// 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); + if !self.delegate.can_resize_col(ix) { + return; + } + let size = size.floor(); + + let old_width = self.col_groups[ix].width.unwrap_or_default(); + let new_width = size; + if new_width < MIN_WIDTH { + return; + } + let changed_width = new_width - old_width; + // If change size is less than 1px, do nothing. + if changed_width > px(-1.0) && changed_width < px(1.0) { + return; + } + self.col_groups[ix].width = Some(new_width); + + // 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 = (next_width - changed_width).max(MIN_WIDTH); + // self.col_groups[ix + 1].width = Some(next_width); + + cx.notify(); + } + + /// Render the column header. + /// The children must be one by one items. + /// 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 { + self.col_wrap(col_ix, cx) + .child( + self.render_cell(col_ix, cx) + .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)), + ) + // resize handle + .child(self.render_resize_handle(col_ix, cx)) + // to save the bounds of this col. + .child({ + let view = cx.view().clone(); + canvas( + move |bounds, cx| view.update(cx, |r, _| r.col_groups[col_ix].bounds = bounds), + |_, _, _| {}, + ) + .absolute() + .size_full() + }) + } } impl FocusableView for Table @@ -281,6 +430,8 @@ where } } +impl EventEmitter for Table where D: TableDelegate {} + impl Render for Table where D: TableDelegate, @@ -291,11 +442,13 @@ where let horizontal_scroll_handle = self.horizontal_scroll_handle.clone(); let cols_count: usize = self.delegate.cols_count(); let rows_count = self.delegate.rows_count(); - let selected_bg = cx.theme().accent.opacity(0.8); - let hover_bg = cx.theme().accent.opacity(0.5); - fn tr(cx: &mut WindowContext) -> Div { - h_flex().gap_1().border_color(cx.theme().border) + fn last_empty_col(_: &mut WindowContext) -> Div { + h_flex().w(px(100.)).h_full().flex_shrink_0() + } + + fn tr(_: &mut WindowContext) -> Div { + h_flex() } let inner_table = v_flex() @@ -305,8 +458,8 @@ where .on_action(cx.listener(Self::action_cancel)) .on_action(cx.listener(Self::action_select_next)) .on_action(cx.listener(Self::action_select_prev)) - .on_action(cx.listener(Self::action_select_next_column)) - .on_action(cx.listener(Self::action_select_prev_column)) + .on_action(cx.listener(Self::action_select_next_col)) + .on_action(cx.listener(Self::action_select_prev_col)) .size_full() .overflow_hidden() .child( @@ -314,7 +467,6 @@ where .flex_grow() .h_10() .w_full() - .shadow_sm() .border_b_1() .border_color(cx.theme().border) .child( @@ -322,30 +474,22 @@ where let horizontal_scroll_handle = horizontal_scroll_handle.clone(); move |table, _, cx| { // Columns - vec![tr(cx) + tr(cx) .id("table-head") .w_full() + .h_10() .overflow_scroll() .track_scroll(&horizontal_scroll_handle) - // The children must be one by one items. - // Becuase the horizontal scroll handle will use the child_item_bounds to - // calculate the item position for itself's `scroll_to_item` method. - .children(table.col_groups.iter().enumerate().map( - |(col_ix, _)| { - table.col_wrap(col_ix, cx).child( - table - .render_cell(col_ix, cx) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, cx| { - this.on_col_head_click(col_ix, cx); - }), - ) - .child(table.delegate.render_th(col_ix)), - ) - }, - )) - .flex_1()] + .bg(cx.theme().table_head) + .children( + table + .col_groups + .iter() + .enumerate() + .map(|(col_ix, _)| table.render_th(col_ix, cx)), + ) + .child(last_empty_col(cx)) + .map(|this| vec![this]) } }) .size_full(), @@ -361,6 +505,17 @@ where tr(cx) .id(("table-row", row_ix)) .w_full() + .when(row_ix > 0, |this| this.border_t_1()) + .when(row_ix % 2 == 0, |this| { + this.bg(cx.theme().table_even) + }) + .hover(|this| { + if table.selected_row.is_some() { + this + } else { + this.bg(cx.theme().table_hover) + } + }) .children((0..cols_count).map(|col_ix| { table .col_wrap(col_ix, cx) // Make the row scroll sync with the horizontal_scroll_handle to support horizontal scrolling. @@ -376,20 +531,13 @@ where ), ) })) - .when(row_ix > 0, |this| this.border_t_1()) - .hover(|this| { - if table.selected_row.is_some() { - this - } else { - this.bg(hover_bg) - } - }) + .child(last_empty_col(cx)) // Row selected style .when_some(table.selected_row, |this, selected_row| { this.when( row_ix == selected_row && table.selection_state == SelectionState::Row, - |this| this.bg(selected_bg), + |this| this.bg(cx.theme().table_active), ) }) .on_mouse_down( @@ -415,7 +563,7 @@ where .rounded_md() .border_1() .border_color(cx.theme().border) - .bg(cx.theme().card) + .bg(cx.theme().table) .child(inner_table) .children(self.render_scrollbar(cx)) .child(ScrollableMask::new( diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 91851452..554dbc50 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -331,6 +331,11 @@ pub struct Theme { pub slider_thumb: Hsla, pub list_item_active: Hsla, pub list_item_hover: Hsla, + pub table: Hsla, + pub table_even: Hsla, + pub table_head: Hsla, + pub table_active: Hsla, + pub table_hover: Hsla, } impl Global for Theme {} @@ -398,6 +403,11 @@ impl From for Theme { slider_thumb: colors.background, list_item_active: colors.secondary_active, list_item_hover: colors.secondary, + table_head: colors.secondary.opacity(0.5), + table: colors.background, + table_even: colors.secondary.opacity(0.3), + table_active: colors.secondary_active, + table_hover: colors.secondary_active.opacity(0.7), } } }