1use std::ops::Range;
57use std::rc::Rc;
58
59use gpui::{
60 AnyElement, App, InteractiveElement, IntoElement, ListSizingBehavior, ParentElement,
61 RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
62 prelude::FluentBuilder, px, uniform_list,
63};
64use gpui_kit_semantics::{NodeSpec, Role, Semantic};
65use gpui_kit_theme::{
66 ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, TextTone, Theme, TypeScale,
67};
68
69use crate::data::viewport::scroll_handle;
70use crate::foundation::{Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text};
71
72type SortHandler = Rc<dyn Fn(SharedString, SortDirection, &mut Window, &mut App)>;
73type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
74type RenderRow = Rc<dyn Fn(usize, &mut Window, &mut App) -> Row>;
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum SortDirection {
80 Ascending,
81 Descending,
82}
83
84impl SortDirection {
85 pub fn reversed(self) -> Self {
87 match self {
88 Self::Ascending => Self::Descending,
89 Self::Descending => Self::Ascending,
90 }
91 }
92
93 pub fn as_str(self) -> &'static str {
94 match self {
95 Self::Ascending => "ascending",
96 Self::Descending => "descending",
97 }
98 }
99}
100
101#[derive(Debug, Clone, Copy, PartialEq)]
103pub enum ColumnWidth {
104 Fixed(f32),
105 Flex(f32),
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
110pub enum Align {
111 #[default]
112 Start,
113 Center,
114 End,
115}
116
117#[derive(Debug, Clone)]
120pub struct Column {
121 key: SharedString,
122 header: SharedString,
123 width: ColumnWidth,
124 align: Align,
125 sortable: bool,
126}
127
128impl Column {
129 pub fn new(key: impl Into<SharedString>, header: impl Into<SharedString>) -> Self {
130 Self {
131 key: key.into(),
132 header: header.into(),
133 width: ColumnWidth::Flex(1.0),
134 align: Align::default(),
135 sortable: false,
136 }
137 }
138
139 pub fn width(mut self, width: ColumnWidth) -> Self {
140 self.width = width;
141 self
142 }
143
144 pub fn fixed(self, width: f32) -> Self {
145 self.width(ColumnWidth::Fixed(width))
146 }
147
148 pub fn flex(self, share: f32) -> Self {
149 self.width(ColumnWidth::Flex(share))
150 }
151
152 pub fn align(mut self, align: Align) -> Self {
153 self.align = align;
154 self
155 }
156
157 pub fn sortable(mut self, sortable: bool) -> Self {
158 self.sortable = sortable;
159 self
160 }
161}
162
163pub struct Cell {
165 pub(crate) content: CellContent,
166 pub(crate) text: Option<SharedString>,
167 pub(crate) published: bool,
168}
169
170pub(crate) enum CellContent {
171 Element(AnyElement),
172 Plain(SharedString),
173}
174
175impl CellContent {
176 pub(crate) fn into_element(self, theme: &Theme) -> AnyElement {
177 match self {
178 Self::Element(element) => element,
179 Self::Plain(value) => text(theme, TypeScale::Body, value).into_any_element(),
180 }
181 }
182}
183
184impl std::fmt::Debug for Cell {
185 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 formatter
187 .debug_struct("Cell")
188 .field("text", &self.text)
189 .field("published", &self.published)
190 .finish()
191 }
192}
193
194impl Cell {
195 pub fn new(content: impl IntoElement) -> Self {
196 Self {
197 content: CellContent::Element(content.into_any_element()),
198 text: None,
199 published: false,
200 }
201 }
202
203 pub fn text(mut self, text: impl Into<SharedString>) -> Self {
205 self.text = Some(text.into());
206 self
207 }
208
209 pub fn published(mut self, published: bool) -> Self {
211 self.published = published;
212 self
213 }
214}
215
216impl From<SharedString> for Cell {
217 fn from(value: SharedString) -> Self {
218 Self {
219 content: CellContent::Plain(value.clone()),
220 text: Some(value),
221 published: false,
222 }
223 }
224}
225
226impl From<&'static str> for Cell {
227 fn from(value: &'static str) -> Self {
228 SharedString::from(value).into()
229 }
230}
231
232impl From<String> for Cell {
233 fn from(value: String) -> Self {
234 SharedString::from(value).into()
235 }
236}
237
238pub struct Row {
240 id: SharedString,
241 text: Option<SharedString>,
242 disabled: bool,
243 cells: Vec<(SharedString, Cell)>,
244}
245
246impl std::fmt::Debug for Row {
247 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 formatter
249 .debug_struct("Row")
250 .field("id", &self.id)
251 .field("cells", &self.cells.len())
252 .field("disabled", &self.disabled)
253 .finish()
254 }
255}
256
257impl Row {
258 pub fn new(id: impl Into<SharedString>) -> Self {
259 Self {
260 id: id.into(),
261 text: None,
262 disabled: false,
263 cells: Vec::new(),
264 }
265 }
266
267 pub fn cell(mut self, key: impl Into<SharedString>, cell: impl Into<Cell>) -> Self {
268 self.cells.push((key.into(), cell.into()));
269 self
270 }
271
272 pub fn text(mut self, text: impl Into<SharedString>) -> Self {
273 self.text = Some(text.into());
274 self
275 }
276
277 pub fn disabled(mut self, disabled: bool) -> Self {
278 self.disabled = disabled;
279 self
280 }
281
282 fn take(&mut self, key: &SharedString) -> Option<Cell> {
283 let position = self.cells.iter().position(|(name, _)| name == key)?;
284 Some(self.cells.remove(position).1)
285 }
286}
287
288#[derive(Clone)]
290struct RowSource {
291 count: usize,
292 render_row: RenderRow,
293}
294
295#[derive(Clone)]
301struct Body {
302 ident: Ident,
303 columns: Vec<Column>,
304 selected: Option<SharedString>,
305 disabled: bool,
306 on_select: Option<SelectHandler>,
307}
308
309#[derive(IntoElement)]
311pub struct Table {
312 ident: Ident,
313 columns: Vec<Column>,
314 rows: Vec<Row>,
315 source: Option<RowSource>,
316 sort: Option<(SharedString, SortDirection)>,
317 selected: Option<SharedString>,
318 row_height: Option<f32>,
319 visible_rows: Option<usize>,
320 size: ControlSize,
321 disabled: bool,
322 on_sort: Option<SortHandler>,
323 on_select: Option<SelectHandler>,
324}
325
326impl std::fmt::Debug for Table {
327 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
328 formatter
329 .debug_struct("Table")
330 .field("ident", &self.ident)
331 .field("columns", &self.columns.len())
332 .field("rows", &self.count())
333 .field("sort", &self.sort)
334 .field("selected", &self.selected)
335 .field("disabled", &self.disabled)
336 .finish()
337 }
338}
339
340impl Table {
341 pub fn new(ident: impl Into<Ident>) -> Self {
342 Self {
343 ident: ident.into(),
344 columns: Vec::new(),
345 rows: Vec::new(),
346 source: None,
347 sort: None,
348 selected: None,
349 row_height: None,
350 visible_rows: None,
351 size: ControlSize::Md,
352 disabled: false,
353 on_sort: None,
354 on_select: None,
355 }
356 }
357
358 pub fn column(mut self, column: Column) -> Self {
359 self.columns.push(column);
360 self
361 }
362
363 pub fn columns(mut self, columns: impl IntoIterator<Item = Column>) -> Self {
364 self.columns.extend(columns);
365 self
366 }
367
368 pub fn rows(mut self, rows: impl IntoIterator<Item = Row>) -> Self {
369 self.rows.extend(rows);
370 self
371 }
372
373 pub fn rows_from(
381 mut self,
382 count: usize,
383 render_row: impl Fn(usize, &mut Window, &mut App) -> Row + 'static,
384 ) -> Self {
385 self.source = Some(RowSource {
386 count,
387 render_row: Rc::new(render_row),
388 });
389 self
390 }
391
392 fn count(&self) -> usize {
393 self.source
394 .as_ref()
395 .map_or(self.rows.len(), |source| source.count)
396 }
397
398 pub fn sort(mut self, sort: Option<(SharedString, SortDirection)>) -> Self {
400 self.sort = sort;
401 self
402 }
403
404 pub fn sorted_by(self, key: impl Into<SharedString>, direction: SortDirection) -> Self {
405 self.sort(Some((key.into(), direction)))
406 }
407
408 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
409 self.selected = Some(id.into());
410 self
411 }
412
413 pub fn row_height(mut self, height: f32) -> Self {
414 self.row_height = Some(height);
415 self
416 }
417
418 pub fn visible_rows(mut self, rows: usize) -> Self {
421 self.visible_rows = Some(rows);
422 self
423 }
424
425 pub fn on_sort(
426 mut self,
427 handler: impl Fn(SharedString, SortDirection, &mut Window, &mut App) + 'static,
428 ) -> Self {
429 self.on_sort = Some(Rc::new(handler));
430 self
431 }
432
433 pub fn on_select(
434 mut self,
435 handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
436 ) -> Self {
437 self.on_select = Some(Rc::new(handler));
438 self
439 }
440
441 fn header(&self, theme: &Theme, height: f32, cx: &mut App) -> AnyElement {
442 let mut header = div()
443 .row()
444 .w_full()
445 .h(px(height))
446 .px(px(theme.space(Space::Sm)))
447 .gap(px(theme.space(Space::Sm)))
448 .surface(theme, Surface::Raised);
449
450 for column in &self.columns {
451 let ident = self.ident.child("header").child(column.key.as_ref());
452 let hover_group = ident.child("hover").semantic_id();
453 let direction = self
454 .sort
455 .as_ref()
456 .filter(|(key, _)| key == &column.key)
457 .map(|(_, direction)| *direction);
458 let actionable = column.sortable && !self.disabled && self.on_sort.is_some();
459
460 let content = div()
461 .row()
462 .gap(px(theme.space(Space::Xs)))
463 .child(
464 text(theme, TypeScale::Label, column.header.clone())
465 .text_tone(theme, TextTone::Muted)
466 .when(actionable, |element| {
467 element.group_hover(hover_group.clone(), |style| {
468 style.text_color(theme.colors.text)
469 })
470 }),
471 )
472 .children(direction.map(|direction| {
473 text(
474 theme,
475 TypeScale::Label,
476 SharedString::from(match direction {
477 SortDirection::Ascending => "↑",
478 SortDirection::Descending => "↓",
479 }),
480 )
481 }));
482
483 let mut cell = cell_frame(div().id(ident.element_id()), column, theme)
484 .group(hover_group)
485 .when(actionable, |element| {
486 element
487 .cursor_pointer()
488 .tab_index(0)
489 .pressable(cx)
490 .focus_ring(theme)
491 })
492 .child(content);
493
494 if let (true, Some(handler)) = (actionable, self.on_sort.clone()) {
495 let key = column.key.clone();
496 let next = direction.map_or(SortDirection::Ascending, SortDirection::reversed);
497 let click = Rc::clone(&handler);
498 let clicked = key.clone();
499 cell = cell
500 .on_click(move |_, window, cx| click(clicked.clone(), next, window, cx))
501 .on_key_down(move |event, window, cx| {
502 if matches!(event.keystroke.key.as_str(), "enter" | "space") {
503 handler(key.clone(), next, window, cx);
504 cx.stop_propagation();
505 }
506 });
507 }
508
509 let spec = if column.sortable {
510 NodeSpec::new(ident.semantic_id(), Role::Button)
511 .parent(self.ident.semantic_id())
512 .text(column.header.clone())
513 .disabled(!actionable)
514 .value(direction.map_or("unsorted", SortDirection::as_str))
517 } else {
518 NodeSpec::new(ident.semantic_id(), Role::Cell)
519 .parent(self.ident.semantic_id())
520 .text(column.header.clone())
521 };
522
523 header = header.child(cell.semantic_in(cx, spec));
524 }
525
526 header.into_any_element()
527 }
528
529 fn body(&self) -> Body {
530 Body {
531 ident: self.ident.clone(),
532 columns: self.columns.clone(),
533 selected: self.selected.clone(),
534 disabled: self.disabled,
535 on_select: self.on_select.clone(),
536 }
537 }
538}
539
540impl Body {
541 fn row_element(&self, theme: &Theme, height: f32, mut row: Row, cx: &mut App) -> AnyElement {
542 let ident = self.ident.child(row.id.as_ref());
543 let selected = self.selected.as_ref() == Some(&row.id);
544 let actionable = !row.disabled && !self.disabled && self.on_select.is_some();
545
546 let mut element = div()
547 .id(ident.element_id())
548 .row()
549 .w_full()
550 .h(px(height))
551 .px(px(theme.space(Space::Sm)))
552 .gap(px(theme.space(Space::Sm)))
553 .when(selected, |element| element.bg(theme.colors.selected))
554 .when(row.disabled, |element| {
555 element.opacity(theme.opacity.disabled)
556 })
557 .when(actionable, |element| {
558 element
559 .cursor_pointer()
560 .tab_index(0)
561 .pressable(cx)
562 .when(!selected, |element| {
563 element.hover(|style| style.bg(theme.colors.hover.opacity(0.3)))
564 })
565 .focus_ring(theme)
566 });
567
568 for column in &self.columns {
569 let cell = row.take(&column.key);
570 let published = cell.as_ref().is_some_and(|cell| cell.published);
571 let text = cell.as_ref().and_then(|cell| cell.text.clone());
572 let frame = cell_frame(div(), column, theme)
573 .overflow_hidden()
574 .children(cell.map(|cell| cell.content.into_element(theme)));
575
576 let frame = if published {
577 let cell_ident = ident.child(column.key.as_ref());
578 let mut spec =
579 NodeSpec::new(cell_ident.semantic_id(), Role::Cell).parent(ident.semantic_id());
580 if let Some(text) = text {
581 spec = spec.text(text);
582 }
583 frame.semantic_in(cx, spec).into_any_element()
584 } else {
585 frame.into_any_element()
586 };
587 element = element.child(frame);
588 }
589
590 if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
591 let id = row.id.clone();
592 element = element.on_click(move |_, window, cx| handler(id.clone(), window, cx));
593 }
594
595 let mut spec = NodeSpec::new(ident.semantic_id(), Role::Row)
596 .parent(self.ident.semantic_id())
597 .selected(selected)
598 .disabled(row.disabled || self.disabled);
599 if let Some(text) = row.text.clone() {
600 spec = spec.text(text);
601 }
602 element.semantic_in(cx, spec).into_any_element()
603 }
604}
605
606impl Disableable for Table {
607 fn disabled(mut self, disabled: bool) -> Self {
608 self.disabled = disabled;
609 self
610 }
611}
612
613impl Sizable for Table {
614 fn control_size(mut self, size: ControlSize) -> Self {
615 self.size = size;
616 self
617 }
618}
619
620impl RenderOnce for Table {
621 fn render(mut self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
622 let theme = cx.theme().clone();
623 let metrics = theme.control.get(self.size);
624 let height = self.row_height.unwrap_or(metrics.height);
625 let count = self.count();
626 let header = self.header(&theme, height, cx);
627 let context = self.body();
628
629 let body = match self.source.take() {
630 Some(source) => {
631 let ident = self.ident.child("body");
632 let scroll = scroll_handle(&ident, cx);
633 let theme = theme.clone();
634 uniform_list(
635 ident.element_id(),
636 count,
637 move |range: Range<usize>, window, cx| {
638 range
639 .map(|index| {
640 let row = (source.render_row)(index, window, cx);
641 context.row_element(&theme, height, row, cx)
642 })
643 .collect::<Vec<_>>()
644 },
645 )
646 .track_scroll(&scroll)
647 .w_full()
648 .with_sizing_behavior(if self.visible_rows.is_some() {
649 ListSizingBehavior::Auto
650 } else {
651 ListSizingBehavior::Infer
652 })
653 .when_some(self.visible_rows, |element, rows| {
657 element.h(px(height * count.min(rows) as f32))
658 })
659 .into_any_element()
660 }
661 None => {
662 let rows = std::mem::take(&mut self.rows);
663 div()
664 .id(self.ident.child("body").element_id())
665 .column()
666 .w_full()
667 .overflow_y_scroll()
668 .when_some(self.visible_rows, |element, rows| {
669 element.max_h(px(height * rows as f32))
670 })
671 .children(
672 rows.into_iter()
673 .map(|row| context.row_element(&theme, height, row, cx))
674 .collect::<Vec<_>>(),
675 )
676 .into_any_element()
677 }
678 };
679
680 div()
681 .id(self.ident.element_id())
682 .column()
683 .w_full()
684 .radius(&theme, Radius::Card)
685 .frame(&theme, Surface::Panel, Elevation::Raised)
686 .overflow_hidden()
687 .child(header)
688 .child(body)
689 .semantic_in(
690 cx,
691 NodeSpec::new(self.ident.semantic_id(), Role::Table).value(count.to_string()),
692 )
693 }
694}
695
696fn cell_frame<E: Styled>(element: E, column: &Column, theme: &Theme) -> E {
697 let element = match column.width {
698 ColumnWidth::Fixed(width) => element.w(px(width)).flex_none(),
699 ColumnWidth::Flex(share) => element
703 .flex_grow(share)
704 .flex_shrink(1.0)
705 .flex_basis(px(0.0)),
706 };
707 let element = element.row().h_full().gap(px(theme.space(Space::Xs)));
708 match column.align {
709 Align::Start => element.justify_start(),
710 Align::Center => element.justify_center(),
711 Align::End => element.justify_end(),
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn a_second_click_on_a_sorted_header_reverses_it() {
721 assert_eq!(
722 SortDirection::Ascending.reversed(),
723 SortDirection::Descending
724 );
725 assert_eq!(
726 SortDirection::Descending.reversed(),
727 SortDirection::Ascending
728 );
729 }
730
731 #[test]
732 fn a_cell_takes_its_name_from_the_string_it_renders() {
733 let cell: Cell = "Indexing".into();
734 assert_eq!(cell.text.as_deref(), Some("Indexing"));
735 assert!(!cell.published);
736 }
737}