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}