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