Skip to main content

gpui_component/table/
data_table.rs

1use crate::{
2    ActiveTheme, Sizable, Size, ThemeStyled as _,
3    actions::{
4        Cancel, SelectDown, SelectFirst, SelectLast, SelectNextColumn, SelectPageDown,
5        SelectPageUp, SelectPrevColumn, SelectUp,
6    },
7    table::{TableDelegate, TableState},
8};
9use gpui::{
10    App, Edges, Entity, Focusable, InteractiveElement, IntoElement, KeyBinding, ParentElement,
11    Pixels, RenderOnce, Styled, Window, div, prelude::FluentBuilder,
12};
13use gpui_base::TestSupportExt as _;
14
15const CONTEXT: &'static str = "DataTable";
16pub(super) fn init(cx: &mut App) {
17    cx.bind_keys([
18        KeyBinding::new("escape", Cancel, Some(CONTEXT)),
19        KeyBinding::new("up", SelectUp, Some(CONTEXT)),
20        KeyBinding::new("down", SelectDown, Some(CONTEXT)),
21        KeyBinding::new("left", SelectPrevColumn, Some(CONTEXT)),
22        KeyBinding::new("right", SelectNextColumn, Some(CONTEXT)),
23        KeyBinding::new("home", SelectFirst, Some(CONTEXT)),
24        KeyBinding::new("end", SelectLast, Some(CONTEXT)),
25        KeyBinding::new("pageup", SelectPageUp, Some(CONTEXT)),
26        KeyBinding::new("pagedown", SelectPageDown, Some(CONTEXT)),
27        KeyBinding::new("tab", SelectNextColumn, Some(CONTEXT)),
28        KeyBinding::new("shift-tab", SelectPrevColumn, Some(CONTEXT)),
29    ]);
30}
31
32pub(super) struct TableOptions {
33    pub(super) scroll_bounce: Option<bool>,
34    pub(super) scrollbar_visible: Edges<bool>,
35    /// Set stripe style of the table.
36    pub(super) stripe: bool,
37    /// Set to use border style of the table.
38    pub(super) bordered: bool,
39    /// Draw the border on the right edge of the fixed columns.
40    pub(super) fixed_cols_border: bool,
41    /// The cell size of the table.
42    pub(super) size: Size,
43    /// Height of each header row. `None` uses [`Size::table_row_height`].
44    pub(super) header_row_height: Option<Pixels>,
45}
46
47impl Default for TableOptions {
48    fn default() -> Self {
49        Self {
50            scroll_bounce: None,
51            scrollbar_visible: Edges::all(true),
52            stripe: false,
53            bordered: true,
54            fixed_cols_border: true,
55            size: Size::default(),
56            header_row_height: None,
57        }
58    }
59}
60
61/// A table element with support for row, column, and cell selection.
62///
63/// # Features
64///
65/// - **Multiple Selection Modes**: Support for row, column, and cell selection
66/// - **Cell Selection**: Click to select individual cells, with keyboard navigation
67/// - **Virtual Scrolling**: Efficient rendering of large datasets
68/// - **Resizable Columns**: Drag column borders to resize
69/// - **Movable Columns**: Drag column headers to reorder
70/// - **Fixed Columns**: Pin columns to the left side
71/// - **Sortable Columns**: Click column headers to sort
72/// - **Context Menus**: Right-click support for rows and cells
73///
74/// # Cell Selection Mode
75///
76/// When cell selection is enabled via [`TableState::cell_selectable()`]:
77/// - Click on cells to select them
78/// - A row header column appears on the left for selecting entire rows
79///   (use [`TableState::row_header()`] to hide it)
80/// - Keyboard navigation (arrow keys, Tab, Home, End, PageUp, PageDown) works at cell level
81/// - Right-click and double-click events are supported
82///
83/// See [`TableState`] for more details on cell selection.
84///
85/// # Example
86///
87/// ```rust,ignore
88/// let table_state = cx.new(|cx| {
89///     TableState::new(delegate, cx)
90///         .cell_selectable(true)
91///         .row_selectable(true)
92/// });
93///
94/// DataTable::new(&table_state)
95///     .stripe(true)
96///     .bordered(true)
97/// ```
98#[derive(IntoElement)]
99pub struct DataTable<D: TableDelegate> {
100    state: Entity<TableState<D>>,
101    options: TableOptions,
102    focus_ring_enabled: bool,
103}
104
105impl<D> DataTable<D>
106where
107    D: TableDelegate,
108{
109    /// Create a new DataTable element with the given [`TableState`].
110    pub fn new(state: &Entity<TableState<D>>) -> Self {
111        Self {
112            state: state.clone(),
113            options: TableOptions::default(),
114            focus_ring_enabled: true,
115        }
116    }
117
118    /// Set to use stripe style of the table, default to false.
119    pub fn stripe(mut self, stripe: bool) -> Self {
120        self.options.stripe = stripe;
121        self
122    }
123
124    /// Bounce the rows at the vertical boundaries, keeping headers and
125    /// scrollbars fixed. On by default on iOS,
126    /// Android and macOS, off elsewhere.
127    pub fn scroll_bounce(mut self, enabled: bool) -> Self {
128        self.options.scroll_bounce = Some(enabled);
129        self
130    }
131
132    /// Set to use border style of the table, default to true.
133    pub fn bordered(mut self, bordered: bool) -> Self {
134        self.options.bordered = bordered;
135        self
136    }
137
138    /// Set the height of each header row.
139    ///
140    /// Column-group rows use the same height, so the whole header is this
141    /// height times the number of header rows. Body rows keep the height from
142    /// [`Size::table_row_height`]. When this is unset, header rows use that
143    /// same height.
144    pub fn header_row_height(mut self, height: impl Into<Pixels>) -> Self {
145        self.options.header_row_height = Some(height.into());
146        self
147    }
148
149    /// Set whether to draw the border on the right edge of the fixed columns,
150    /// default to true.
151    ///
152    /// Turn it off when the fixed cells draw that edge themselves, so the
153    /// boundary is not drawn twice.
154    pub fn fixed_cols_border(mut self, fixed_cols_border: bool) -> Self {
155        self.options.fixed_cols_border = fixed_cols_border;
156        self
157    }
158
159    /// Set scrollbar visibility.
160    pub fn scrollbar_visible(mut self, vertical: bool, horizontal: bool) -> Self {
161        self.options.scrollbar_visible = Edges {
162            right: vertical,
163            bottom: horizontal,
164            ..Default::default()
165        };
166        self
167    }
168}
169
170impl<D> Sizable for DataTable<D>
171where
172    D: TableDelegate,
173{
174    fn with_size(mut self, size: impl Into<Size>) -> Self {
175        self.options.size = size.into();
176        self
177    }
178}
179
180impl<D> crate::FocusableExt for DataTable<D>
181where
182    D: TableDelegate,
183{
184    fn focus_ring(mut self, enabled: bool) -> Self {
185        self.focus_ring_enabled = enabled;
186        self
187    }
188
189    fn is_focus_ring_enabled(&self) -> bool {
190        self.focus_ring_enabled
191    }
192}
193
194impl<D> RenderOnce for DataTable<D>
195where
196    D: TableDelegate,
197{
198    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
199        let bordered = self.options.bordered;
200        let focus_ring_enabled = self.focus_ring_enabled;
201        let focus_handle = self.state.focus_handle(cx);
202        self.state.update(cx, |state, _| {
203            state.options = self.options;
204        });
205
206        div()
207            .id("table")
208            .test_support()
209            .size_full()
210            .key_context(CONTEXT)
211            .track_focus(&focus_handle)
212            .on_action(window.listener_for(&self.state, TableState::action_cancel))
213            .on_action(window.listener_for(&self.state, TableState::action_select_next))
214            .on_action(window.listener_for(&self.state, TableState::action_select_prev))
215            .on_action(window.listener_for(&self.state, TableState::action_select_next_col))
216            .on_action(window.listener_for(&self.state, TableState::action_select_prev_col))
217            .on_action(window.listener_for(&self.state, TableState::action_select_first_column))
218            .on_action(window.listener_for(&self.state, TableState::action_select_last_column))
219            .on_action(window.listener_for(&self.state, TableState::action_select_page_up))
220            .on_action(window.listener_for(&self.state, TableState::action_select_page_down))
221            .bg(cx.theme().tokens.table)
222            .when(bordered, |this| {
223                this.rounded(cx.theme().radius)
224                    .border_1()
225                    .border_color(cx.theme().border)
226            })
227            .child(self.state)
228            // Clicking a row focuses the table too, so only keyboard focus
229            // shows it, as CSS `:focus-visible` does.
230            .when(
231                focus_ring_enabled
232                    && focus_handle.is_focused(window)
233                    && window.last_input_was_keyboard(),
234                |this| this.focus_ring_style(window, cx),
235            )
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use gpui::{
242        AppContext as _, Context, Entity, Render, TestAppContext, VisualTestContext, Window, div,
243        px,
244    };
245
246    use super::*;
247    use crate::{
248        FocusableExt as _,
249        table::{Column, TableState},
250    };
251
252    struct RowsDelegate;
253
254    impl TableDelegate for RowsDelegate {
255        fn columns_count(&self, _: &App) -> usize {
256            1
257        }
258        fn rows_count(&self, _: &App) -> usize {
259            3
260        }
261        fn column(&self, _: usize, _: &App) -> Column {
262            Column::new("name", "Name")
263        }
264        fn render_td(
265            &mut self,
266            row: usize,
267            _: usize,
268            _: &mut Window,
269            _: &mut Context<TableState<Self>>,
270        ) -> impl IntoElement {
271            div().child(row.to_string())
272        }
273    }
274
275    struct FocusRingHarness {
276        state: Entity<TableState<RowsDelegate>>,
277        focus_ring: bool,
278    }
279
280    impl Render for FocusRingHarness {
281        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
282            div()
283                .w(px(240.))
284                .h(px(160.))
285                .child(DataTable::new(&self.state).focus_ring(self.focus_ring))
286        }
287    }
288
289    /// Focuses the table and moves the selection with the keyboard, the way
290    /// a user arriving with up/down sees the ring.
291    fn keyboard_focused_table(cx: &mut TestAppContext, focus_ring: bool) -> &mut VisualTestContext {
292        cx.update(crate::init);
293        let (view, cx) = cx.add_window_view(move |window, cx| FocusRingHarness {
294            state: cx.new(|cx| TableState::new(RowsDelegate, window, cx)),
295            focus_ring,
296        });
297        let state = view.read_with(cx, |view, _| view.state.clone());
298        cx.update(|window, cx| {
299            let handle = state.focus_handle(cx);
300            window.focus(&handle, cx);
301        });
302        cx.simulate_keystrokes("down");
303        cx.update(|window, cx| window.draw(cx).clear(cx));
304        cx
305    }
306
307    #[gpui::test]
308    fn test_data_table_builder(cx: &mut TestAppContext) {
309        cx.update(crate::init);
310        let (view, cx) = cx.add_window_view(|window, cx| FocusRingHarness {
311            state: cx.new(|cx| TableState::new(RowsDelegate, window, cx)),
312            focus_ring: true,
313        });
314        let state = view.read_with(cx, |view, _| view.state.clone());
315        assert!(DataTable::new(&state).is_focus_ring_enabled());
316
317        let table = DataTable::new(&state)
318            .stripe(true)
319            .bordered(false)
320            .scroll_bounce(false)
321            .header_row_height(px(32.))
322            .fixed_cols_border(false)
323            .scrollbar_visible(false, true)
324            .with_size(Size::Small)
325            .focus_ring(false);
326        assert!(table.options.stripe);
327        assert!(!table.options.bordered);
328        assert_eq!(table.options.scroll_bounce, Some(false));
329        assert_eq!(table.options.header_row_height, Some(px(32.)));
330        assert!(!table.options.fixed_cols_border);
331        assert!(!table.options.scrollbar_visible.right);
332        assert!(table.options.scrollbar_visible.bottom);
333        assert_eq!(table.options.size, Size::Small);
334        assert!(!table.is_focus_ring_enabled());
335        assert!(table.focus_ring(true).is_focus_ring_enabled());
336    }
337
338    #[gpui::test]
339    fn data_table_draws_the_focus_ring_by_default(cx: &mut TestAppContext) {
340        let cx = keyboard_focused_table(cx, true);
341        assert!(
342            cx.debug_bounds("focus-ring").is_some(),
343            "a keyboard-focused table draws its focus ring"
344        );
345    }
346
347    #[gpui::test]
348    fn data_table_focus_ring_can_be_turned_off(cx: &mut TestAppContext) {
349        let cx = keyboard_focused_table(cx, false);
350        assert!(
351            cx.debug_bounds("focus-ring").is_none(),
352            "`focus_ring(false)` must not draw a ring"
353        );
354    }
355}