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