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
16pub 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}