1use std::{ops::Range, rc::Rc};
2
3use gpui::{
4 AbsoluteLength, AppContext, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
5 FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, ListState, Point,
6 Stateful, UniformListScrollHandle, WeakEntity, list, transparent_black, uniform_list,
7};
8
9use crate::{
10 ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
11 ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
12 InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
13 ScrollAxes, ScrollableHandle, Scrollbars, SharedString, StatefulInteractiveElement, Styled,
14 StyledExt as _, StyledTypography, Window, WithScrollbar, div, example_group_with_title, h_flex,
15 px, single_example, v_flex,
16};
17use itertools::intersperse_with;
18
19const RESIZE_COLUMN_WIDTH: f32 = 8.0;
20
21#[derive(Debug)]
22struct DraggedColumn(usize);
23
24struct UniformListData<const COLS: usize> {
25 render_list_of_rows_fn:
26 Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
27 element_id: ElementId,
28 row_count: usize,
29}
30
31struct VariableRowHeightListData<const COLS: usize> {
32 /// Unlike UniformList, this closure renders only single row, allowing each one to have its own height
33 render_row_fn: Box<dyn Fn(usize, &mut Window, &mut App) -> [AnyElement; COLS]>,
34 list_state: ListState,
35 row_count: usize,
36}
37
38enum TableContents<const COLS: usize> {
39 Vec(Vec<[AnyElement; COLS]>),
40 UniformList(UniformListData<COLS>),
41 VariableRowHeightList(VariableRowHeightListData<COLS>),
42}
43
44impl<const COLS: usize> TableContents<COLS> {
45 fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> {
46 match self {
47 TableContents::Vec(rows) => Some(rows),
48 TableContents::UniformList(_) => None,
49 TableContents::VariableRowHeightList(_) => None,
50 }
51 }
52
53 fn len(&self) -> usize {
54 match self {
55 TableContents::Vec(rows) => rows.len(),
56 TableContents::UniformList(data) => data.row_count,
57 TableContents::VariableRowHeightList(data) => data.row_count,
58 }
59 }
60
61 fn is_empty(&self) -> bool {
62 self.len() == 0
63 }
64}
65
66pub struct TableInteractionState {
67 pub focus_handle: FocusHandle,
68 pub scroll_handle: UniformListScrollHandle,
69 pub custom_scrollbar: Option<Scrollbars>,
70}
71
72impl TableInteractionState {
73 pub fn new(cx: &mut App) -> Self {
74 Self {
75 focus_handle: cx.focus_handle(),
76 scroll_handle: UniformListScrollHandle::new(),
77 custom_scrollbar: None,
78 }
79 }
80
81 pub fn with_custom_scrollbar(mut self, custom_scrollbar: Scrollbars) -> Self {
82 self.custom_scrollbar = Some(custom_scrollbar);
83 self
84 }
85
86 pub fn scroll_offset(&self) -> Point<Pixels> {
87 self.scroll_handle.offset()
88 }
89
90 pub fn set_scroll_offset(&self, offset: Point<Pixels>) {
91 self.scroll_handle.set_offset(offset);
92 }
93
94 pub fn listener<E: ?Sized>(
95 this: &Entity<Self>,
96 f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static,
97 ) -> impl Fn(&E, &mut Window, &mut App) + 'static {
98 let view = this.downgrade();
99 move |e: &E, window: &mut Window, cx: &mut App| {
100 view.update(cx, |view, cx| f(view, e, window, cx)).ok();
101 }
102 }
103
104 fn render_resize_handles<const COLS: usize>(
105 &self,
106 column_widths: &[Length; COLS],
107 resizable_columns: &[TableResizeBehavior; COLS],
108 initial_sizes: [DefiniteLength; COLS],
109 columns: Option<Entity<TableColumnWidths<COLS>>>,
110 window: &mut Window,
111 cx: &mut App,
112 ) -> AnyElement {
113 let spacers = column_widths
114 .iter()
115 .map(|width| base_cell_style(Some(*width)).into_any_element());
116
117 let mut column_ix = 0;
118 let resizable_columns_slice = *resizable_columns;
119 let mut resizable_columns = resizable_columns.iter();
120
121 let dividers = intersperse_with(spacers, || {
122 window.with_id(column_ix, |window| {
123 let mut resize_divider = div()
124 // This is required because this is evaluated at a different time than the use_state call above
125 .id(column_ix)
126 .relative()
127 .top_0()
128 .w_px()
129 .h_full()
130 .bg(cx.theme().colors().border.opacity(0.8));
131
132 let mut resize_handle = div()
133 .id("column-resize-handle")
134 .absolute()
135 .left_neg_0p5()
136 .w(px(RESIZE_COLUMN_WIDTH))
137 .h_full();
138
139 if resizable_columns
140 .next()
141 .is_some_and(TableResizeBehavior::is_resizable)
142 {
143 let hovered = window.use_state(cx, |_window, _cx| false);
144
145 resize_divider = resize_divider.when(*hovered.read(cx), |div| {
146 div.bg(cx.theme().colors().border_focused)
147 });
148
149 resize_handle = resize_handle
150 .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
151 .cursor_col_resize()
152 .when_some(columns.clone(), |this, columns| {
153 this.on_click(move |event, window, cx| {
154 if event.click_count() >= 2 {
155 columns.update(cx, |columns, _| {
156 columns.on_double_click(
157 column_ix,
158 &initial_sizes,
159 &resizable_columns_slice,
160 window,
161 );
162 })
163 }
164
165 cx.stop_propagation();
166 })
167 })
168 .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
169 cx.new(|_cx| gpui::Empty)
170 })
171 }
172
173 column_ix += 1;
174 resize_divider.child(resize_handle).into_any_element()
175 })
176 });
177
178 h_flex()
179 .id("resize-handles")
180 .absolute()
181 .inset_0()
182 .w_full()
183 .children(dividers)
184 .into_any_element()
185 }
186}
187
188#[derive(Debug, Copy, Clone, PartialEq)]
189pub enum TableResizeBehavior {
190 None,
191 Resizable,
192 MinSize(f32),
193}
194
195impl TableResizeBehavior {
196 pub fn is_resizable(&self) -> bool {
197 *self != TableResizeBehavior::None
198 }
199
200 pub fn min_size(&self) -> Option<f32> {
201 match self {
202 TableResizeBehavior::None => None,
203 TableResizeBehavior::Resizable => Some(0.05),
204 TableResizeBehavior::MinSize(min_size) => Some(*min_size),
205 }
206 }
207}
208
209pub struct TableColumnWidths<const COLS: usize> {
210 widths: [DefiniteLength; COLS],
211 visible_widths: [DefiniteLength; COLS],
212 cached_bounds_width: Pixels,
213 initialized: bool,
214}
215
216impl<const COLS: usize> TableColumnWidths<COLS> {
217 pub fn new(_: &mut App) -> Self {
218 Self {
219 widths: [DefiniteLength::default(); COLS],
220 visible_widths: [DefiniteLength::default(); COLS],
221 cached_bounds_width: Default::default(),
222 initialized: false,
223 }
224 }
225
226 fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
227 match length {
228 DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
229 DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
230 rems_width.to_pixels(rem_size) / bounds_width
231 }
232 DefiniteLength::Fraction(fraction) => *fraction,
233 }
234 }
235
236 fn on_double_click(
237 &mut self,
238 double_click_position: usize,
239 initial_sizes: &[DefiniteLength; COLS],
240 resize_behavior: &[TableResizeBehavior; COLS],
241 window: &mut Window,
242 ) {
243 let bounds_width = self.cached_bounds_width;
244 let rem_size = window.rem_size();
245 let initial_sizes =
246 initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size));
247 let widths = self
248 .widths
249 .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
250
251 let updated_widths = Self::reset_to_initial_size(
252 double_click_position,
253 widths,
254 initial_sizes,
255 resize_behavior,
256 );
257 self.widths = updated_widths.map(DefiniteLength::Fraction);
258 self.visible_widths = self.widths;
259 }
260
261 fn reset_to_initial_size(
262 col_idx: usize,
263 mut widths: [f32; COLS],
264 initial_sizes: [f32; COLS],
265 resize_behavior: &[TableResizeBehavior; COLS],
266 ) -> [f32; COLS] {
267 // RESET:
268 // Part 1:
269 // Figure out if we should shrink/grow the selected column
270 // Get diff which represents the change in column we want to make initial size delta curr_size = diff
271 //
272 // Part 2: We need to decide which side column we should move and where
273 //
274 // If we want to grow our column we should check the left/right columns diff to see what side
275 // has a greater delta than their initial size. Likewise, if we shrink our column we should check
276 // the left/right column diffs to see what side has the smallest delta.
277 //
278 // Part 3: resize
279 //
280 // col_idx represents the column handle to the right of an active column
281 //
282 // If growing and right has the greater delta {
283 // shift col_idx to the right
284 // } else if growing and left has the greater delta {
285 // shift col_idx - 1 to the left
286 // } else if shrinking and the right has the greater delta {
287 // shift
288 // } {
289 //
290 // }
291 // }
292 //
293 // if we need to shrink, then if the right
294 //
295
296 // DRAGGING
297 // we get diff which represents the change in the _drag handle_ position
298 // -diff => dragging left ->
299 // grow the column to the right of the handle as much as we can shrink columns to the left of the handle
300 // +diff => dragging right -> growing handles column
301 // grow the column to the left of the handle as much as we can shrink columns to the right of the handle
302 //
303
304 let diff = initial_sizes[col_idx] - widths[col_idx];
305
306 let left_diff =
307 initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
308 let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
309 - widths[col_idx + 1..].iter().sum::<f32>();
310
311 let go_left_first = if diff < 0.0 {
312 left_diff > right_diff
313 } else {
314 left_diff < right_diff
315 };
316
317 if !go_left_first {
318 let diff_remaining =
319 Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
320
321 if diff_remaining != 0.0 && col_idx > 0 {
322 Self::propagate_resize_diff(
323 diff_remaining,
324 col_idx,
325 &mut widths,
326 resize_behavior,
327 -1,
328 );
329 }
330 } else {
331 let diff_remaining =
332 Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
333
334 if diff_remaining != 0.0 {
335 Self::propagate_resize_diff(
336 diff_remaining,
337 col_idx,
338 &mut widths,
339 resize_behavior,
340 1,
341 );
342 }
343 }
344
345 widths
346 }
347
348 fn on_drag_move(
349 &mut self,
350 drag_event: &DragMoveEvent<DraggedColumn>,
351 resize_behavior: &[TableResizeBehavior; COLS],
352 window: &mut Window,
353 cx: &mut Context<Self>,
354 ) {
355 let drag_position = drag_event.event.position;
356 let bounds = drag_event.bounds;
357
358 let mut col_position = 0.0;
359 let rem_size = window.rem_size();
360 let bounds_width = bounds.right() - bounds.left();
361 let col_idx = drag_event.drag(cx).0;
362
363 let column_handle_width = Self::get_fraction(
364 &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))),
365 bounds_width,
366 rem_size,
367 );
368
369 let mut widths = self
370 .widths
371 .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
372
373 for length in widths[0..=col_idx].iter() {
374 col_position += length + column_handle_width;
375 }
376
377 let mut total_length_ratio = col_position;
378 for length in widths[col_idx + 1..].iter() {
379 total_length_ratio += length;
380 }
381 total_length_ratio += (COLS - 1 - col_idx) as f32 * column_handle_width;
382
383 let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
384 let drag_fraction = drag_fraction * total_length_ratio;
385 let diff = drag_fraction - col_position - column_handle_width / 2.0;
386
387 Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior);
388
389 self.visible_widths = widths.map(DefiniteLength::Fraction);
390 }
391
392 fn drag_column_handle(
393 diff: f32,
394 col_idx: usize,
395 widths: &mut [f32; COLS],
396 resize_behavior: &[TableResizeBehavior; COLS],
397 ) {
398 // if diff > 0.0 then go right
399 if diff > 0.0 {
400 Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
401 } else {
402 Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
403 }
404 }
405
406 fn propagate_resize_diff(
407 diff: f32,
408 col_idx: usize,
409 widths: &mut [f32; COLS],
410 resize_behavior: &[TableResizeBehavior; COLS],
411 direction: i8,
412 ) -> f32 {
413 let mut diff_remaining = diff;
414 if resize_behavior[col_idx].min_size().is_none() {
415 return diff;
416 }
417
418 let step_right;
419 let step_left;
420 if direction < 0 {
421 step_right = 0;
422 step_left = 1;
423 } else {
424 step_right = 1;
425 step_left = 0;
426 }
427 if col_idx == 0 && direction < 0 {
428 return diff;
429 }
430 let mut curr_column = col_idx + step_right - step_left;
431
432 while diff_remaining != 0.0 && curr_column < COLS {
433 let Some(min_size) = resize_behavior[curr_column].min_size() else {
434 if curr_column == 0 {
435 break;
436 }
437 curr_column -= step_left;
438 curr_column += step_right;
439 continue;
440 };
441
442 let curr_width = widths[curr_column] - diff_remaining;
443 widths[curr_column] = curr_width;
444
445 if min_size > curr_width {
446 diff_remaining = min_size - curr_width;
447 widths[curr_column] = min_size;
448 } else {
449 diff_remaining = 0.0;
450 break;
451 }
452 if curr_column == 0 {
453 break;
454 }
455 curr_column -= step_left;
456 curr_column += step_right;
457 }
458 widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
459
460 diff_remaining
461 }
462}
463
464pub struct TableWidths<const COLS: usize> {
465 initial: [DefiniteLength; COLS],
466 current: Option<Entity<TableColumnWidths<COLS>>>,
467 resizable: [TableResizeBehavior; COLS],
468}
469
470impl<const COLS: usize> TableWidths<COLS> {
471 pub fn new(widths: [impl Into<DefiniteLength>; COLS]) -> Self {
472 let widths = widths.map(Into::into);
473
474 TableWidths {
475 initial: widths,
476 current: None,
477 resizable: [TableResizeBehavior::None; COLS],
478 }
479 }
480
481 fn lengths(&self, cx: &App) -> [Length; COLS] {
482 self.current
483 .as_ref()
484 .map(|entity| entity.read(cx).visible_widths.map(Length::Definite))
485 .unwrap_or(self.initial.map(Length::Definite))
486 }
487}
488
489/// A table component
490#[derive(RegisterComponent, IntoElement)]
491pub struct Table<const COLS: usize = 3> {
492 striped: bool,
493 width: Option<Length>,
494 headers: Option<[AnyElement; COLS]>,
495 rows: TableContents<COLS>,
496 interaction_state: Option<WeakEntity<TableInteractionState>>,
497 col_widths: Option<TableWidths<COLS>>,
498 map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
499 use_ui_font: bool,
500 empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
501}
502
503impl<const COLS: usize> Table<COLS> {
504 /// number of headers provided.
505 pub fn new() -> Self {
506 Self {
507 striped: false,
508 width: None,
509 headers: None,
510 rows: TableContents::Vec(Vec::new()),
511 interaction_state: None,
512 map_row: None,
513 use_ui_font: true,
514 empty_table_callback: None,
515 col_widths: None,
516 }
517 }
518
519 /// Enables uniform list rendering.
520 /// The provided function will be passed directly to the `uniform_list` element.
521 /// Therefore, if this method is called, any calls to [`Table::row`] before or after
522 /// this method is called will be ignored.
523 pub fn uniform_list(
524 mut self,
525 id: impl Into<ElementId>,
526 row_count: usize,
527 render_item_fn: impl Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>
528 + 'static,
529 ) -> Self {
530 self.rows = TableContents::UniformList(UniformListData {
531 element_id: id.into(),
532 row_count,
533 render_list_of_rows_fn: Box::new(render_item_fn),
534 });
535 self
536 }
537
538 /// Enables rendering of tables with variable row heights, allowing each row to have its own height.
539 ///
540 /// This mode is useful for displaying content such as CSV data or multiline cells, where rows may not have uniform heights.
541 /// It is generally slower than [`Table::uniform_list`] due to the need to measure each row individually, but it provides correct layout for non-uniform or multiline content.
542 ///
543 /// # Parameters
544 /// - `row_count`: The total number of rows in the table.
545 /// - `list_state`: The [`ListState`] used for managing scroll position and virtualization. This must be initialized and managed by the caller, and should be kept in sync with the number of rows.
546 /// - `render_row_fn`: A closure that renders a single row, given the row index, a mutable reference to [`Window`], and a mutable reference to [`App`]. It should return an array of [`AnyElement`]s, one for each column.
547 pub fn variable_row_height_list(
548 mut self,
549 row_count: usize,
550 list_state: ListState,
551 render_row_fn: impl Fn(usize, &mut Window, &mut App) -> [AnyElement; COLS] + 'static,
552 ) -> Self {
553 self.rows = TableContents::VariableRowHeightList(VariableRowHeightListData {
554 render_row_fn: Box::new(render_row_fn),
555 list_state,
556 row_count,
557 });
558 self
559 }
560
561 /// Enables row striping.
562 pub fn striped(mut self) -> Self {
563 self.striped = true;
564 self
565 }
566
567 /// Sets the width of the table.
568 /// Will enable horizontal scrolling if [`Self::interactable`] is also called.
569 pub fn width(mut self, width: impl Into<Length>) -> Self {
570 self.width = Some(width.into());
571 self
572 }
573
574 /// Enables interaction (primarily scrolling) with the table.
575 ///
576 /// Vertical scrolling will be enabled by default if the table is taller than its container.
577 ///
578 /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise
579 /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`]
580 /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will
581 /// be set to [`ListHorizontalSizingBehavior::FitList`].
582 pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
583 self.interaction_state = Some(interaction_state.downgrade());
584 self
585 }
586
587 pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
588 self.headers = Some(headers.map(IntoElement::into_any_element));
589 self
590 }
591
592 pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self {
593 if let Some(rows) = self.rows.rows_mut() {
594 rows.push(items.map(IntoElement::into_any_element));
595 }
596 self
597 }
598
599 pub fn column_widths(mut self, widths: [impl Into<DefiniteLength>; COLS]) -> Self {
600 if self.col_widths.is_none() {
601 self.col_widths = Some(TableWidths::new(widths));
602 }
603 self
604 }
605
606 pub fn resizable_columns(
607 mut self,
608 resizable: [TableResizeBehavior; COLS],
609 column_widths: &Entity<TableColumnWidths<COLS>>,
610 cx: &mut App,
611 ) -> Self {
612 if let Some(table_widths) = self.col_widths.as_mut() {
613 table_widths.resizable = resizable;
614 let column_widths = table_widths
615 .current
616 .get_or_insert_with(|| column_widths.clone());
617
618 column_widths.update(cx, |widths, _| {
619 if !widths.initialized {
620 widths.initialized = true;
621 widths.widths = table_widths.initial;
622 widths.visible_widths = widths.widths;
623 }
624 })
625 }
626 self
627 }
628
629 pub fn no_ui_font(mut self) -> Self {
630 self.use_ui_font = false;
631 self
632 }
633
634 pub fn map_row(
635 mut self,
636 callback: impl Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement + 'static,
637 ) -> Self {
638 self.map_row = Some(Rc::new(callback));
639 self
640 }
641
642 /// Provide a callback that is invoked when the table is rendered without any rows
643 pub fn empty_table_callback(
644 mut self,
645 callback: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
646 ) -> Self {
647 self.empty_table_callback = Some(Rc::new(callback));
648 self
649 }
650}
651
652fn base_cell_style(width: Option<Length>) -> Div {
653 div()
654 .px_1p5()
655 .when_some(width, |this, width| this.w(width))
656 .when(width.is_none(), |this| this.flex_1())
657 .whitespace_nowrap()
658 .text_ellipsis()
659 .overflow_hidden()
660}
661
662fn base_cell_style_text(width: Option<Length>, use_ui_font: bool, cx: &App) -> Div {
663 base_cell_style(width).when(use_ui_font, |el| el.text_ui(cx))
664}
665
666pub fn render_table_row<const COLS: usize>(
667 row_index: usize,
668 items: [impl IntoElement; COLS],
669 table_context: TableRenderContext<COLS>,
670 window: &mut Window,
671 cx: &mut App,
672) -> AnyElement {
673 let is_striped = table_context.striped;
674 let is_last = row_index == table_context.total_row_count - 1;
675 let bg = if row_index % 2 == 1 && is_striped {
676 Some(cx.theme().colors().text.opacity(0.05))
677 } else {
678 None
679 };
680 let column_widths = table_context
681 .column_widths
682 .map_or([None; COLS], |widths| widths.map(Some));
683
684 let mut row = div()
685 // NOTE: `h_flex()` sneakily applies `items_center()` which is not default behavior for div element.
686 // Applying `.flex().flex_row()` manually to overcome that
687 .flex()
688 .flex_row()
689 .id(("table_row", row_index))
690 .size_full()
691 .when_some(bg, |row, bg| row.bg(bg))
692 .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.6)))
693 .when(!is_striped, |row| {
694 row.border_b_1()
695 .border_color(transparent_black())
696 .when(!is_last, |row| row.border_color(cx.theme().colors().border))
697 });
698
699 row = row.children(
700 items
701 .map(IntoElement::into_any_element)
702 .into_iter()
703 .zip(column_widths)
704 .map(|(cell, width)| {
705 base_cell_style_text(width, table_context.use_ui_font, cx)
706 .px_1()
707 .py_0p5()
708 .child(cell)
709 }),
710 );
711
712 let row = if let Some(map_row) = table_context.map_row {
713 map_row((row_index, row), window, cx)
714 } else {
715 row.into_any_element()
716 };
717
718 div().size_full().child(row).into_any_element()
719}
720
721pub fn render_table_header<const COLS: usize>(
722 headers: [impl IntoElement; COLS],
723 table_context: TableRenderContext<COLS>,
724 columns_widths: Option<(
725 WeakEntity<TableColumnWidths<COLS>>,
726 [TableResizeBehavior; COLS],
727 [DefiniteLength; COLS],
728 )>,
729 entity_id: Option<EntityId>,
730 cx: &mut App,
731) -> impl IntoElement {
732 let column_widths = table_context
733 .column_widths
734 .map_or([None; COLS], |widths| widths.map(Some));
735
736 let element_id = entity_id
737 .map(|entity| entity.to_string())
738 .unwrap_or_default();
739
740 let shared_element_id: SharedString = format!("table-{}", element_id).into();
741
742 div()
743 .flex()
744 .flex_row()
745 .items_center()
746 .justify_between()
747 .w_full()
748 .p_2()
749 .border_b_1()
750 .border_color(cx.theme().colors().border)
751 .children(headers.into_iter().enumerate().zip(column_widths).map(
752 |((header_idx, h), width)| {
753 base_cell_style_text(width, table_context.use_ui_font, cx)
754 .child(h)
755 .id(ElementId::NamedInteger(
756 shared_element_id.clone(),
757 header_idx as u64,
758 ))
759 .when_some(
760 columns_widths.as_ref().cloned(),
761 |this, (column_widths, resizables, initial_sizes)| {
762 if resizables[header_idx].is_resizable() {
763 this.on_click(move |event, window, cx| {
764 if event.click_count() > 1 {
765 column_widths
766 .update(cx, |column, _| {
767 column.on_double_click(
768 header_idx,
769 &initial_sizes,
770 &resizables,
771 window,
772 );
773 })
774 .ok();
775 }
776 })
777 } else {
778 this
779 }
780 },
781 )
782 },
783 ))
784}
785
786#[derive(Clone)]
787pub struct TableRenderContext<const COLS: usize> {
788 pub striped: bool,
789 pub total_row_count: usize,
790 pub column_widths: Option<[Length; COLS]>,
791 pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
792 pub use_ui_font: bool,
793}
794
795impl<const COLS: usize> TableRenderContext<COLS> {
796 fn new(table: &Table<COLS>, cx: &App) -> Self {
797 Self {
798 striped: table.striped,
799 total_row_count: table.rows.len(),
800 column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
801 map_row: table.map_row.clone(),
802 use_ui_font: table.use_ui_font,
803 }
804 }
805}
806
807impl<const COLS: usize> RenderOnce for Table<COLS> {
808 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
809 let table_context = TableRenderContext::new(&self, cx);
810 let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
811 let current_widths = self
812 .col_widths
813 .as_ref()
814 .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable)))
815 .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
816
817 let current_widths_with_initial_sizes = self
818 .col_widths
819 .as_ref()
820 .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial)))
821 .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
822
823 let width = self.width;
824 let no_rows_rendered = self.rows.is_empty();
825
826 let table = div()
827 .when_some(width, |this, width| this.w(width))
828 .h_full()
829 .v_flex()
830 .when_some(self.headers.take(), |this, headers| {
831 this.child(render_table_header(
832 headers,
833 table_context.clone(),
834 current_widths_with_initial_sizes,
835 interaction_state.as_ref().map(Entity::entity_id),
836 cx,
837 ))
838 })
839 .when_some(current_widths, {
840 |this, (widths, resize_behavior)| {
841 this.on_drag_move::<DraggedColumn>({
842 let widths = widths.clone();
843 move |e, window, cx| {
844 widths
845 .update(cx, |widths, cx| {
846 widths.on_drag_move(e, &resize_behavior, window, cx);
847 })
848 .ok();
849 }
850 })
851 .on_children_prepainted({
852 let widths = widths.clone();
853 move |bounds, _, cx| {
854 widths
855 .update(cx, |widths, _| {
856 // This works because all children x axis bounds are the same
857 widths.cached_bounds_width =
858 bounds[0].right() - bounds[0].left();
859 })
860 .ok();
861 }
862 })
863 .on_drop::<DraggedColumn>(move |_, _, cx| {
864 widths
865 .update(cx, |widths, _| {
866 widths.widths = widths.visible_widths;
867 })
868 .ok();
869 // Finish the resize operation
870 })
871 }
872 })
873 .child({
874 let content = div()
875 .flex_grow()
876 .w_full()
877 .relative()
878 .overflow_hidden()
879 .map(|parent| match self.rows {
880 TableContents::Vec(items) => {
881 parent.children(items.into_iter().enumerate().map(|(index, row)| {
882 div().child(render_table_row(
883 index,
884 row,
885 table_context.clone(),
886 window,
887 cx,
888 ))
889 }))
890 }
891 TableContents::UniformList(uniform_list_data) => parent.child(
892 uniform_list(
893 uniform_list_data.element_id,
894 uniform_list_data.row_count,
895 {
896 let render_item_fn = uniform_list_data.render_list_of_rows_fn;
897 move |range: Range<usize>, window, cx| {
898 let elements = render_item_fn(range.clone(), window, cx);
899 elements
900 .into_iter()
901 .zip(range)
902 .map(|(row, row_index)| {
903 render_table_row(
904 row_index,
905 row,
906 table_context.clone(),
907 window,
908 cx,
909 )
910 })
911 .collect()
912 }
913 },
914 )
915 .size_full()
916 .flex_grow()
917 .with_sizing_behavior(ListSizingBehavior::Auto)
918 .with_horizontal_sizing_behavior(if width.is_some() {
919 ListHorizontalSizingBehavior::Unconstrained
920 } else {
921 ListHorizontalSizingBehavior::FitList
922 })
923 .when_some(
924 interaction_state.as_ref(),
925 |this, state| {
926 this.track_scroll(
927 &state.read_with(cx, |s, _| s.scroll_handle.clone()),
928 )
929 },
930 ),
931 ),
932 TableContents::VariableRowHeightList(variable_list_data) => parent.child(
933 list(variable_list_data.list_state.clone(), {
934 let render_item_fn = variable_list_data.render_row_fn;
935 move |row_index: usize, window: &mut Window, cx: &mut App| {
936 let row = render_item_fn(row_index, window, cx);
937 render_table_row(
938 row_index,
939 row,
940 table_context.clone(),
941 window,
942 cx,
943 )
944 }
945 })
946 .size_full()
947 .flex_grow()
948 .with_sizing_behavior(ListSizingBehavior::Auto),
949 ),
950 })
951 .when_some(
952 self.col_widths.as_ref().zip(interaction_state.as_ref()),
953 |parent, (table_widths, state)| {
954 parent.child(state.update(cx, |state, cx| {
955 let resizable_columns = table_widths.resizable;
956 let column_widths = table_widths.lengths(cx);
957 let columns = table_widths.current.clone();
958 let initial_sizes = table_widths.initial;
959 state.render_resize_handles(
960 &column_widths,
961 &resizable_columns,
962 initial_sizes,
963 columns,
964 window,
965 cx,
966 )
967 }))
968 },
969 );
970
971 if let Some(state) = interaction_state.as_ref() {
972 let scrollbars = state
973 .read(cx)
974 .custom_scrollbar
975 .clone()
976 .unwrap_or_else(|| Scrollbars::new(ScrollAxes::Both));
977 content
978 .custom_scrollbars(
979 scrollbars.tracked_scroll_handle(&state.read(cx).scroll_handle),
980 window,
981 cx,
982 )
983 .into_any_element()
984 } else {
985 content.into_any_element()
986 }
987 })
988 .when_some(
989 no_rows_rendered
990 .then_some(self.empty_table_callback)
991 .flatten(),
992 |this, callback| {
993 this.child(
994 h_flex()
995 .size_full()
996 .p_3()
997 .items_start()
998 .justify_center()
999 .child(callback(window, cx)),
1000 )
1001 },
1002 );
1003
1004 if let Some(interaction_state) = interaction_state.as_ref() {
1005 table
1006 .track_focus(&interaction_state.read(cx).focus_handle)
1007 .id(("table", interaction_state.entity_id()))
1008 .into_any_element()
1009 } else {
1010 table.into_any_element()
1011 }
1012 }
1013}
1014
1015impl Component for Table<3> {
1016 fn scope() -> ComponentScope {
1017 ComponentScope::Layout
1018 }
1019
1020 fn description() -> Option<&'static str> {
1021 Some("A table component for displaying data in rows and columns with optional styling.")
1022 }
1023
1024 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1025 Some(
1026 v_flex()
1027 .gap_6()
1028 .children(vec![
1029 example_group_with_title(
1030 "Basic Tables",
1031 vec![
1032 single_example(
1033 "Simple Table",
1034 Table::new()
1035 .width(px(400.))
1036 .header(["Name", "Age", "City"])
1037 .row(["Alice", "28", "New York"])
1038 .row(["Bob", "32", "San Francisco"])
1039 .row(["Charlie", "25", "London"])
1040 .into_any_element(),
1041 ),
1042 single_example(
1043 "Two Column Table",
1044 Table::new()
1045 .header(["Category", "Value"])
1046 .width(px(300.))
1047 .row(["Revenue", "$100,000"])
1048 .row(["Expenses", "$75,000"])
1049 .row(["Profit", "$25,000"])
1050 .into_any_element(),
1051 ),
1052 ],
1053 ),
1054 example_group_with_title(
1055 "Styled Tables",
1056 vec![
1057 single_example(
1058 "Default",
1059 Table::new()
1060 .width(px(400.))
1061 .header(["Product", "Price", "Stock"])
1062 .row(["Laptop", "$999", "In Stock"])
1063 .row(["Phone", "$599", "Low Stock"])
1064 .row(["Tablet", "$399", "Out of Stock"])
1065 .into_any_element(),
1066 ),
1067 single_example(
1068 "Striped",
1069 Table::new()
1070 .width(px(400.))
1071 .striped()
1072 .header(["Product", "Price", "Stock"])
1073 .row(["Laptop", "$999", "In Stock"])
1074 .row(["Phone", "$599", "Low Stock"])
1075 .row(["Tablet", "$399", "Out of Stock"])
1076 .row(["Headphones", "$199", "In Stock"])
1077 .into_any_element(),
1078 ),
1079 ],
1080 ),
1081 example_group_with_title(
1082 "Mixed Content Table",
1083 vec![single_example(
1084 "Table with Elements",
1085 Table::new()
1086 .width(px(840.))
1087 .header(["Status", "Name", "Priority", "Deadline", "Action"])
1088 .row([
1089 Indicator::dot().color(Color::Success).into_any_element(),
1090 "Project A".into_any_element(),
1091 "High".into_any_element(),
1092 "2023-12-31".into_any_element(),
1093 Button::new("view_a", "View")
1094 .style(ButtonStyle::Filled)
1095 .full_width()
1096 .into_any_element(),
1097 ])
1098 .row([
1099 Indicator::dot().color(Color::Warning).into_any_element(),
1100 "Project B".into_any_element(),
1101 "Medium".into_any_element(),
1102 "2024-03-15".into_any_element(),
1103 Button::new("view_b", "View")
1104 .style(ButtonStyle::Filled)
1105 .full_width()
1106 .into_any_element(),
1107 ])
1108 .row([
1109 Indicator::dot().color(Color::Error).into_any_element(),
1110 "Project C".into_any_element(),
1111 "Low".into_any_element(),
1112 "2024-06-30".into_any_element(),
1113 Button::new("view_c", "View")
1114 .style(ButtonStyle::Filled)
1115 .full_width()
1116 .into_any_element(),
1117 ])
1118 .into_any_element(),
1119 )],
1120 ),
1121 ])
1122 .into_any_element(),
1123 )
1124 }
1125}
1126
1127#[cfg(test)]
1128mod test {
1129 use super::*;
1130
1131 fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
1132 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
1133 }
1134
1135 fn cols_to_str<const COLS: usize>(cols: &[f32; COLS], total_size: f32) -> String {
1136 cols.map(|f| "*".repeat(f32::round(f * total_size) as usize))
1137 .join("|")
1138 }
1139
1140 fn parse_resize_behavior<const COLS: usize>(
1141 input: &str,
1142 total_size: f32,
1143 ) -> [TableResizeBehavior; COLS] {
1144 let mut resize_behavior = [TableResizeBehavior::None; COLS];
1145 let mut max_index = 0;
1146 for (index, col) in input.split('|').enumerate() {
1147 if col.starts_with('X') || col.is_empty() {
1148 resize_behavior[index] = TableResizeBehavior::None;
1149 } else if col.starts_with('*') {
1150 resize_behavior[index] =
1151 TableResizeBehavior::MinSize(col.len() as f32 / total_size);
1152 } else {
1153 panic!("invalid test input: unrecognized resize behavior: {}", col);
1154 }
1155 max_index = index;
1156 }
1157
1158 if max_index + 1 != COLS {
1159 panic!("invalid test input: too many columns");
1160 }
1161 resize_behavior
1162 }
1163
1164 mod reset_column_size {
1165 use super::*;
1166
1167 fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
1168 let mut widths = [f32::NAN; COLS];
1169 let mut column_index = None;
1170 for (index, col) in input.split('|').enumerate() {
1171 widths[index] = col.len() as f32;
1172 if col.starts_with('X') {
1173 column_index = Some(index);
1174 }
1175 }
1176
1177 for w in widths {
1178 assert!(w.is_finite(), "incorrect number of columns");
1179 }
1180 let total = widths.iter().sum::<f32>();
1181 for width in &mut widths {
1182 *width /= total;
1183 }
1184 (widths, total, column_index)
1185 }
1186
1187 #[track_caller]
1188 fn check_reset_size<const COLS: usize>(
1189 initial_sizes: &str,
1190 widths: &str,
1191 expected: &str,
1192 resize_behavior: &str,
1193 ) {
1194 let (initial_sizes, total_1, None) = parse::<COLS>(initial_sizes) else {
1195 panic!("invalid test input: initial sizes should not be marked");
1196 };
1197 let (widths, total_2, Some(column_index)) = parse::<COLS>(widths) else {
1198 panic!("invalid test input: widths should be marked");
1199 };
1200 assert_eq!(
1201 total_1, total_2,
1202 "invalid test input: total width not the same {total_1}, {total_2}"
1203 );
1204 let (expected, total_3, None) = parse::<COLS>(expected) else {
1205 panic!("invalid test input: expected should not be marked: {expected:?}");
1206 };
1207 assert_eq!(
1208 total_2, total_3,
1209 "invalid test input: total width not the same"
1210 );
1211 let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
1212 let result = TableColumnWidths::reset_to_initial_size(
1213 column_index,
1214 widths,
1215 initial_sizes,
1216 &resize_behavior,
1217 );
1218 let is_eq = is_almost_eq(&result, &expected);
1219 if !is_eq {
1220 let result_str = cols_to_str(&result, total_1);
1221 let expected_str = cols_to_str(&expected, total_1);
1222 panic!(
1223 "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
1224 );
1225 }
1226 }
1227
1228 macro_rules! check_reset_size {
1229 (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
1230 check_reset_size::<$cols>($initial, $current, $expected, $resizing);
1231 };
1232 ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
1233 #[test]
1234 fn $name() {
1235 check_reset_size::<$cols>($initial, $current, $expected, $resizing);
1236 }
1237 };
1238 }
1239
1240 check_reset_size!(
1241 basic_right,
1242 columns: 5,
1243 starting: "**|**|**|**|**",
1244 snapshot: "**|**|X|***|**",
1245 expected: "**|**|**|**|**",
1246 minimums: "X|*|*|*|*",
1247 );
1248
1249 check_reset_size!(
1250 basic_left,
1251 columns: 5,
1252 starting: "**|**|**|**|**",
1253 snapshot: "**|**|***|X|**",
1254 expected: "**|**|**|**|**",
1255 minimums: "X|*|*|*|**",
1256 );
1257
1258 check_reset_size!(
1259 squashed_left_reset_col2,
1260 columns: 6,
1261 starting: "*|***|**|**|****|*",
1262 snapshot: "*|*|X|*|*|********",
1263 expected: "*|*|**|*|*|*******",
1264 minimums: "X|*|*|*|*|*",
1265 );
1266
1267 check_reset_size!(
1268 grow_cascading_right,
1269 columns: 6,
1270 starting: "*|***|****|**|***|*",
1271 snapshot: "*|***|X|**|**|*****",
1272 expected: "*|***|****|*|*|****",
1273 minimums: "X|*|*|*|*|*",
1274 );
1275
1276 check_reset_size!(
1277 squashed_right_reset_col4,
1278 columns: 6,
1279 starting: "*|***|**|**|****|*",
1280 snapshot: "*|********|*|*|X|*",
1281 expected: "*|*****|*|*|****|*",
1282 minimums: "X|*|*|*|*|*",
1283 );
1284
1285 check_reset_size!(
1286 reset_col6_right,
1287 columns: 6,
1288 starting: "*|***|**|***|***|**",
1289 snapshot: "*|***|**|***|**|XXX",
1290 expected: "*|***|**|***|***|**",
1291 minimums: "X|*|*|*|*|*",
1292 );
1293
1294 check_reset_size!(
1295 reset_col6_left,
1296 columns: 6,
1297 starting: "*|***|**|***|***|**",
1298 snapshot: "*|***|**|***|****|X",
1299 expected: "*|***|**|***|***|**",
1300 minimums: "X|*|*|*|*|*",
1301 );
1302
1303 check_reset_size!(
1304 last_column_grow_cascading,
1305 columns: 6,
1306 starting: "*|***|**|**|**|***",
1307 snapshot: "*|*******|*|**|*|X",
1308 expected: "*|******|*|*|*|***",
1309 minimums: "X|*|*|*|*|*",
1310 );
1311
1312 check_reset_size!(
1313 goes_left_when_left_has_extreme_diff,
1314 columns: 6,
1315 starting: "*|***|****|**|**|***",
1316 snapshot: "*|********|X|*|**|**",
1317 expected: "*|*****|****|*|**|**",
1318 minimums: "X|*|*|*|*|*",
1319 );
1320
1321 check_reset_size!(
1322 basic_shrink_right,
1323 columns: 6,
1324 starting: "**|**|**|**|**|**",
1325 snapshot: "**|**|XXX|*|**|**",
1326 expected: "**|**|**|**|**|**",
1327 minimums: "X|*|*|*|*|*",
1328 );
1329
1330 check_reset_size!(
1331 shrink_should_go_left,
1332 columns: 6,
1333 starting: "*|***|**|*|*|*",
1334 snapshot: "*|*|XXX|**|*|*",
1335 expected: "*|**|**|**|*|*",
1336 minimums: "X|*|*|*|*|*",
1337 );
1338
1339 check_reset_size!(
1340 shrink_should_go_right,
1341 columns: 6,
1342 starting: "*|***|**|**|**|*",
1343 snapshot: "*|****|XXX|*|*|*",
1344 expected: "*|****|**|**|*|*",
1345 minimums: "X|*|*|*|*|*",
1346 );
1347 }
1348
1349 mod drag_handle {
1350 use super::*;
1351
1352 fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
1353 let mut widths = [f32::NAN; COLS];
1354 let column_index = input.replace("*", "").find("I");
1355 for (index, col) in input.replace("I", "|").split('|').enumerate() {
1356 widths[index] = col.len() as f32;
1357 }
1358
1359 for w in widths {
1360 assert!(w.is_finite(), "incorrect number of columns");
1361 }
1362 let total = widths.iter().sum::<f32>();
1363 for width in &mut widths {
1364 *width /= total;
1365 }
1366 (widths, total, column_index)
1367 }
1368
1369 #[track_caller]
1370 fn check<const COLS: usize>(
1371 distance: i32,
1372 widths: &str,
1373 expected: &str,
1374 resize_behavior: &str,
1375 ) {
1376 let (mut widths, total_1, Some(column_index)) = parse::<COLS>(widths) else {
1377 panic!("invalid test input: widths should be marked");
1378 };
1379 let (expected, total_2, None) = parse::<COLS>(expected) else {
1380 panic!("invalid test input: expected should not be marked: {expected:?}");
1381 };
1382 assert_eq!(
1383 total_1, total_2,
1384 "invalid test input: total width not the same"
1385 );
1386 let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
1387
1388 let distance = distance as f32 / total_1;
1389
1390 let result = TableColumnWidths::drag_column_handle(
1391 distance,
1392 column_index,
1393 &mut widths,
1394 &resize_behavior,
1395 );
1396
1397 let is_eq = is_almost_eq(&widths, &expected);
1398 if !is_eq {
1399 let result_str = cols_to_str(&widths, total_1);
1400 let expected_str = cols_to_str(&expected, total_1);
1401 panic!(
1402 "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
1403 );
1404 }
1405 }
1406
1407 macro_rules! check {
1408 (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
1409 check!($cols, $dist, $snapshot, $expected, $resizing);
1410 };
1411 ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
1412 #[test]
1413 fn $name() {
1414 check::<$cols>($dist, $current, $expected, $resizing);
1415 }
1416 };
1417 }
1418
1419 check!(
1420 basic_right_drag,
1421 columns: 3,
1422 distance: 1,
1423 snapshot: "**|**I**",
1424 expected: "**|***|*",
1425 minimums: "X|*|*",
1426 );
1427
1428 check!(
1429 drag_left_against_mins,
1430 columns: 5,
1431 distance: -1,
1432 snapshot: "*|*|*|*I*******",
1433 expected: "*|*|*|*|*******",
1434 minimums: "X|*|*|*|*",
1435 );
1436
1437 check!(
1438 drag_left,
1439 columns: 5,
1440 distance: -2,
1441 snapshot: "*|*|*|*****I***",
1442 expected: "*|*|*|***|*****",
1443 minimums: "X|*|*|*|*",
1444 );
1445 }
1446}