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