Skip to main content

liora_components/
virtualized_table.rs

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