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