1use std::{ops::Range, time::Duration};
2
3use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide};
4use gpui::{
5 AppContext, Axis, Context, Entity, FocusHandle, FontWeight, Length,
6 ListHorizontalSizingBehavior, ListSizingBehavior, MouseButton, Task, UniformListScrollHandle,
7 WeakEntity, uniform_list,
8};
9use settings::Settings as _;
10use ui::{
11 ActiveTheme as _, AnyElement, App, Button, ButtonCommon as _, ButtonStyle, Color, Component,
12 ComponentScope, Div, ElementId, FixedWidth as _, FluentBuilder as _, Indicator,
13 InteractiveElement as _, IntoElement, ParentElement, Pixels, RegisterComponent, RenderOnce,
14 Scrollbar, ScrollbarState, StatefulInteractiveElement as _, Styled, StyledExt as _,
15 StyledTypography, Window, div, example_group_with_title, h_flex, px, single_example, v_flex,
16};
17
18struct UniformListData {
19 render_item_fn: Box<dyn Fn(Range<usize>, &mut Window, &mut App) -> Vec<AnyElement>>,
20 element_id: ElementId,
21 row_count: usize,
22}
23
24enum TableContents<const COLS: usize> {
25 Vec(Vec<[AnyElement; COLS]>),
26 UniformList(UniformListData),
27}
28
29impl<const COLS: usize> TableContents<COLS> {
30 fn rows_mut(&mut self) -> Option<&mut Vec<[AnyElement; COLS]>> {
31 match self {
32 TableContents::Vec(rows) => Some(rows),
33 TableContents::UniformList(_) => None,
34 }
35 }
36
37 fn len(&self) -> usize {
38 match self {
39 TableContents::Vec(rows) => rows.len(),
40 TableContents::UniformList(data) => data.row_count,
41 }
42 }
43}
44
45pub struct TableInteractionState {
46 pub focus_handle: FocusHandle,
47 pub scroll_handle: UniformListScrollHandle,
48 pub horizontal_scrollbar: ScrollbarProperties,
49 pub vertical_scrollbar: ScrollbarProperties,
50}
51
52impl TableInteractionState {
53 pub fn new(window: &mut Window, cx: &mut App) -> Entity<Self> {
54 cx.new(|cx| {
55 let focus_handle = cx.focus_handle();
56
57 cx.on_focus_out(&focus_handle, window, |this: &mut Self, _, window, cx| {
58 this.hide_scrollbars(window, cx);
59 })
60 .detach();
61
62 let scroll_handle = UniformListScrollHandle::new();
63 let vertical_scrollbar = ScrollbarProperties {
64 axis: Axis::Vertical,
65 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
66 show_scrollbar: false,
67 show_track: false,
68 auto_hide: false,
69 hide_task: None,
70 };
71
72 let horizontal_scrollbar = ScrollbarProperties {
73 axis: Axis::Horizontal,
74 state: ScrollbarState::new(scroll_handle.clone()).parent_entity(&cx.entity()),
75 show_scrollbar: false,
76 show_track: false,
77 auto_hide: false,
78 hide_task: None,
79 };
80
81 let mut this = Self {
82 focus_handle,
83 scroll_handle,
84 horizontal_scrollbar,
85 vertical_scrollbar,
86 };
87
88 this.update_scrollbar_visibility(cx);
89 this
90 })
91 }
92
93 fn update_scrollbar_visibility(&mut self, cx: &mut Context<Self>) {
94 let show_setting = EditorSettings::get_global(cx).scrollbar.show;
95
96 let scroll_handle = self.scroll_handle.0.borrow();
97
98 let autohide = |show: ShowScrollbar, cx: &mut Context<Self>| match show {
99 ShowScrollbar::Auto => true,
100 ShowScrollbar::System => cx
101 .try_global::<ScrollbarAutoHide>()
102 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
103 ShowScrollbar::Always => false,
104 ShowScrollbar::Never => false,
105 };
106
107 let longest_item_width = scroll_handle.last_item_size.and_then(|size| {
108 (size.contents.width > size.item.width).then_some(size.contents.width)
109 });
110
111 // is there an item long enough that we should show a horizontal scrollbar?
112 let item_wider_than_container = if let Some(longest_item_width) = longest_item_width {
113 longest_item_width > px(scroll_handle.base_handle.bounds().size.width.0)
114 } else {
115 true
116 };
117
118 let show_scrollbar = match show_setting {
119 ShowScrollbar::Auto | ShowScrollbar::System | ShowScrollbar::Always => true,
120 ShowScrollbar::Never => false,
121 };
122 let show_vertical = show_scrollbar;
123
124 let show_horizontal = item_wider_than_container && show_scrollbar;
125
126 let show_horizontal_track =
127 show_horizontal && matches!(show_setting, ShowScrollbar::Always);
128
129 // TODO: we probably should hide the scroll track when the list doesn't need to scroll
130 let show_vertical_track = show_vertical && matches!(show_setting, ShowScrollbar::Always);
131
132 self.vertical_scrollbar = ScrollbarProperties {
133 axis: self.vertical_scrollbar.axis,
134 state: self.vertical_scrollbar.state.clone(),
135 show_scrollbar: show_vertical,
136 show_track: show_vertical_track,
137 auto_hide: autohide(show_setting, cx),
138 hide_task: None,
139 };
140
141 self.horizontal_scrollbar = ScrollbarProperties {
142 axis: self.horizontal_scrollbar.axis,
143 state: self.horizontal_scrollbar.state.clone(),
144 show_scrollbar: show_horizontal,
145 show_track: show_horizontal_track,
146 auto_hide: autohide(show_setting, cx),
147 hide_task: None,
148 };
149
150 cx.notify();
151 }
152
153 fn hide_scrollbars(&mut self, window: &mut Window, cx: &mut Context<Self>) {
154 self.horizontal_scrollbar.hide(window, cx);
155 self.vertical_scrollbar.hide(window, cx);
156 }
157
158 // fn listener(this: Entity<Self>, fn: F) ->
159
160 pub fn listener<E: ?Sized>(
161 this: &Entity<Self>,
162 f: impl Fn(&mut Self, &E, &mut Window, &mut Context<Self>) + 'static,
163 ) -> impl Fn(&E, &mut Window, &mut App) + 'static {
164 let view = this.downgrade();
165 move |e: &E, window: &mut Window, cx: &mut App| {
166 view.update(cx, |view, cx| f(view, e, window, cx)).ok();
167 }
168 }
169
170 fn render_vertical_scrollbar(this: &Entity<Self>, cx: &mut App) -> impl IntoElement {
171 div()
172 .id("keymap-editor-vertical-scroll")
173 .occlude()
174 .flex_none()
175 .h_full()
176 .cursor_default()
177 .absolute()
178 .right_0()
179 .top_0()
180 .bottom_0()
181 .w(px(12.))
182 .on_mouse_move(Self::listener(this, |_, _, _, cx| {
183 cx.notify();
184 cx.stop_propagation()
185 }))
186 .on_hover(|_, _, cx| {
187 cx.stop_propagation();
188 })
189 .on_mouse_up(
190 MouseButton::Left,
191 Self::listener(this, |this, _, window, cx| {
192 if !this.vertical_scrollbar.state.is_dragging()
193 && !this.focus_handle.contains_focused(window, cx)
194 {
195 this.vertical_scrollbar.hide(window, cx);
196 cx.notify();
197 }
198
199 cx.stop_propagation();
200 }),
201 )
202 .on_any_mouse_down(|_, _, cx| {
203 cx.stop_propagation();
204 })
205 .on_scroll_wheel(Self::listener(&this, |_, _, _, cx| {
206 cx.notify();
207 }))
208 .children(Scrollbar::vertical(
209 this.read(cx).vertical_scrollbar.state.clone(),
210 ))
211 }
212
213 /// Renders the horizontal scrollbar.
214 ///
215 /// The right offset is used to determine how far to the right the
216 /// scrollbar should extend to, useful for ensuring it doesn't collide
217 /// with the vertical scrollbar when visible.
218 fn render_horizontal_scrollbar(
219 this: &Entity<Self>,
220 right_offset: Pixels,
221 cx: &mut App,
222 ) -> impl IntoElement {
223 div()
224 .id("keymap-editor-horizontal-scroll")
225 .occlude()
226 .flex_none()
227 .w_full()
228 .cursor_default()
229 .absolute()
230 .bottom_neg_px()
231 .left_0()
232 .right_0()
233 .pr(right_offset)
234 .on_mouse_move(Self::listener(this, |_, _, _, cx| {
235 cx.notify();
236 cx.stop_propagation()
237 }))
238 .on_hover(|_, _, cx| {
239 cx.stop_propagation();
240 })
241 .on_any_mouse_down(|_, _, cx| {
242 cx.stop_propagation();
243 })
244 .on_mouse_up(
245 MouseButton::Left,
246 Self::listener(this, |this, _, window, cx| {
247 if !this.horizontal_scrollbar.state.is_dragging()
248 && !this.focus_handle.contains_focused(window, cx)
249 {
250 this.horizontal_scrollbar.hide(window, cx);
251 cx.notify();
252 }
253
254 cx.stop_propagation();
255 }),
256 )
257 .on_scroll_wheel(Self::listener(this, |_, _, _, cx| {
258 cx.notify();
259 }))
260 .children(Scrollbar::horizontal(
261 // percentage as f32..end_offset as f32,
262 this.read(cx).horizontal_scrollbar.state.clone(),
263 ))
264 }
265}
266
267/// A table component
268#[derive(RegisterComponent, IntoElement)]
269pub struct Table<const COLS: usize = 3> {
270 striped: bool,
271 width: Length,
272 headers: Option<[AnyElement; COLS]>,
273 rows: TableContents<COLS>,
274 interaction_state: Option<WeakEntity<TableInteractionState>>,
275}
276
277impl<const COLS: usize> Table<COLS> {
278 /// number of headers provided.
279 pub fn new() -> Self {
280 Table {
281 striped: false,
282 width: Length::Auto,
283 headers: None,
284 rows: TableContents::Vec(Vec::new()),
285 interaction_state: None,
286 }
287 }
288
289 /// Enables uniform list rendering.
290 /// The provided function will be passed directly to the `uniform_list` element.
291 /// Therefore, if this method is called, any calls to [`Table::row`] before or after
292 /// this method is called will be ignored.
293 pub fn uniform_list(
294 mut self,
295 id: impl Into<ElementId>,
296 row_count: usize,
297 render_item_fn: impl Fn(Range<usize>, &mut Window, &mut App) -> Vec<AnyElement> + 'static,
298 ) -> Self {
299 self.rows = TableContents::UniformList(UniformListData {
300 element_id: id.into(),
301 row_count: row_count,
302 render_item_fn: Box::new(render_item_fn),
303 });
304 self
305 }
306
307 /// Enables row striping.
308 pub fn striped(mut self) -> Self {
309 self.striped = true;
310 self
311 }
312
313 /// Sets the width of the table.
314 pub fn width(mut self, width: impl Into<Length>) -> Self {
315 self.width = width.into();
316 self
317 }
318
319 pub fn interactable(mut self, interaction_state: &Entity<TableInteractionState>) -> Self {
320 self.interaction_state = Some(interaction_state.downgrade());
321 self
322 }
323
324 pub fn header(mut self, headers: [impl IntoElement; COLS]) -> Self {
325 self.headers = Some(headers.map(IntoElement::into_any_element));
326 self
327 }
328
329 pub fn row(mut self, items: [impl IntoElement; COLS]) -> Self {
330 if let Some(rows) = self.rows.rows_mut() {
331 rows.push(items.map(IntoElement::into_any_element));
332 }
333 self
334 }
335
336 pub fn render_row(&self, items: [impl IntoElement; COLS], cx: &mut App) -> AnyElement {
337 return render_row(0, items, self.rows.len(), self.striped, cx);
338 }
339
340 pub fn render_header(
341 &self,
342 headers: [impl IntoElement; COLS],
343 cx: &mut App,
344 ) -> impl IntoElement {
345 render_header(headers, cx)
346 }
347}
348
349fn base_cell_style(cx: &App) -> Div {
350 div()
351 .px_1p5()
352 .flex_1()
353 .justify_start()
354 .text_ui(cx)
355 .whitespace_nowrap()
356 .text_ellipsis()
357 .overflow_hidden()
358}
359
360pub fn render_row<const COLS: usize>(
361 row_index: usize,
362 items: [impl IntoElement; COLS],
363 row_count: usize,
364 striped: bool,
365 cx: &App,
366) -> AnyElement {
367 let is_last = row_index == row_count - 1;
368 let bg = if row_index % 2 == 1 && striped {
369 Some(cx.theme().colors().text.opacity(0.05))
370 } else {
371 None
372 };
373 div()
374 .w_full()
375 .flex()
376 .flex_row()
377 .items_center()
378 .justify_between()
379 .px_1p5()
380 .py_1()
381 .when_some(bg, |row, bg| row.bg(bg))
382 .when(!is_last, |row| {
383 row.border_b_1().border_color(cx.theme().colors().border)
384 })
385 .children(
386 items
387 .map(IntoElement::into_any_element)
388 .map(|cell| base_cell_style(cx).child(cell)),
389 )
390 .into_any_element()
391}
392
393pub fn render_header<const COLS: usize>(
394 headers: [impl IntoElement; COLS],
395 cx: &mut App,
396) -> impl IntoElement {
397 div()
398 .flex()
399 .flex_row()
400 .items_center()
401 .justify_between()
402 .w_full()
403 .p_2()
404 .border_b_1()
405 .border_color(cx.theme().colors().border)
406 .children(headers.into_iter().map(|h| {
407 base_cell_style(cx)
408 .font_weight(FontWeight::SEMIBOLD)
409 .child(h)
410 }))
411}
412
413impl<const COLS: usize> RenderOnce for Table<COLS> {
414 fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
415 // match self.ro
416 let row_count = self.rows.len();
417 let interaction_state = self.interaction_state.and_then(|state| state.upgrade());
418
419 let scroll_track_size = px(16.);
420 let h_scroll_offset = if interaction_state
421 .as_ref()
422 .is_some_and(|state| state.read(cx).vertical_scrollbar.show_scrollbar)
423 {
424 // magic number
425 px(3.)
426 } else {
427 px(0.)
428 };
429
430 div()
431 .id("todo! how to have id")
432 .w(self.width)
433 .overflow_hidden()
434 .when_some(interaction_state.as_ref(), |this, interaction_state| {
435 this.track_focus(&interaction_state.read(cx).focus_handle)
436 .on_hover({
437 let interaction_state = interaction_state.downgrade();
438 move |hovered, window, cx| {
439 interaction_state
440 .update(cx, |interaction_state, cx| {
441 if *hovered {
442 interaction_state.horizontal_scrollbar.show(cx);
443 interaction_state.vertical_scrollbar.show(cx);
444 cx.notify();
445 } else if !interaction_state
446 .focus_handle
447 .contains_focused(window, cx)
448 {
449 interaction_state.hide_scrollbars(window, cx);
450 }
451 })
452 .ok(); // todo! handle error?
453 }
454 })
455 })
456 .when_some(self.headers.take(), |this, headers| {
457 this.child(render_header(headers, cx))
458 })
459 .map(|parent| match self.rows {
460 TableContents::Vec(items) => parent.children(
461 items
462 .into_iter()
463 .enumerate()
464 .map(|(index, row)| render_row(index, row, row_count, self.striped, cx)),
465 ),
466 TableContents::UniformList(uniform_list_data) => parent
467 .h_full()
468 .v_flex()
469 .child(
470 div()
471 .flex_grow()
472 .w_full()
473 .relative()
474 .overflow_hidden()
475 .child(
476 uniform_list(
477 uniform_list_data.element_id,
478 uniform_list_data.row_count,
479 uniform_list_data.render_item_fn,
480 )
481 .size_full()
482 .flex_grow()
483 .with_sizing_behavior(ListSizingBehavior::Auto)
484 .with_horizontal_sizing_behavior(
485 ListHorizontalSizingBehavior::Unconstrained,
486 )
487 .when_some(
488 interaction_state.as_ref(),
489 |this, state| {
490 this.track_scroll(
491 state.read_with(cx, |s, _| s.scroll_handle.clone()),
492 )
493 },
494 ),
495 )
496 .when(
497 interaction_state.as_ref().is_some_and(|state| {
498 state.read(cx).vertical_scrollbar.show_track
499 }),
500 |this| {
501 this.child(
502 v_flex()
503 .h_full()
504 .flex_none()
505 .w(scroll_track_size)
506 .bg(cx.theme().colors().background)
507 .child(
508 div()
509 .size_full()
510 .flex_1()
511 .border_l_1()
512 .border_color(cx.theme().colors().border),
513 ),
514 )
515 },
516 )
517 .when_some(
518 interaction_state.as_ref().filter(|state| {
519 state.read(cx).vertical_scrollbar.show_scrollbar
520 }),
521 |this, interaction_state| {
522 this.child(TableInteractionState::render_vertical_scrollbar(
523 interaction_state,
524 cx,
525 ))
526 },
527 ),
528 )
529 .when(
530 interaction_state
531 .as_ref()
532 .is_some_and(|state| state.read(cx).horizontal_scrollbar.show_track),
533 |this| {
534 this.child(
535 h_flex()
536 .w_full()
537 .h(scroll_track_size)
538 .flex_none()
539 .relative()
540 .child(
541 div()
542 .w_full()
543 .flex_1()
544 // for some reason the horizontal scrollbar is 1px
545 // taller than the vertical scrollbar??
546 .h(scroll_track_size - px(1.))
547 .bg(cx.theme().colors().background)
548 .border_t_1()
549 .border_color(cx.theme().colors().border),
550 )
551 .when(
552 interaction_state.as_ref().is_some_and(|state| {
553 state.read(cx).vertical_scrollbar.show_track
554 }),
555 |this| {
556 this.child(
557 div()
558 .flex_none()
559 // -1px prevents a missing pixel between the two container borders
560 .w(scroll_track_size - px(1.))
561 .h_full(),
562 )
563 .child(
564 // HACK: Fill the missing 1px 🥲
565 div()
566 .absolute()
567 .right(scroll_track_size - px(1.))
568 .bottom(scroll_track_size - px(1.))
569 .size_px()
570 .bg(cx.theme().colors().border),
571 )
572 },
573 ),
574 )
575 },
576 )
577 .when_some(
578 interaction_state
579 .as_ref()
580 .filter(|state| state.read(cx).horizontal_scrollbar.show_scrollbar),
581 |this, interaction_state| {
582 this.child(TableInteractionState::render_horizontal_scrollbar(
583 interaction_state,
584 h_scroll_offset,
585 cx,
586 ))
587 },
588 ),
589 })
590 }
591}
592
593// computed state related to how to render scrollbars
594// one per axis
595// on render we just read this off the keymap editor
596// we update it when
597// - settings change
598// - on focus in, on focus out, on hover, etc.
599#[derive(Debug)]
600pub struct ScrollbarProperties {
601 axis: Axis,
602 show_scrollbar: bool,
603 show_track: bool,
604 auto_hide: bool,
605 hide_task: Option<Task<()>>,
606 state: ScrollbarState,
607}
608
609impl ScrollbarProperties {
610 // Shows the scrollbar and cancels any pending hide task
611 fn show(&mut self, cx: &mut Context<TableInteractionState>) {
612 if !self.auto_hide {
613 return;
614 }
615 self.show_scrollbar = true;
616 self.hide_task.take();
617 cx.notify();
618 }
619
620 fn hide(&mut self, window: &mut Window, cx: &mut Context<TableInteractionState>) {
621 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
622
623 if !self.auto_hide {
624 return;
625 }
626
627 let axis = self.axis;
628 self.hide_task = Some(cx.spawn_in(window, async move |keymap_editor, cx| {
629 cx.background_executor()
630 .timer(SCROLLBAR_SHOW_INTERVAL)
631 .await;
632
633 if let Some(keymap_editor) = keymap_editor.upgrade() {
634 keymap_editor
635 .update(cx, |keymap_editor, cx| {
636 match axis {
637 Axis::Vertical => {
638 keymap_editor.vertical_scrollbar.show_scrollbar = false
639 }
640 Axis::Horizontal => {
641 keymap_editor.horizontal_scrollbar.show_scrollbar = false
642 }
643 }
644 cx.notify();
645 })
646 .ok();
647 }
648 }));
649 }
650}
651
652impl Component for Table<3> {
653 fn scope() -> ComponentScope {
654 ComponentScope::Layout
655 }
656
657 fn description() -> Option<&'static str> {
658 Some("A table component for displaying data in rows and columns with optional styling.")
659 }
660
661 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
662 Some(
663 v_flex()
664 .gap_6()
665 .children(vec![
666 example_group_with_title(
667 "Basic Tables",
668 vec![
669 single_example(
670 "Simple Table",
671 Table::new()
672 .width(px(400.))
673 .header(["Name", "Age", "City"])
674 .row(["Alice", "28", "New York"])
675 .row(["Bob", "32", "San Francisco"])
676 .row(["Charlie", "25", "London"])
677 .into_any_element(),
678 ),
679 single_example(
680 "Two Column Table",
681 Table::new()
682 .header(["Category", "Value"])
683 .width(px(300.))
684 .row(["Revenue", "$100,000"])
685 .row(["Expenses", "$75,000"])
686 .row(["Profit", "$25,000"])
687 .into_any_element(),
688 ),
689 ],
690 ),
691 example_group_with_title(
692 "Styled Tables",
693 vec![
694 single_example(
695 "Default",
696 Table::new()
697 .width(px(400.))
698 .header(["Product", "Price", "Stock"])
699 .row(["Laptop", "$999", "In Stock"])
700 .row(["Phone", "$599", "Low Stock"])
701 .row(["Tablet", "$399", "Out of Stock"])
702 .into_any_element(),
703 ),
704 single_example(
705 "Striped",
706 Table::new()
707 .width(px(400.))
708 .striped()
709 .header(["Product", "Price", "Stock"])
710 .row(["Laptop", "$999", "In Stock"])
711 .row(["Phone", "$599", "Low Stock"])
712 .row(["Tablet", "$399", "Out of Stock"])
713 .row(["Headphones", "$199", "In Stock"])
714 .into_any_element(),
715 ),
716 ],
717 ),
718 example_group_with_title(
719 "Mixed Content Table",
720 vec![single_example(
721 "Table with Elements",
722 Table::new()
723 .width(px(840.))
724 .header(["Status", "Name", "Priority", "Deadline", "Action"])
725 .row([
726 Indicator::dot().color(Color::Success).into_any_element(),
727 "Project A".into_any_element(),
728 "High".into_any_element(),
729 "2023-12-31".into_any_element(),
730 Button::new("view_a", "View")
731 .style(ButtonStyle::Filled)
732 .full_width()
733 .into_any_element(),
734 ])
735 .row([
736 Indicator::dot().color(Color::Warning).into_any_element(),
737 "Project B".into_any_element(),
738 "Medium".into_any_element(),
739 "2024-03-15".into_any_element(),
740 Button::new("view_b", "View")
741 .style(ButtonStyle::Filled)
742 .full_width()
743 .into_any_element(),
744 ])
745 .row([
746 Indicator::dot().color(Color::Error).into_any_element(),
747 "Project C".into_any_element(),
748 "Low".into_any_element(),
749 "2024-06-30".into_any_element(),
750 Button::new("view_c", "View")
751 .style(ButtonStyle::Filled)
752 .full_width()
753 .into_any_element(),
754 ])
755 .into_any_element(),
756 )],
757 ),
758 ])
759 .into_any_element(),
760 )
761 }
762}