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