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 pub(super) stripe: bool,
38 pub(super) bordered: bool,
40 pub(super) fixed_cols_border: bool,
42 pub(super) size: Size,
44 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#[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 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 pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
123 self.accessibility_label = Some(label.into());
124 self
125 }
126
127 pub fn stripe(mut self, stripe: bool) -> Self {
129 self.options.stripe = stripe;
130 self
131 }
132
133 pub fn scroll_bounce(mut self, enabled: bool) -> Self {
137 self.options.scroll_bounce = Some(enabled);
138 self
139 }
140
141 pub fn bordered(mut self, bordered: bool) -> Self {
143 self.options.bordered = bordered;
144 self
145 }
146
147 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 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 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 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 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 .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 .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 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}