Skip to main content

liora_components/
table.rs

1use crate::gpui_compat::element_id;
2use gpui::{
3    AnyElement, App, Component, IntoElement, MouseButton, Pixels, RenderOnce, SharedString, Window,
4    div, prelude::*, px,
5};
6use liora_core::Config;
7use liora_icons::Icon;
8use liora_icons_lucide::IconName;
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum TableAlign {
13    #[default]
14    Left,
15    Center,
16    Right,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TableSortOrder {
21    Ascending,
22    Descending,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct TableSortState {
27    pub key: SharedString,
28    pub order: Option<TableSortOrder>,
29}
30
31pub struct TableColumn {
32    pub key: SharedString,
33    pub label: SharedString,
34    pub header: Option<AnyElement>,
35    pub width: Option<Pixels>,
36    pub min_width: Pixels,
37    pub align: TableAlign,
38    pub sortable: bool,
39}
40
41pub struct TableCell {
42    pub key: SharedString,
43    pub value: AnyElement,
44}
45
46pub struct TableRow {
47    cells: Vec<TableCell>,
48}
49
50pub struct Table {
51    id: SharedString,
52    columns: Vec<TableColumn>,
53    rows: Vec<TableRow>,
54    border: bool,
55    stripe: bool,
56    loading: bool,
57    fixed_header: bool,
58    height: Option<Pixels>,
59    empty_text: SharedString,
60    sort_key: Option<SharedString>,
61    sort_order: Option<TableSortOrder>,
62    on_sort_change: Option<Arc<dyn Fn(TableSortState, &mut Window, &mut App) + 'static>>,
63}
64
65impl TableColumn {
66    pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
67        Self {
68            key: key.into(),
69            label: label.into(),
70            header: None,
71            width: None,
72            min_width: px(120.0),
73            align: TableAlign::Left,
74            sortable: false,
75        }
76    }
77
78    pub fn header(mut self, header: impl IntoElement) -> Self {
79        self.header = Some(header.into_any_element());
80        self
81    }
82
83    pub fn width(mut self, width: impl Into<Pixels>) -> Self {
84        self.width = Some(width.into());
85        self
86    }
87
88    pub fn width_sm(self) -> Self {
89        self.width(px(120.0))
90    }
91
92    pub fn min_width(mut self, width: impl Into<Pixels>) -> Self {
93        self.min_width = width.into();
94        self
95    }
96
97    pub fn min_width_lg(self) -> Self {
98        self.min_width(px(260.0))
99    }
100
101    pub fn align(mut self, align: TableAlign) -> Self {
102        self.align = align;
103        self
104    }
105
106    pub fn sortable(mut self) -> Self {
107        self.sortable = true;
108        self
109    }
110}
111
112impl TableRow {
113    pub fn new() -> Self {
114        Self { cells: vec![] }
115    }
116
117    pub fn cell(mut self, key: impl Into<SharedString>, value: impl IntoElement) -> Self {
118        self.cells.push(TableCell {
119            key: key.into(),
120            value: value.into_any_element(),
121        });
122        self
123    }
124
125    fn take_cell(&mut self, key: &SharedString) -> Option<AnyElement> {
126        self.cells
127            .iter()
128            .position(|cell| &cell.key == key)
129            .map(|index| self.cells.remove(index).value)
130    }
131}
132
133impl Table {
134    pub fn new(columns: Vec<TableColumn>) -> Self {
135        Self {
136            id: liora_core::unique_id("table"),
137            columns,
138            rows: vec![],
139            border: false,
140            stripe: false,
141            loading: false,
142            fixed_header: false,
143            height: None,
144            empty_text: "暂无数据".into(),
145            sort_key: None,
146            sort_order: None,
147            on_sort_change: None,
148        }
149    }
150
151    pub fn id(mut self, id: impl Into<SharedString>) -> Self {
152        self.id = id.into();
153        self
154    }
155
156    pub fn row(mut self, row: TableRow) -> Self {
157        self.rows.push(row);
158        self
159    }
160
161    pub fn rows(mut self, rows: impl IntoIterator<Item = TableRow>) -> Self {
162        self.rows.extend(rows);
163        self
164    }
165
166    pub fn border(mut self, border: bool) -> Self {
167        self.border = border;
168        self
169    }
170
171    pub fn stripe(mut self, stripe: bool) -> Self {
172        self.stripe = stripe;
173        self
174    }
175
176    pub fn loading(mut self, loading: bool) -> Self {
177        self.loading = loading;
178        self
179    }
180
181    pub fn fixed_header(mut self, fixed_header: bool) -> Self {
182        self.fixed_header = fixed_header;
183        self
184    }
185
186    pub fn height(mut self, height: impl Into<Pixels>) -> Self {
187        self.height = Some(height.into());
188        self
189    }
190
191    pub fn height_md(self) -> Self {
192        self.height(px(260.0))
193    }
194
195    pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
196        self.empty_text = text.into();
197        self
198    }
199
200    pub fn sort(mut self, key: impl Into<SharedString>, order: Option<TableSortOrder>) -> Self {
201        self.sort_key = Some(key.into());
202        self.sort_order = order;
203        self
204    }
205
206    pub fn on_sort_change(
207        mut self,
208        f: impl Fn(TableSortState, &mut Window, &mut App) + 'static,
209    ) -> Self {
210        self.on_sort_change = Some(Arc::new(f));
211        self
212    }
213}
214
215impl RenderOnce for Table {
216    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
217        let theme = cx.global::<Config>().theme.clone();
218        let mut columns = self.columns;
219        let has_rows = !self.rows.is_empty();
220        let border = self.border;
221        let stripe = self.stripe;
222        let fixed_header = self.fixed_header || self.height.is_some();
223        let height = self.height;
224        let body_id = format!("{}-body", self.id);
225        let sort_key = self.sort_key;
226        let sort_order = self.sort_order;
227        let on_sort_change = self.on_sort_change;
228
229        let header = div()
230            .flex()
231            .flex_row()
232            .w_full()
233            .bg(theme.neutral.hover)
234            .border_b_1()
235            .border_color(theme.neutral.border)
236            .children(columns.iter_mut().enumerate().map(|(index, column)| {
237                let active_order = if sort_key.as_ref() == Some(&column.key) {
238                    sort_order
239                } else {
240                    None
241                };
242                table_header_cell(
243                    column,
244                    border,
245                    index,
246                    &theme,
247                    active_order,
248                    on_sort_change.clone(),
249                    &self.id,
250                )
251            }));
252
253        let body = if has_rows {
254            div()
255                .flex()
256                .flex_col()
257                .w_full()
258                .children(
259                    self.rows
260                        .into_iter()
261                        .enumerate()
262                        .map(|(row_index, mut row)| {
263                            let striped = stripe && row_index % 2 == 1;
264                            div()
265                                .flex()
266                                .flex_row()
267                                .w_full()
268                                .bg(if striped {
269                                    theme.neutral.hover.opacity(0.45)
270                                } else {
271                                    theme.neutral.card
272                                })
273                                .hover(|s| s.bg(theme.primary.light_9))
274                                .when(row_index > 0, |s| {
275                                    s.border_t_1().border_color(theme.neutral.divider)
276                                })
277                                .children(columns.iter().enumerate().map(move |(index, column)| {
278                                    let value = row
279                                        .take_cell(&column.key)
280                                        .unwrap_or_else(|| div().into_any_element());
281                                    table_cell_shell(column, border, index)
282                                        .min_h(px(48.0))
283                                        .py_3()
284                                        .child(
285                                            div()
286                                                .text_size(px(theme.font_size.sm))
287                                                .text_color(theme.neutral.text_1)
288                                                .child(value),
289                                        )
290                                }))
291                        }),
292                )
293                .into_any_element()
294        } else {
295            div()
296                .w_full()
297                .min_h(px(180.0))
298                .flex()
299                .items_center()
300                .justify_center()
301                .child(
302                    div()
303                        .flex()
304                        .flex_col()
305                        .items_center()
306                        .gap_2()
307                        .child(
308                            Icon::new(IconName::PackageOpen)
309                                .size(px(40.0))
310                                .color(theme.neutral.text_3),
311                        )
312                        .child(
313                            div()
314                                .text_sm()
315                                .text_color(theme.neutral.text_3)
316                                .child(self.empty_text),
317                        ),
318                )
319                .into_any_element()
320        };
321
322        let body = div()
323            .w_full()
324            .id(element_id(body_id))
325            .when(fixed_header, |s| s.overflow_y_scroll())
326            .when_some(height, |s, h| s.max_h(h))
327            .child(body);
328
329        div()
330            .relative()
331            .w_full()
332            .overflow_hidden()
333            .rounded(px(theme.radius.md))
334            .border_1()
335            .border_color(theme.neutral.border)
336            .bg(theme.neutral.card)
337            .child(header)
338            .child(body)
339            .when(self.loading, |s| {
340                s.child(
341                    div()
342                        .absolute()
343                        .top_0()
344                        .left_0()
345                        .size_full()
346                        .bg(theme.neutral.card.opacity(0.72))
347                        .flex()
348                        .items_center()
349                        .justify_center()
350                        .child(
351                            div()
352                                .flex()
353                                .flex_col()
354                                .items_center()
355                                .gap_2()
356                                .child(
357                                    Icon::new(IconName::LoaderCircle)
358                                        .size(px(32.0))
359                                        .color(theme.primary.base),
360                                )
361                                .child(
362                                    div()
363                                        .text_sm()
364                                        .text_color(theme.primary.base)
365                                        .child("加载中"),
366                                ),
367                        ),
368                )
369            })
370    }
371}
372
373impl IntoElement for Table {
374    type Element = Component<Self>;
375
376    fn into_element(self) -> Self::Element {
377        Component::new(self)
378    }
379}
380
381fn table_cell_shell(column: &TableColumn, border: bool, index: usize) -> gpui::Div {
382    let mut cell = div()
383        .flex()
384        .items_center()
385        .px_4()
386        .min_w(column.min_width)
387        .when(border && index > 0, |s| s.border_l_1());
388
389    cell = match column.width {
390        Some(width) => cell.w(width).flex_shrink_0(),
391        None => cell.flex_1(),
392    };
393
394    match column.align {
395        TableAlign::Left => cell.justify_start(),
396        TableAlign::Center => cell.justify_center(),
397        TableAlign::Right => cell.justify_end(),
398    }
399}
400
401fn table_header_cell(
402    column: &mut TableColumn,
403    border: bool,
404    index: usize,
405    theme: &liora_theme::Theme,
406    active_order: Option<TableSortOrder>,
407    on_sort_change: Option<Arc<dyn Fn(TableSortState, &mut Window, &mut App) + 'static>>,
408    table_id: &SharedString,
409) -> AnyElement {
410    let header_content = column.header.take().unwrap_or_else(|| {
411        div()
412            .text_size(px(theme.font_size.sm))
413            .font_weight(gpui::FontWeight::BOLD)
414            .text_color(theme.neutral.text_2)
415            .child(column.label.clone())
416            .into_any_element()
417    });
418
419    let icon = match active_order {
420        Some(TableSortOrder::Ascending) => IconName::ArrowUp,
421        Some(TableSortOrder::Descending) => IconName::ArrowDown,
422        None => IconName::ArrowUpDown,
423    };
424    let icon_color = if active_order.is_some() {
425        theme.primary.base
426    } else {
427        theme.neutral.text_3
428    };
429
430    let content = div()
431        .flex()
432        .items_center()
433        .gap_1()
434        .child(header_content)
435        .when(column.sortable, |s| {
436            s.child(Icon::new(icon).size(px(14.0)).color(icon_color))
437        });
438
439    let cell = table_cell_shell(column, border, index)
440        .py_3()
441        .child(content);
442
443    if !column.sortable {
444        return cell.into_any_element();
445    }
446
447    let column_key = column.key.clone();
448    let next_order = match active_order {
449        None => Some(TableSortOrder::Ascending),
450        Some(TableSortOrder::Ascending) => Some(TableSortOrder::Descending),
451        Some(TableSortOrder::Descending) => None,
452    };
453    let callback = on_sort_change.clone();
454
455    cell.id(element_id(format!("{}-sort-{}", table_id, column.key)))
456        .cursor_pointer()
457        .hover(|s| s.bg(theme.neutral.pressed))
458        .on_mouse_up(MouseButton::Left, move |_, window, cx| {
459            if let Some(callback) = &callback {
460                callback(
461                    TableSortState {
462                        key: column_key.clone(),
463                        order: next_order,
464                    },
465                    window,
466                    cx,
467                );
468            }
469        })
470        .into_any_element()
471}