1use std::{ops::Range, rc::Rc, time::Duration};
2
3use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
4use gpui::{
5 AbsoluteLength, AppContext, Axis, Context, DefiniteLength, DragMoveEvent, Entity, EntityId,
6 FocusHandle, Length, ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Point,
7 Stateful, Task, UniformListScrollHandle, WeakEntity, transparent_black, uniform_list,
8};
9
10use itertools::intersperse_with;
11use settings::Settings as _;
12use ui::{
13 ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
14 ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
15 InteractiveElement, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
16 Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, StyledExt as _,
17 StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
18};
19
20const RESIZE_COLUMN_WIDTH: f32 = 5.0;
21
22#[derive(Debug)]
23struct DraggedColumn(usize);
24
25struct UniformListData<const COLS: usize> {
26 render_item_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>>,
27 element_id: ElementId,
28 row_count: usize,
29}
30
31enum TableContents<const COLS: usize> {
32 Vec(Vec<[AnyElement; COLS]>),
33 UniformList(UniformListData<COLS>),
34}
35
36impl<const COLS: usize> TableContents<COLS> {
37 fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> {
38 match self {
39 TableContents::Vec(rows) => Some(rows),
40 TableContents::UniformList(_) => None,
41 }
42 }
43
44 fn len(&self) -> usize {
45 match self {
46 TableContents::Vec(rows) => rows.len(),
47 TableContents::UniformList(data) => data.row_count,
48 }
49 }
50
51 fn is_empty(&self) -> bool {
52 self.len() == 0
53 }
54}
55
56pub struct TableInteractionState {
57 pub focus_handle: FocusHandle,
58 pub scroll_handle: UniformListScrollHandle,
59 pub horizontal_scrollbar: ScrollbarProperties,
60 pub vertical_scrollbar: ScrollbarProperties,
61}
62
63impl TableInteractionState {
64 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
65 cx.new(|cx| {
66 let focus_handle = cx.focus_handle();
67
68 cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, window, cx| {
69 this.hide_scrollbars(window, cx);
70 })
71 .detach();
72
73 let scroll_handle = UniformListScrollHandle::new();
74 let vertical_scrollbar = ScrollbarProperties {
75 axis: Axis::Vertical,
76 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
77 show_scrollbar: false,
78 show_track: false,
79 auto_hide: false,
80 hide_task: None,
81 };
82
83 let horizontal_scrollbar = ScrollbarProperties {
84 axis: Axis::Horizontal,
85 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
86 show_scrollbar: false,
87 show_track: false,
88 auto_hide: false,
89 hide_task: None,
90 };
91
92 let mut this = Self {
93 focus_handle,
94 scroll_handle,
95 horizontal_scrollbar,
96 vertical_scrollbar,
97 };
98
99 this.update_scrollbar_visibility(cx);
100 this
101 })
102 }
103
104 pub fn get_scrollbar_offset(&self, axis: Axis) -> Point<Pixels> {
105 match axis {
106 Axis::Vertical => self.vertical_scrollbar.state.scroll_handle().offset(),
107 Axis::Horizontal => self.horizontal_scrollbar.state.scroll_handle().offset(),
108 }
109 }
110
111 pub fn set_scrollbar_offset(&self, axis: Axis, offset: Point<Pixels>) {
112 match axis {
113 Axis::Vertical => self
114 .vertical_scrollbar
115 .state
116 .scroll_handle()
117 .set_offset(offset),
118 Axis::Horizontal => self
119 .horizontal_scrollbar
120 .state
121 .scroll_handle()
122 .set_offset(offset),
123 }
124 }
125
126 fn update_scrollbar_visibility(&mut self, cx: &mut Context<Self>) {
127 let show_setting = EditorSettings::get_global(cx).scrollbar.show;
128
129 let scroll_handle = self.scroll_handle.0.borrow();
130
131 let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
132 ShowScrollbar::Auto => true,
133 ShowScrollbar::System => cx
134 .try_global::<ScrollbarAutoHide>()
135 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
136 ShowScrollbar::Always => false,
137 ShowScrollbar::Never => false,
138 };
139
140 let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
141 (size.contents.width > size.item.width).then_some(size.contents.width)
142 });
143
144 // is there an item long enough that we should show a horizontal scrollbar?
145 let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
146 longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
147 } else {
148 true
149 };
150
151 let show_scrollbar = match show_setting {
152 ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
153 ShowScrollbar::Never => false,
154 };
155 let show_vertical = show_scrollbar;
156
157 let show_horizontal = item_wider_than_container && show_scrollbar;
158
159 let show_horizontal_track =
160 show_horizontal && matches!(show_setting, ShowScrollbar::Always);
161
162 // TODO: we probably should hide the scroll track when the list doesn't need to scroll
163 let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
164
165 self.vertical_scrollbar = ScrollbarProperties {
166 axis: self.vertical_scrollbar.axis,
167 state: self.vertical_scrollbar.state.clone(),
168 show_scrollbar: show_vertical,
169 show_track: show_vertical_track,
170 auto_hide: autohide(show_setting, cx),
171 hide_task: None,
172 };
173
174 self.horizontal_scrollbar = ScrollbarProperties {
175 axis: self.horizontal_scrollbar.axis,
176 state: self.horizontal_scrollbar.state.clone(),
177 show_scrollbar: show_horizontal,
178 show_track: show_horizontal_track,
179 auto_hide: autohide(show_setting, cx),
180 hide_task: None,
181 };
182
183 cx.notify();
184 }
185
186 fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
187 self.horizontal_scrollbar.hide(window, cx);
188 self.vertical_scrollbar.hide(window, cx);
189 }
190
191 pub fn listener<E: ?Sized>(
192 this: &Entity<Self>,
193 f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static,
194 ) -> impl Fn(&E, &mut Window, &mut App) + 'static {
195 let view = this.downgrade();
196 move |e: &E, window: &mut Window, cx: &mut App| {
197 view.update(cx, |view, cx| f(view, e, window, cx)).ok();
198 }
199 }
200
201 fn render_resize_handles<const COLS: usize>(
202 &self,
203 column_widths: &[Length; COLS],
204 resizable_columns: &[ResizeBehavior; COLS],
205 initial_sizes: [DefiniteLength; COLS],
206 columns: Option<Entity<ColumnWidths<COLS>>>,
207 window: &mut Window,
208 cx: &mut App,
209 ) -> AnyElement {
210 let spacers = column_widths
211 .iter()
212 .map(|width| base_cell_style(Some(*width)).into_any_element());
213
214 let mut column_ix = 0;
215 let resizable_columns_slice = *resizable_columns;
216 let mut resizable_columns = resizable_columns.into_iter();
217 let dividers = intersperse_with(spacers, || {
218 window.with_id(column_ix, |window| {
219 let mut resize_divider = div()
220 // This is required because this is evaluated at a different time than the use_state call above
221 .id(column_ix)
222 .relative()
223 .top_0()
224 .w_0p5()
225 .h_full()
226 .bg(cx.theme().colors().border.opacity(0.5));
227
228 let mut resize_handle = div()
229 .id("column-resize-handle")
230 .absolute()
231 .left_neg_0p5()
232 .w(px(RESIZE_COLUMN_WIDTH))
233 .h_full();
234
235 if resizable_columns
236 .next()
237 .is_some_and(ResizeBehavior::is_resizable)
238 {
239 let hovered = window.use_state(cx, |_window, _cx| false);
240 resize_divider = resize_divider.when(*hovered.read(cx), |div| {
241 div.bg(cx.theme().colors().border_focused)
242 });
243 resize_handle = resize_handle
244 .on_hover(move |&was_hovered, _, cx| hovered.write(cx, was_hovered))
245 .cursor_col_resize()
246 .when_some(columns.clone(), |this, columns| {
247 this.on_click(move |event, window, cx| {
248 if event.down.click_count >= 2 {
249 columns.update(cx, |columns, _| {
250 columns.on_double_click(
251 column_ix,
252 &initial_sizes,
253 &resizable_columns_slice,
254 window,
255 );
256 })
257 }
258
259 cx.stop_propagation();
260 })
261 })
262 .on_drag(DraggedColumn(column_ix), |_, _offset, _window, cx| {
263 cx.new(|_cx| gpui::Empty)
264 })
265 }
266
267 column_ix += 1;
268 resize_divider.child(resize_handle).into_any_element()
269 })
270 });
271
272 div()
273 .id("resize-handles")
274 .h_flex()
275 .absolute()
276 .w_full()
277 .inset_0()
278 .children(dividers)
279 .into_any_element()
280 }
281
282 fn render_vertical_scrollbar_track(
283 this: &Entity<Self>,
284 parent: Div,
285 scroll_track_size: Pixels,
286 cx: &mut App,
287 ) -> Div {
288 if !this.read(cx).vertical_scrollbar.show_track {
289 return parent;
290 }
291 let child = v_flex()
292 .h_full()
293 .flex_none()
294 .w(scroll_track_size)
295 .bg(cx.theme().colors().background)
296 .child(
297 div()
298 .size_full()
299 .flex_1()
300 .border_l_1()
301 .border_color(cx.theme().colors().border),
302 );
303 parent.child(child)
304 }
305
306 fn render_vertical_scrollbar(this: &Entity<Self>, parent: Div, cx: &mut App) -> Div {
307 if !this.read(cx).vertical_scrollbar.show_scrollbar {
308 return parent;
309 }
310 let child = div()
311 .id(("table-vertical-scrollbar", this.entity_id()))
312 .occlude()
313 .flex_none()
314 .h_full()
315 .cursor_default()
316 .absolute()
317 .right_0()
318 .top_0()
319 .bottom_0()
320 .w(px(12.))
321 .on_mouse_move(Self::listener(this, |_, _, _, cx| {
322 cx.notify();
323 cx.stop_propagation()
324 }))
325 .on_hover(|_, _, cx| {
326 cx.stop_propagation();
327 })
328 .on_mouse_up(
329 MouseButton::Left,
330 Self::listener(this, |this, _, window, cx| {
331 if !this.vertical_scrollbar.state.is_dragging()
332 && !this.focus_handle.contains_focused(window, cx)
333 {
334 this.vertical_scrollbar.hide(window, cx);
335 cx.notify();
336 }
337
338 cx.stop_propagation();
339 }),
340 )
341 .on_any_mouse_down(|_, _, cx| {
342 cx.stop_propagation();
343 })
344 .on_scroll_wheel(Self::listener(&this, |_, _, _, cx| {
345 cx.notify();
346 }))
347 .children(Scrollbar::vertical(
348 this.read(cx).vertical_scrollbar.state.clone(),
349 ));
350 parent.child(child)
351 }
352
353 /// Renders the horizontal scrollbar.
354 ///
355 /// The right offset is used to determine how far to the right the
356 /// scrollbar should extend to, useful for ensuring it doesn't collide
357 /// with the vertical scrollbar when visible.
358 fn render_horizontal_scrollbar(
359 this: &Entity<Self>,
360 parent: Div,
361 right_offset: Pixels,
362 cx: &mut App,
363 ) -> Div {
364 if !this.read(cx).horizontal_scrollbar.show_scrollbar {
365 return parent;
366 }
367 let child = div()
368 .id(("table-horizontal-scrollbar", this.entity_id()))
369 .occlude()
370 .flex_none()
371 .w_full()
372 .cursor_default()
373 .absolute()
374 .bottom_neg_px()
375 .left_0()
376 .right_0()
377 .pr(right_offset)
378 .on_mouse_move(Self::listener(this, |_, _, _, cx| {
379 cx.notify();
380 cx.stop_propagation()
381 }))
382 .on_hover(|_, _, cx| {
383 cx.stop_propagation();
384 })
385 .on_any_mouse_down(|_, _, cx| {
386 cx.stop_propagation();
387 })
388 .on_mouse_up(
389 MouseButton::Left,
390 Self::listener(this, |this, _, window, cx| {
391 if !this.horizontal_scrollbar.state.is_dragging()
392 && !this.focus_handle.contains_focused(window, cx)
393 {
394 this.horizontal_scrollbar.hide(window, cx);
395 cx.notify();
396 }
397
398 cx.stop_propagation();
399 }),
400 )
401 .on_scroll_wheel(Self::listener(this, |_, _, _, cx| {
402 cx.notify();
403 }))
404 .children(Scrollbar::horizontal(
405 // percentage as f32..end_offset as f32,
406 this.read(cx).horizontal_scrollbar.state.clone(),
407 ));
408 parent.child(child)
409 }
410
411 fn render_horizontal_scrollbar_track(
412 this: &Entity<Self>,
413 parent: Div,
414 scroll_track_size: Pixels,
415 cx: &mut App,
416 ) -> Div {
417 if !this.read(cx).horizontal_scrollbar.show_track {
418 return parent;
419 }
420 let child = h_flex()
421 .w_full()
422 .h(scroll_track_size)
423 .flex_none()
424 .relative()
425 .child(
426 div()
427 .w_full()
428 .flex_1()
429 // for some reason the horizontal scrollbar is 1px
430 // taller than the vertical scrollbar??
431 .h(scroll_track_size - px(1.))
432 .bg(cx.theme().colors().background)
433 .border_t_1()
434 .border_color(cx.theme().colors().border),
435 )
436 .when(this.read(cx).vertical_scrollbar.show_track, |parent| {
437 parent
438 .child(
439 div()
440 .flex_none()
441 // -1px prevents a missing pixel between the two container borders
442 .w(scroll_track_size - px(1.))
443 .h_full(),
444 )
445 .child(
446 // HACK: Fill the missing 1px 🥲
447 div()
448 .absolute()
449 .right(scroll_track_size - px(1.))
450 .bottom(scroll_track_size - px(1.))
451 .size_px()
452 .bg(cx.theme().colors().border),
453 )
454 });
455
456 parent.child(child)
457 }
458}
459
460#[derive(Debug, Copy, Clone, PartialEq)]
461pub enum ResizeBehavior {
462 None,
463 Resizable,
464 MinSize(f32),
465}
466
467impl ResizeBehavior {
468 pub fn is_resizable(&self) -> bool {
469 *self != ResizeBehavior::None
470 }
471
472 pub fn min_size(&self) -> Option<f32> {
473 match self {
474 ResizeBehavior::None => None,
475 ResizeBehavior::Resizable => Some(0.05),
476 ResizeBehavior::MinSize(min_size) => Some(*min_size),
477 }
478 }
479}
480
481pub struct ColumnWidths<const COLS: usize> {
482 widths: [DefiniteLength; COLS],
483 visible_widths: [DefiniteLength; COLS],
484 cached_bounds_width: Pixels,
485 initialized: bool,
486}
487
488impl<const COLS: usize> ColumnWidths<COLS> {
489 pub fn new(_: &mut App) -> Self {
490 Self {
491 widths: [DefiniteLength::default(); COLS],
492 visible_widths: [DefiniteLength::default(); COLS],
493 cached_bounds_width: Default::default(),
494 initialized: false,
495 }
496 }
497
498 fn get_fraction(length: &DefiniteLength, bounds_width: Pixels, rem_size: Pixels) -> f32 {
499 match length {
500 DefiniteLength::Absolute(AbsoluteLength::Pixels(pixels)) => *pixels / bounds_width,
501 DefiniteLength::Absolute(AbsoluteLength::Rems(rems_width)) => {
502 rems_width.to_pixels(rem_size) / bounds_width
503 }
504 DefiniteLength::Fraction(fraction) => *fraction,
505 }
506 }
507
508 fn on_double_click(
509 &mut self,
510 double_click_position: usize,
511 initial_sizes: &[DefiniteLength; COLS],
512 resize_behavior: &[ResizeBehavior; COLS],
513 window: &mut Window,
514 ) {
515 let bounds_width = self.cached_bounds_width;
516 let rem_size = window.rem_size();
517 let initial_sizes =
518 initial_sizes.map(|length| Self::get_fraction(&length, bounds_width, rem_size));
519 let widths = self
520 .widths
521 .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
522
523 let updated_widths = Self::reset_to_initial_size(
524 double_click_position,
525 widths,
526 initial_sizes,
527 resize_behavior,
528 );
529 self.widths = updated_widths.map(DefiniteLength::Fraction);
530 self.visible_widths = self.widths;
531 }
532
533 fn reset_to_initial_size(
534 col_idx: usize,
535 mut widths: [f32; COLS],
536 initial_sizes: [f32; COLS],
537 resize_behavior: &[ResizeBehavior; COLS],
538 ) -> [f32; COLS] {
539 // RESET:
540 // Part 1:
541 // Figure out if we should shrink/grow the selected column
542 // Get diff which represents the change in column we want to make initial size delta curr_size = diff
543 //
544 // Part 2: We need to decide which side column we should move and where
545 //
546 // If we want to grow our column we should check the left/right columns diff to see what side
547 // has a greater delta than their initial size. Likewise, if we shrink our column we should check
548 // the left/right column diffs to see what side has the smallest delta.
549 //
550 // Part 3: resize
551 //
552 // col_idx represents the column handle to the right of an active column
553 //
554 // If growing and right has the greater delta {
555 // shift col_idx to the right
556 // } else if growing and left has the greater delta {
557 // shift col_idx - 1 to the left
558 // } else if shrinking and the right has the greater delta {
559 // shift
560 // } {
561 //
562 // }
563 // }
564 //
565 // if we need to shrink, then if the right
566 //
567
568 // DRAGGING
569 // we get diff which represents the change in the _drag handle_ position
570 // -diff => dragging left ->
571 // grow the column to the right of the handle as much as we can shrink columns to the left of the handle
572 // +diff => dragging right -> growing handles column
573 // grow the column to the left of the handle as much as we can shrink columns to the right of the handle
574 //
575
576 let diff = initial_sizes[col_idx] - widths[col_idx];
577
578 let left_diff =
579 initial_sizes[..col_idx].iter().sum::<f32>() - widths[..col_idx].iter().sum::<f32>();
580 let right_diff = initial_sizes[col_idx + 1..].iter().sum::<f32>()
581 - widths[col_idx + 1..].iter().sum::<f32>();
582
583 let go_left_first = if diff < 0.0 {
584 left_diff > right_diff
585 } else {
586 left_diff < right_diff
587 };
588
589 if !go_left_first {
590 let diff_remaining =
591 Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, 1);
592
593 if diff_remaining != 0.0 && col_idx > 0 {
594 Self::propagate_resize_diff(
595 diff_remaining,
596 col_idx,
597 &mut widths,
598 resize_behavior,
599 -1,
600 );
601 }
602 } else {
603 let diff_remaining =
604 Self::propagate_resize_diff(diff, col_idx, &mut widths, resize_behavior, -1);
605
606 if diff_remaining != 0.0 {
607 Self::propagate_resize_diff(
608 diff_remaining,
609 col_idx,
610 &mut widths,
611 resize_behavior,
612 1,
613 );
614 }
615 }
616
617 widths
618 }
619
620 fn on_drag_move(
621 &mut self,
622 drag_event: &DragMoveEvent<DraggedColumn>,
623 resize_behavior: &[ResizeBehavior; COLS],
624 window: &mut Window,
625 cx: &mut Context<Self>,
626 ) {
627 let drag_position = drag_event.event.position;
628 let bounds = drag_event.bounds;
629
630 let mut col_position = 0.0;
631 let rem_size = window.rem_size();
632 let bounds_width = bounds.right() - bounds.left();
633 let col_idx = drag_event.drag(cx).0;
634
635 let column_handle_width = Self::get_fraction(
636 &DefiniteLength::Absolute(AbsoluteLength::Pixels(px(RESIZE_COLUMN_WIDTH))),
637 bounds_width,
638 rem_size,
639 );
640
641 let mut widths = self
642 .widths
643 .map(|length| Self::get_fraction(&length, bounds_width, rem_size));
644
645 for length in widths[0..=col_idx].iter() {
646 col_position += length + column_handle_width;
647 }
648
649 let mut total_length_ratio = col_position;
650 for length in widths[col_idx + 1..].iter() {
651 total_length_ratio += length;
652 }
653 total_length_ratio += (COLS - 1 - col_idx) as f32 * column_handle_width;
654
655 let drag_fraction = (drag_position.x - bounds.left()) / bounds_width;
656 let drag_fraction = drag_fraction * total_length_ratio;
657 let diff = drag_fraction - col_position - column_handle_width / 2.0;
658
659 Self::drag_column_handle(diff, col_idx, &mut widths, resize_behavior);
660
661 self.visible_widths = widths.map(DefiniteLength::Fraction);
662 }
663
664 fn drag_column_handle(
665 diff: f32,
666 col_idx: usize,
667 widths: &mut [f32; COLS],
668 resize_behavior: &[ResizeBehavior; COLS],
669 ) {
670 // if diff > 0.0 then go right
671 if diff > 0.0 {
672 Self::propagate_resize_diff(diff, col_idx, widths, resize_behavior, 1);
673 } else {
674 Self::propagate_resize_diff(-diff, col_idx + 1, widths, resize_behavior, -1);
675 }
676 }
677
678 fn propagate_resize_diff(
679 diff: f32,
680 col_idx: usize,
681 widths: &mut [f32; COLS],
682 resize_behavior: &[ResizeBehavior; COLS],
683 direction: i8,
684 ) -> f32 {
685 let mut diff_remaining = diff;
686 if resize_behavior[col_idx].min_size().is_none() {
687 return diff;
688 }
689
690 let step_right;
691 let step_left;
692 if direction < 0 {
693 step_right = 0;
694 step_left = 1;
695 } else {
696 step_right = 1;
697 step_left = 0;
698 }
699 if col_idx == 0 && direction < 0 {
700 return diff;
701 }
702 let mut curr_column = col_idx + step_right - step_left;
703
704 while diff_remaining != 0.0 && curr_column < COLS {
705 let Some(min_size) = resize_behavior[curr_column].min_size() else {
706 if curr_column == 0 {
707 break;
708 }
709 curr_column -= step_left;
710 curr_column += step_right;
711 continue;
712 };
713
714 let curr_width = widths[curr_column] - diff_remaining;
715 widths[curr_column] = curr_width;
716
717 if min_size > curr_width {
718 diff_remaining = min_size - curr_width;
719 widths[curr_column] = min_size;
720 } else {
721 diff_remaining = 0.0;
722 break;
723 }
724 if curr_column == 0 {
725 break;
726 }
727 curr_column -= step_left;
728 curr_column += step_right;
729 }
730 widths[col_idx] = widths[col_idx] + (diff - diff_remaining);
731
732 return diff_remaining;
733 }
734}
735
736pub struct TableWidths<const COLS: usize> {
737 initial: [DefiniteLength; COLS],
738 current: Option<Entity<ColumnWidths<COLS>>>,
739 resizable: [ResizeBehavior; COLS],
740}
741
742impl<const COLS: usize> TableWidths<COLS> {
743 pub fn new(widths: [impl Into<DefiniteLength>; COLS]) -> Self {
744 let widths = widths.map(Into::into);
745
746 TableWidths {
747 initial: widths,
748 current: None,
749 resizable: [ResizeBehavior::None; COLS],
750 }
751 }
752
753 fn lengths(&self, cx: &App) -> [Length; COLS] {
754 self.current
755 .as_ref()
756 .map(|entity| entity.read(cx).visible_widths.map(Length::Definite))
757 .unwrap_or(self.initial.map(Length::Definite))
758 }
759}
760
761/// A table component
762#[derive(RegisterComponent, IntoElement)]
763pub struct Table<const COLS: usize = 3> {
764 striped: bool,
765 width: Option<Length>,
766 headers: Option<[AnyElement; COLS]>,
767 rows: TableContents<COLS>,
768 interaction_state: Option<WeakEntity<TableInteractionState>>,
769 col_widths: Option<TableWidths<COLS>>,
770 map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
771 empty_table_callback: Option<Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>>,
772}
773
774impl<const COLS: usize> Table<COLS> {
775 /// number of headers provided.
776 pub fn new() -> Self {
777 Self {
778 striped: false,
779 width: None,
780 headers: None,
781 rows: TableContents::Vec(Vec::new()),
782 interaction_state: None,
783 map_row: None,
784 empty_table_callback: None,
785 col_widths: None,
786 }
787 }
788
789 /// Enables uniform list rendering.
790 /// The provided function will be passed directly to the `uniform_list` element.
791 /// Therefore, if this method is called, any calls to [`Table::row`] before or after
792 /// this method is called will be ignored.
793 pub fn uniform_list(
794 mut self,
795 id: impl Into<ElementId>,
796 row_count: usize,
797 render_item_fn: impl Fn(Range<usize>, &mut Window, &mut App) -> Vec<[AnyElement; COLS]>
798 + 'static,
799 ) -> Self {
800 self.rows = TableContents::UniformList(UniformListData {
801 element_id: id.into(),
802 row_count: row_count,
803 render_item_fn: Box::new(render_item_fn),
804 });
805 self
806 }
807
808 /// Enables row striping.
809 pub fn striped(mut self) -> Self {
810 self.striped = true;
811 self
812 }
813
814 /// Sets the width of the table.
815 /// Will enable horizontal scrolling if [`Self::interactable`] is also called.
816 pub fn width(mut self, width: impl Into<Length>) -> Self {
817 self.width = Some(width.into());
818 self
819 }
820
821 /// Enables interaction (primarily scrolling) with the table.
822 ///
823 /// Vertical scrolling will be enabled by default if the table is taller than its container.
824 ///
825 /// Horizontal scrolling will only be enabled if [`Self::width`] is also called, otherwise
826 /// the list will always shrink the table columns to fit their contents I.e. If [`Self::uniform_list`]
827 /// is used without a width and with [`Self::interactable`], the [`ListHorizontalSizingBehavior`] will
828 /// be set to [`ListHorizontalSizingBehavior::FitList`].
829 pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
830 self.interaction_state = Some(interaction_state.downgrade());
831 self
832 }
833
834 pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
835 self.headers = Some(headers.map(IntoElement::into_any_element));
836 self
837 }
838
839 pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self {
840 if let Some(rows) = self.rows.rows_mut() {
841 rows.push(items.map(IntoElement::into_any_element));
842 }
843 self
844 }
845
846 pub fn column_widths(mut self, widths: [impl Into<DefiniteLength>; COLS]) -> Self {
847 if self.col_widths.is_none() {
848 self.col_widths = Some(TableWidths::new(widths));
849 }
850 self
851 }
852
853 pub fn resizable_columns(
854 mut self,
855 resizable: [ResizeBehavior; COLS],
856 column_widths: &Entity<ColumnWidths<COLS>>,
857 cx: &mut App,
858 ) -> Self {
859 if let Some(table_widths) = self.col_widths.as_mut() {
860 table_widths.resizable = resizable;
861 let column_widths = table_widths
862 .current
863 .get_or_insert_with(|| column_widths.clone());
864
865 column_widths.update(cx, |widths, _| {
866 if !widths.initialized {
867 widths.initialized = true;
868 widths.widths = table_widths.initial;
869 widths.visible_widths = widths.widths;
870 }
871 })
872 }
873 self
874 }
875
876 pub fn map_row(
877 mut self,
878 callback: impl Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement + 'static,
879 ) -> Self {
880 self.map_row = Some(Rc::new(callback));
881 self
882 }
883
884 /// Provide a callback that is invoked when the table is rendered without any rows
885 pub fn empty_table_callback(
886 mut self,
887 callback: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
888 ) -> Self {
889 self.empty_table_callback = Some(Rc::new(callback));
890 self
891 }
892}
893
894fn base_cell_style(width: Option<Length>) -> Div {
895 div()
896 .px_1p5()
897 .when_some(width, |this, width| this.w(width))
898 .when(width.is_none(), |this| this.flex_1())
899 .justify_start()
900 .whitespace_nowrap()
901 .text_ellipsis()
902 .overflow_hidden()
903}
904
905fn base_cell_style_text(width: Option<Length>, cx: &App) -> Div {
906 base_cell_style(width).text_ui(cx)
907}
908
909pub fn render_row<const COLS: usize>(
910 row_index: usize,
911 items: [impl IntoElement; COLS],
912 table_context: TableRenderContext<COLS>,
913 window: &mut Window,
914 cx: &mut App,
915) -> AnyElement {
916 let is_striped = table_context.striped;
917 let is_last = row_index == table_context.total_row_count - 1;
918 let bg = if row_index % 2 == 1 && is_striped {
919 Some(cx.theme().colors().text.opacity(0.05))
920 } else {
921 None
922 };
923 let column_widths = table_context
924 .column_widths
925 .map_or([None; COLS], |widths| widths.map(Some));
926
927 let mut row = h_flex()
928 .h_full()
929 .id(("table_row", row_index))
930 .w_full()
931 .justify_between()
932 .when_some(bg, |row, bg| row.bg(bg))
933 .when(!is_striped, |row| {
934 row.border_b_1()
935 .border_color(transparent_black())
936 .when(!is_last, |row| row.border_color(cx.theme().colors().border))
937 });
938
939 row = row.children(
940 items
941 .map(IntoElement::into_any_element)
942 .into_iter()
943 .zip(column_widths)
944 .map(|(cell, width)| base_cell_style_text(width, cx).px_1p5().py_1().child(cell)),
945 );
946
947 let row = if let Some(map_row) = table_context.map_row {
948 map_row((row_index, row), window, cx)
949 } else {
950 row.into_any_element()
951 };
952
953 div().h_full().w_full().child(row).into_any_element()
954}
955
956pub fn render_header<const COLS: usize>(
957 headers: [impl IntoElement; COLS],
958 table_context: TableRenderContext<COLS>,
959 columns_widths: Option<(
960 WeakEntity<ColumnWidths<COLS>>,
961 [ResizeBehavior; COLS],
962 [DefiniteLength; COLS],
963 )>,
964 entity_id: Option<EntityId>,
965 cx: &mut App,
966) -> impl IntoElement {
967 let column_widths = table_context
968 .column_widths
969 .map_or([None; COLS], |widths| widths.map(Some));
970
971 let element_id = entity_id
972 .map(|entity| entity.to_string())
973 .unwrap_or_default();
974
975 let shared_element_id: SharedString = format!("table-{}", element_id).into();
976
977 div()
978 .flex()
979 .flex_row()
980 .items_center()
981 .justify_between()
982 .w_full()
983 .p_2()
984 .border_b_1()
985 .border_color(cx.theme().colors().border)
986 .children(headers.into_iter().enumerate().zip(column_widths).map(
987 |((header_idx, h), width)| {
988 base_cell_style_text(width, cx)
989 .child(h)
990 .id(ElementId::NamedInteger(
991 shared_element_id.clone(),
992 header_idx as u64,
993 ))
994 .when_some(
995 columns_widths.as_ref().cloned(),
996 |this, (column_widths, resizables, initial_sizes)| {
997 if resizables[header_idx].is_resizable() {
998 this.on_click(move |event, window, cx| {
999 if event.down.click_count > 1 {
1000 column_widths
1001 .update(cx, |column, _| {
1002 column.on_double_click(
1003 header_idx,
1004 &initial_sizes,
1005 &resizables,
1006 window,
1007 );
1008 })
1009 .ok();
1010 }
1011 })
1012 } else {
1013 this
1014 }
1015 },
1016 )
1017 },
1018 ))
1019}
1020
1021#[derive(Clone)]
1022pub struct TableRenderContext<const COLS: usize> {
1023 pub striped: bool,
1024 pub total_row_count: usize,
1025 pub column_widths: Option<[Length; COLS]>,
1026 pub map_row: Option<Rc<dyn Fn((usize, Stateful<Div>), &mut Window, &mut App) -> AnyElement>>,
1027}
1028
1029impl<const COLS: usize> TableRenderContext<COLS> {
1030 fn new(table: &Table<COLS>, cx: &App) -> Self {
1031 Self {
1032 striped: table.striped,
1033 total_row_count: table.rows.len(),
1034 column_widths: table.col_widths.as_ref().map(|widths| widths.lengths(cx)),
1035 map_row: table.map_row.clone(),
1036 }
1037 }
1038}
1039
1040impl<const COLS: usize> RenderOnce for Table<COLS> {
1041 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1042 let table_context = TableRenderContext::new(&self, cx);
1043 let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
1044 let current_widths = self
1045 .col_widths
1046 .as_ref()
1047 .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable)))
1048 .map(|(curr, resize_behavior)| (curr.downgrade(), resize_behavior));
1049
1050 let current_widths_with_initial_sizes = self
1051 .col_widths
1052 .as_ref()
1053 .and_then(|widths| Some((widths.current.as_ref()?, widths.resizable, widths.initial)))
1054 .map(|(curr, resize_behavior, initial)| (curr.downgrade(), resize_behavior, initial));
1055
1056 let scroll_track_size = px(16.);
1057 let h_scroll_offset = if interaction_state
1058 .as_ref()
1059 .is_some_and(|state| state.read(cx).vertical_scrollbar.show_scrollbar)
1060 {
1061 // magic number
1062 px(3.)
1063 } else {
1064 px(0.)
1065 };
1066
1067 let width = self.width;
1068 let no_rows_rendered = self.rows.is_empty();
1069
1070 let table = div()
1071 .when_some(width, |this, width| this.w(width))
1072 .h_full()
1073 .v_flex()
1074 .when_some(self.headers.take(), |this, headers| {
1075 this.child(render_header(
1076 headers,
1077 table_context.clone(),
1078 current_widths_with_initial_sizes,
1079 interaction_state.as_ref().map(Entity::entity_id),
1080 cx,
1081 ))
1082 })
1083 .when_some(current_widths, {
1084 |this, (widths, resize_behavior)| {
1085 this.on_drag_move::<DraggedColumn>({
1086 let widths = widths.clone();
1087 move |e, window, cx| {
1088 widths
1089 .update(cx, |widths, cx| {
1090 widths.on_drag_move(e, &resize_behavior, window, cx);
1091 })
1092 .ok();
1093 }
1094 })
1095 .on_children_prepainted({
1096 let widths = widths.clone();
1097 move |bounds, _, cx| {
1098 widths
1099 .update(cx, |widths, _| {
1100 // This works because all children x axis bounds are the same
1101 widths.cached_bounds_width =
1102 bounds[0].right() - bounds[0].left();
1103 })
1104 .ok();
1105 }
1106 })
1107 .on_drop::<DraggedColumn>(move |_, _, cx| {
1108 widths
1109 .update(cx, |widths, _| {
1110 widths.widths = widths.visible_widths;
1111 })
1112 .ok();
1113 // Finish the resize operation
1114 })
1115 }
1116 })
1117 .child(
1118 div()
1119 .flex_grow()
1120 .w_full()
1121 .relative()
1122 .overflow_hidden()
1123 .map(|parent| match self.rows {
1124 TableContents::Vec(items) => {
1125 parent.children(items.into_iter().enumerate().map(|(index, row)| {
1126 render_row(index, row, table_context.clone(), window, cx)
1127 }))
1128 }
1129 TableContents::UniformList(uniform_list_data) => parent.child(
1130 uniform_list(
1131 uniform_list_data.element_id,
1132 uniform_list_data.row_count,
1133 {
1134 let render_item_fn = uniform_list_data.render_item_fn;
1135 move |range: Range<usize>, window, cx| {
1136 let elements = render_item_fn(range.clone(), window, cx);
1137 elements
1138 .into_iter()
1139 .zip(range)
1140 .map(|(row, row_index)| {
1141 render_row(
1142 row_index,
1143 row,
1144 table_context.clone(),
1145 window,
1146 cx,
1147 )
1148 })
1149 .collect()
1150 }
1151 },
1152 )
1153 .size_full()
1154 .flex_grow()
1155 .with_sizing_behavior(ListSizingBehavior::Auto)
1156 .with_horizontal_sizing_behavior(if width.is_some() {
1157 ListHorizontalSizingBehavior::Unconstrained
1158 } else {
1159 ListHorizontalSizingBehavior::FitList
1160 })
1161 .when_some(
1162 interaction_state.as_ref(),
1163 |this, state| {
1164 this.track_scroll(
1165 state.read_with(cx, |s, _| s.scroll_handle.clone()),
1166 )
1167 },
1168 ),
1169 ),
1170 })
1171 .when_some(
1172 self.col_widths.as_ref().zip(interaction_state.as_ref()),
1173 |parent, (table_widths, state)| {
1174 parent.child(state.update(cx, |state, cx| {
1175 let resizable_columns = table_widths.resizable;
1176 let column_widths = table_widths.lengths(cx);
1177 let columns = table_widths.current.clone();
1178 let initial_sizes = table_widths.initial;
1179 state.render_resize_handles(
1180 &column_widths,
1181 &resizable_columns,
1182 initial_sizes,
1183 columns,
1184 window,
1185 cx,
1186 )
1187 }))
1188 },
1189 )
1190 .when_some(interaction_state.as_ref(), |this, interaction_state| {
1191 this.map(|this| {
1192 TableInteractionState::render_vertical_scrollbar_track(
1193 interaction_state,
1194 this,
1195 scroll_track_size,
1196 cx,
1197 )
1198 })
1199 .map(|this| {
1200 TableInteractionState::render_vertical_scrollbar(
1201 interaction_state,
1202 this,
1203 cx,
1204 )
1205 })
1206 }),
1207 )
1208 .when_some(
1209 no_rows_rendered
1210 .then_some(self.empty_table_callback)
1211 .flatten(),
1212 |this, callback| {
1213 this.child(
1214 h_flex()
1215 .size_full()
1216 .p_3()
1217 .items_start()
1218 .justify_center()
1219 .child(callback(window, cx)),
1220 )
1221 },
1222 )
1223 .when_some(
1224 width.and(interaction_state.as_ref()),
1225 |this, interaction_state| {
1226 this.map(|this| {
1227 TableInteractionState::render_horizontal_scrollbar_track(
1228 interaction_state,
1229 this,
1230 scroll_track_size,
1231 cx,
1232 )
1233 })
1234 .map(|this| {
1235 TableInteractionState::render_horizontal_scrollbar(
1236 interaction_state,
1237 this,
1238 h_scroll_offset,
1239 cx,
1240 )
1241 })
1242 },
1243 );
1244
1245 if let Some(interaction_state) = interaction_state.as_ref() {
1246 table
1247 .track_focus(&interaction_state.read(cx).focus_handle)
1248 .id(("table", interaction_state.entity_id()))
1249 .on_hover({
1250 let interaction_state = interaction_state.downgrade();
1251 move |hovered, window, cx| {
1252 interaction_state
1253 .update(cx, |interaction_state, cx| {
1254 if *hovered {
1255 interaction_state.horizontal_scrollbar.show(cx);
1256 interaction_state.vertical_scrollbar.show(cx);
1257 cx.notify();
1258 } else if !interaction_state
1259 .focus_handle
1260 .contains_focused(window, cx)
1261 {
1262 interaction_state.hide_scrollbars(window, cx);
1263 }
1264 })
1265 .ok();
1266 }
1267 })
1268 .into_any_element()
1269 } else {
1270 table.into_any_element()
1271 }
1272 }
1273}
1274
1275// computed state related to how to render scrollbars
1276// one per axis
1277// on render we just read this off the keymap editor
1278// we update it when
1279// - settings change
1280// - on focus in, on focus out, on hover, etc.
1281#[derive(Debug)]
1282pub struct ScrollbarProperties {
1283 axis: Axis,
1284 show_scrollbar: bool,
1285 show_track: bool,
1286 auto_hide: bool,
1287 hide_task: Option<Task<()>>,
1288 state: ScrollbarState,
1289}
1290
1291impl ScrollbarProperties {
1292 // Shows the scrollbar and cancels any pending hide task
1293 fn show(&mut self, cx: &mut Context<TableInteractionState>) {
1294 if !self.auto_hide {
1295 return;
1296 }
1297 self.show_scrollbar = true;
1298 self.hide_task.take();
1299 cx.notify();
1300 }
1301
1302 fn hide(&mut self, window: &mut Window, cx: &mut Context<TableInteractionState>) {
1303 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
1304
1305 if !self.auto_hide {
1306 return;
1307 }
1308
1309 let axis = self.axis;
1310 self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| {
1311 cx.background_executor()
1312 .timer(SCROLLBAR_SHOW_INTERVAL)
1313 .await;
1314
1315 if let Some(keymap_editor) = keymap_editor.upgrade() {
1316 keymap_editor
1317 .update(cx, |keymap_editor, cx| {
1318 match axis {
1319 Axis::Vertical => {
1320 keymap_editor.vertical_scrollbar.show_scrollbar = false
1321 }
1322 Axis::Horizontal => {
1323 keymap_editor.horizontal_scrollbar.show_scrollbar = false
1324 }
1325 }
1326 cx.notify();
1327 })
1328 .ok();
1329 }
1330 }));
1331 }
1332}
1333
1334impl Component for Table<3> {
1335 fn scope() -> ComponentScope {
1336 ComponentScope::Layout
1337 }
1338
1339 fn description() -> Option<&'static str> {
1340 Some("A table component for displaying data in rows and columns with optional styling.")
1341 }
1342
1343 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
1344 Some(
1345 v_flex()
1346 .gap_6()
1347 .children(vec![
1348 example_group_with_title(
1349 "Basic Tables",
1350 vec![
1351 single_example(
1352 "Simple Table",
1353 Table::new()
1354 .width(px(400.))
1355 .header(["Name", "Age", "City"])
1356 .row(["Alice", "28", "New York"])
1357 .row(["Bob", "32", "San Francisco"])
1358 .row(["Charlie", "25", "London"])
1359 .into_any_element(),
1360 ),
1361 single_example(
1362 "Two Column Table",
1363 Table::new()
1364 .header(["Category", "Value"])
1365 .width(px(300.))
1366 .row(["Revenue", "$100,000"])
1367 .row(["Expenses", "$75,000"])
1368 .row(["Profit", "$25,000"])
1369 .into_any_element(),
1370 ),
1371 ],
1372 ),
1373 example_group_with_title(
1374 "Styled Tables",
1375 vec![
1376 single_example(
1377 "Default",
1378 Table::new()
1379 .width(px(400.))
1380 .header(["Product", "Price", "Stock"])
1381 .row(["Laptop", "$999", "In Stock"])
1382 .row(["Phone", "$599", "Low Stock"])
1383 .row(["Tablet", "$399", "Out of Stock"])
1384 .into_any_element(),
1385 ),
1386 single_example(
1387 "Striped",
1388 Table::new()
1389 .width(px(400.))
1390 .striped()
1391 .header(["Product", "Price", "Stock"])
1392 .row(["Laptop", "$999", "In Stock"])
1393 .row(["Phone", "$599", "Low Stock"])
1394 .row(["Tablet", "$399", "Out of Stock"])
1395 .row(["Headphones", "$199", "In Stock"])
1396 .into_any_element(),
1397 ),
1398 ],
1399 ),
1400 example_group_with_title(
1401 "Mixed Content Table",
1402 vec![single_example(
1403 "Table with Elements",
1404 Table::new()
1405 .width(px(840.))
1406 .header(["Status", "Name", "Priority", "Deadline", "Action"])
1407 .row([
1408 Indicator::dot().color(Color::Success).into_any_element(),
1409 "Project A".into_any_element(),
1410 "High".into_any_element(),
1411 "2023-12-31".into_any_element(),
1412 Button::new("view_a", "View")
1413 .style(ButtonStyle::Filled)
1414 .full_width()
1415 .into_any_element(),
1416 ])
1417 .row([
1418 Indicator::dot().color(Color::Warning).into_any_element(),
1419 "Project B".into_any_element(),
1420 "Medium".into_any_element(),
1421 "2024-03-15".into_any_element(),
1422 Button::new("view_b", "View")
1423 .style(ButtonStyle::Filled)
1424 .full_width()
1425 .into_any_element(),
1426 ])
1427 .row([
1428 Indicator::dot().color(Color::Error).into_any_element(),
1429 "Project C".into_any_element(),
1430 "Low".into_any_element(),
1431 "2024-06-30".into_any_element(),
1432 Button::new("view_c", "View")
1433 .style(ButtonStyle::Filled)
1434 .full_width()
1435 .into_any_element(),
1436 ])
1437 .into_any_element(),
1438 )],
1439 ),
1440 ])
1441 .into_any_element(),
1442 )
1443 }
1444}
1445
1446#[cfg(test)]
1447mod test {
1448 use super::*;
1449
1450 fn is_almost_eq(a: &[f32], b: &[f32]) -> bool {
1451 a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6)
1452 }
1453
1454 fn cols_to_str<const COLS: usize>(cols: &[f32; COLS], total_size: f32) -> String {
1455 cols.map(|f| "*".repeat(f32::round(f * total_size) as usize))
1456 .join("|")
1457 }
1458
1459 fn parse_resize_behavior<const COLS: usize>(
1460 input: &str,
1461 total_size: f32,
1462 ) -> [ResizeBehavior; COLS] {
1463 let mut resize_behavior = [ResizeBehavior::None; COLS];
1464 let mut max_index = 0;
1465 for (index, col) in input.split('|').enumerate() {
1466 if col.starts_with('X') || col.is_empty() {
1467 resize_behavior[index] = ResizeBehavior::None;
1468 } else if col.starts_with('*') {
1469 resize_behavior[index] = ResizeBehavior::MinSize(col.len() as f32 / total_size);
1470 } else {
1471 panic!("invalid test input: unrecognized resize behavior: {}", col);
1472 }
1473 max_index = index;
1474 }
1475
1476 if max_index + 1 != COLS {
1477 panic!("invalid test input: too many columns");
1478 }
1479 resize_behavior
1480 }
1481
1482 mod reset_column_size {
1483 use super::*;
1484
1485 fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
1486 let mut widths = [f32::NAN; COLS];
1487 let mut column_index = None;
1488 for (index, col) in input.split('|').enumerate() {
1489 widths[index] = col.len() as f32;
1490 if col.starts_with('X') {
1491 column_index = Some(index);
1492 }
1493 }
1494
1495 for w in widths {
1496 assert!(w.is_finite(), "incorrect number of columns");
1497 }
1498 let total = widths.iter().sum::<f32>();
1499 for width in &mut widths {
1500 *width /= total;
1501 }
1502 (widths, total, column_index)
1503 }
1504
1505 #[track_caller]
1506 fn check_reset_size<const COLS: usize>(
1507 initial_sizes: &str,
1508 widths: &str,
1509 expected: &str,
1510 resize_behavior: &str,
1511 ) {
1512 let (initial_sizes, total_1, None) = parse::<COLS>(initial_sizes) else {
1513 panic!("invalid test input: initial sizes should not be marked");
1514 };
1515 let (widths, total_2, Some(column_index)) = parse::<COLS>(widths) else {
1516 panic!("invalid test input: widths should be marked");
1517 };
1518 assert_eq!(
1519 total_1, total_2,
1520 "invalid test input: total width not the same {total_1}, {total_2}"
1521 );
1522 let (expected, total_3, None) = parse::<COLS>(expected) else {
1523 panic!("invalid test input: expected should not be marked: {expected:?}");
1524 };
1525 assert_eq!(
1526 total_2, total_3,
1527 "invalid test input: total width not the same"
1528 );
1529 let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
1530 let result = ColumnWidths::reset_to_initial_size(
1531 column_index,
1532 widths,
1533 initial_sizes,
1534 &resize_behavior,
1535 );
1536 let is_eq = is_almost_eq(&result, &expected);
1537 if !is_eq {
1538 let result_str = cols_to_str(&result, total_1);
1539 let expected_str = cols_to_str(&expected, total_1);
1540 panic!(
1541 "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
1542 );
1543 }
1544 }
1545
1546 macro_rules! check_reset_size {
1547 (columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
1548 check_reset_size::<$cols>($initial, $current, $expected, $resizing);
1549 };
1550 ($name:ident, columns: $cols:expr, starting: $initial:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
1551 #[test]
1552 fn $name() {
1553 check_reset_size::<$cols>($initial, $current, $expected, $resizing);
1554 }
1555 };
1556 }
1557
1558 check_reset_size!(
1559 basic_right,
1560 columns: 5,
1561 starting: "**|**|**|**|**",
1562 snapshot: "**|**|X|***|**",
1563 expected: "**|**|**|**|**",
1564 minimums: "X|*|*|*|*",
1565 );
1566
1567 check_reset_size!(
1568 basic_left,
1569 columns: 5,
1570 starting: "**|**|**|**|**",
1571 snapshot: "**|**|***|X|**",
1572 expected: "**|**|**|**|**",
1573 minimums: "X|*|*|*|**",
1574 );
1575
1576 check_reset_size!(
1577 squashed_left_reset_col2,
1578 columns: 6,
1579 starting: "*|***|**|**|****|*",
1580 snapshot: "*|*|X|*|*|********",
1581 expected: "*|*|**|*|*|*******",
1582 minimums: "X|*|*|*|*|*",
1583 );
1584
1585 check_reset_size!(
1586 grow_cascading_right,
1587 columns: 6,
1588 starting: "*|***|****|**|***|*",
1589 snapshot: "*|***|X|**|**|*****",
1590 expected: "*|***|****|*|*|****",
1591 minimums: "X|*|*|*|*|*",
1592 );
1593
1594 check_reset_size!(
1595 squashed_right_reset_col4,
1596 columns: 6,
1597 starting: "*|***|**|**|****|*",
1598 snapshot: "*|********|*|*|X|*",
1599 expected: "*|*****|*|*|****|*",
1600 minimums: "X|*|*|*|*|*",
1601 );
1602
1603 check_reset_size!(
1604 reset_col6_right,
1605 columns: 6,
1606 starting: "*|***|**|***|***|**",
1607 snapshot: "*|***|**|***|**|XXX",
1608 expected: "*|***|**|***|***|**",
1609 minimums: "X|*|*|*|*|*",
1610 );
1611
1612 check_reset_size!(
1613 reset_col6_left,
1614 columns: 6,
1615 starting: "*|***|**|***|***|**",
1616 snapshot: "*|***|**|***|****|X",
1617 expected: "*|***|**|***|***|**",
1618 minimums: "X|*|*|*|*|*",
1619 );
1620
1621 check_reset_size!(
1622 last_column_grow_cascading,
1623 columns: 6,
1624 starting: "*|***|**|**|**|***",
1625 snapshot: "*|*******|*|**|*|X",
1626 expected: "*|******|*|*|*|***",
1627 minimums: "X|*|*|*|*|*",
1628 );
1629
1630 check_reset_size!(
1631 goes_left_when_left_has_extreme_diff,
1632 columns: 6,
1633 starting: "*|***|****|**|**|***",
1634 snapshot: "*|********|X|*|**|**",
1635 expected: "*|*****|****|*|**|**",
1636 minimums: "X|*|*|*|*|*",
1637 );
1638
1639 check_reset_size!(
1640 basic_shrink_right,
1641 columns: 6,
1642 starting: "**|**|**|**|**|**",
1643 snapshot: "**|**|XXX|*|**|**",
1644 expected: "**|**|**|**|**|**",
1645 minimums: "X|*|*|*|*|*",
1646 );
1647
1648 check_reset_size!(
1649 shrink_should_go_left,
1650 columns: 6,
1651 starting: "*|***|**|*|*|*",
1652 snapshot: "*|*|XXX|**|*|*",
1653 expected: "*|**|**|**|*|*",
1654 minimums: "X|*|*|*|*|*",
1655 );
1656
1657 check_reset_size!(
1658 shrink_should_go_right,
1659 columns: 6,
1660 starting: "*|***|**|**|**|*",
1661 snapshot: "*|****|XXX|*|*|*",
1662 expected: "*|****|**|**|*|*",
1663 minimums: "X|*|*|*|*|*",
1664 );
1665 }
1666
1667 mod drag_handle {
1668 use super::*;
1669
1670 fn parse<const COLS: usize>(input: &str) -> ([f32; COLS], f32, Option<usize>) {
1671 let mut widths = [f32::NAN; COLS];
1672 let column_index = input.replace("*", "").find("I");
1673 for (index, col) in input.replace("I", "|").split('|').enumerate() {
1674 widths[index] = col.len() as f32;
1675 }
1676
1677 for w in widths {
1678 assert!(w.is_finite(), "incorrect number of columns");
1679 }
1680 let total = widths.iter().sum::<f32>();
1681 for width in &mut widths {
1682 *width /= total;
1683 }
1684 (widths, total, column_index)
1685 }
1686
1687 #[track_caller]
1688 fn check<const COLS: usize>(
1689 distance: i32,
1690 widths: &str,
1691 expected: &str,
1692 resize_behavior: &str,
1693 ) {
1694 let (mut widths, total_1, Some(column_index)) = parse::<COLS>(widths) else {
1695 panic!("invalid test input: widths should be marked");
1696 };
1697 let (expected, total_2, None) = parse::<COLS>(expected) else {
1698 panic!("invalid test input: expected should not be marked: {expected:?}");
1699 };
1700 assert_eq!(
1701 total_1, total_2,
1702 "invalid test input: total width not the same"
1703 );
1704 let resize_behavior = parse_resize_behavior::<COLS>(resize_behavior, total_1);
1705
1706 let distance = distance as f32 / total_1;
1707
1708 let result = ColumnWidths::drag_column_handle(
1709 distance,
1710 column_index,
1711 &mut widths,
1712 &resize_behavior,
1713 );
1714
1715 let is_eq = is_almost_eq(&widths, &expected);
1716 if !is_eq {
1717 let result_str = cols_to_str(&widths, total_1);
1718 let expected_str = cols_to_str(&expected, total_1);
1719 panic!(
1720 "resize failed\ncomputed: {result_str}\nexpected: {expected_str}\n\ncomputed values: {result:?}\nexpected values: {expected:?}\n:minimum widths: {resize_behavior:?}"
1721 );
1722 }
1723 }
1724
1725 macro_rules! check {
1726 (columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, resizing: $resizing:expr $(,)?) => {
1727 check!($cols, $dist, $snapshot, $expected, $resizing);
1728 };
1729 ($name:ident, columns: $cols:expr, distance: $dist:expr, snapshot: $current:expr, expected: $expected:expr, minimums: $resizing:expr $(,)?) => {
1730 #[test]
1731 fn $name() {
1732 check::<$cols>($dist, $current, $expected, $resizing);
1733 }
1734 };
1735 }
1736
1737 check!(
1738 basic_right_drag,
1739 columns: 3,
1740 distance: 1,
1741 snapshot: "**|**I**",
1742 expected: "**|***|*",
1743 minimums: "X|*|*",
1744 );
1745
1746 check!(
1747 drag_left_against_mins,
1748 columns: 5,
1749 distance: -1,
1750 snapshot: "*|*|*|*I*******",
1751 expected: "*|*|*|*|*******",
1752 minimums: "X|*|*|*|*",
1753 );
1754
1755 check!(
1756 drag_left,
1757 columns: 5,
1758 distance: -2,
1759 snapshot: "*|*|*|*****I***",
1760 expected: "*|*|*|***|*****",
1761 minimums: "X|*|*|*|*",
1762 );
1763 }
1764}