Skip to main content

vizia_core/views/
table.rs

1use std::{ops::Deref, rc::Rc, sync::Arc};
2
3use crate::prelude::*;
4
5/// Sort direction for a table column.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum TableSortDirection {
8    /// No sort direction.
9    None,
10    /// Sort in ascending order.
11    Ascending,
12    /// Sort in descending order.
13    Descending,
14}
15
16impl_res_simple!(TableSortDirection);
17
18/// Controls how sortable columns cycle through sort directions when clicked.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TableSortCycle {
21    /// Cycles between ascending and descending.
22    BiState,
23    /// Cycles ascending -> descending -> unsorted.
24    TriState,
25}
26
27impl_res_simple!(TableSortCycle);
28
29pub(super) fn sort_direction_for_column<K: PartialEq>(
30    sort_state: Option<&TableSortState<K>>,
31    column_key: &K,
32) -> TableSortDirection {
33    match sort_state {
34        Some(state) if &state.key == column_key => state.direction,
35        _ => TableSortDirection::None,
36    }
37}
38
39pub(super) fn next_sort_direction(
40    sort_cycle: TableSortCycle,
41    current_direction: TableSortDirection,
42) -> TableSortDirection {
43    match (sort_cycle, current_direction) {
44        (TableSortCycle::BiState, TableSortDirection::Ascending) => TableSortDirection::Descending,
45        (TableSortCycle::BiState, _) => TableSortDirection::Ascending,
46        (TableSortCycle::TriState, TableSortDirection::None) => TableSortDirection::Ascending,
47        (TableSortCycle::TriState, TableSortDirection::Ascending) => TableSortDirection::Descending,
48        (TableSortCycle::TriState, TableSortDirection::Descending) => TableSortDirection::None,
49    }
50}
51
52type TableHeaderContent<S> = dyn Fn(&mut Context, Memo<TableSortDirection>) -> Handle<S>;
53type TableCellContent<T> = dyn Fn(&mut Context, Memo<T>);
54
55impl<T: PartialEq + 'static, S: View, K: Clone + PartialEq + Send + Sync + 'static>
56    Res<Vec<TableColumn<T, S, K>>> for Vec<TableColumn<T, S, K>>
57{
58    fn get_value(&self, _: &impl DataContext) -> Vec<TableColumn<T, S, K>> {
59        self.clone()
60    }
61}
62
63/// Reusable helpers for building table header content.
64#[derive(Clone)]
65pub struct TableHeader;
66
67impl TableHeader {
68    pub fn new(
69        cx: &mut Context,
70        title: impl Into<String>,
71        sort_direction: Memo<TableSortDirection>,
72    ) -> Handle<'_, TableHeader> {
73        Self.build(cx, move |cx| {
74            let title = title.into();
75            Label::new(cx, title).class("table-header-title").width(Stretch(1.0)).min_width(Auto);
76            let sort_indicator = Memo::new(move |_| match sort_direction.get() {
77                TableSortDirection::Ascending => "^".to_string(),
78                TableSortDirection::Descending => "v".to_string(),
79                TableSortDirection::None => "ยท".to_string(),
80            });
81
82            Label::new(cx, sort_indicator).class("table-sort-indicator").text_wrap(false);
83        })
84        .layout_type(LayoutType::Row)
85        .width(Stretch(1.0))
86        .min_width(Auto)
87    }
88}
89
90impl View for TableHeader {
91    fn element(&self) -> Option<&'static str> {
92        Some("table-header")
93    }
94}
95
96/// Externally controlled sort state for a table.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct TableSortState<K = String> {
99    /// Stable column key.
100    pub key: K,
101    /// Current sort direction.
102    pub direction: TableSortDirection,
103}
104
105/// Describes a table column.
106pub struct TableColumn<T: PartialEq + 'static, S: View, K = String>
107where
108    K: Clone + PartialEq + Send + Sync + 'static,
109{
110    /// Stable identity used to preserve state across reactive column updates.
111    pub key: K,
112    /// Initial width in logical pixels.
113    pub width: Signal<f32>,
114    /// Minimum width in logical pixels when resized.
115    pub min_width: Signal<f32>,
116    /// Whether this column can trigger sorting.
117    pub sortable: Signal<bool>,
118    /// Whether this column can be resized when table resizing is enabled.
119    pub resizable: Signal<bool>,
120    /// Whether this column is hidden from layout and rendering.
121    pub hidden: Signal<bool>,
122    /// Custom cell content builder.
123    pub cell_content: Rc<TableCellContent<T>>,
124    /// Custom header content builder.
125    pub header_content: Rc<TableHeaderContent<S>>,
126}
127
128impl<T: PartialEq + 'static, S: View, K: Clone + PartialEq + Send + Sync + 'static> Clone
129    for TableColumn<T, S, K>
130{
131    fn clone(&self) -> Self {
132        Self {
133            key: self.key.clone(),
134            width: self.width,
135            min_width: self.min_width,
136            sortable: self.sortable,
137            resizable: self.resizable,
138            hidden: self.hidden,
139            cell_content: self.cell_content.clone(),
140            header_content: self.header_content.clone(),
141        }
142    }
143}
144
145impl<T: PartialEq + 'static, S: View, K: Clone + PartialEq + Send + Sync + 'static>
146    TableColumn<T, S, K>
147{
148    /// Creates a new table column from explicit header and cell builders.
149    ///
150    /// Use this when you need full control over header and cell rendering.
151    ///
152    /// ```ignore
153    /// TableColumn::new(
154    ///     "status",
155    ///     |cx, sort_direction| TableHeader::new(cx, "Status", sort_direction),
156    ///     |cx, row| {
157    ///         let status = row.map(|row: &RowData| row.status.clone());
158    ///         Label::new(cx, status).class("table-cell-text");
159    ///     },
160    /// )
161    /// .resizable(true)
162    /// .sortable(true);
163    /// ```
164    pub fn new(
165        key: impl Into<K>,
166        header_content: impl Fn(&mut Context, Memo<TableSortDirection>) -> Handle<S> + 'static,
167        cell_content: impl Fn(&mut Context, Memo<T>) + 'static,
168    ) -> Self {
169        Self {
170            key: key.into(),
171            width: Signal::new(180.0),
172            min_width: Signal::new(80.0),
173            sortable: Signal::new(true),
174            resizable: Signal::new(false),
175            hidden: Signal::new(false),
176            cell_content: Rc::new(cell_content),
177            header_content: Rc::new(header_content),
178        }
179    }
180
181    /// Sets the initial width.
182    pub fn width(self, width: f32) -> Self {
183        self.width.set(width.max(self.min_width.get_untracked()));
184        self
185    }
186
187    /// Sets the minimum width.
188    pub fn min_width(self, min_width: f32) -> Self {
189        self.min_width.set(min_width);
190        self.width.set(self.width.get_untracked().max(min_width));
191        self
192    }
193
194    /// Sets whether this column can trigger sorting.
195    pub fn sortable(self, sortable: bool) -> Self {
196        self.sortable.set(sortable);
197        self
198    }
199
200    /// Sets whether this column can be resized when table resizing is enabled.
201    pub fn resizable(self, resizable: bool) -> Self {
202        self.resizable.set(resizable);
203        self
204    }
205
206    /// Sets whether this column is hidden from layout and rendering.
207    pub fn hidden(self, hidden: bool) -> Self {
208        self.hidden.set(hidden);
209        self
210    }
211
212    /// Binds hidden state to an external resource.
213    pub fn hidden_res<U: Into<bool> + Clone + 'static>(
214        self,
215        cx: &mut Context,
216        hidden: impl Res<U> + 'static,
217    ) -> Self {
218        let hidden_signal = self.hidden;
219        hidden.set_or_bind(cx, move |cx, res| {
220            hidden_signal.set(res.get_value(cx).into());
221        });
222        self
223    }
224}
225
226/// A table-like view backed by [`List`] for variable row heights.
227///
228/// This implementation prioritizes flexible row layout over viewport virtualization.
229/// For large datasets, prefer filtering, pagination, or incremental loading at the model layer.
230pub struct Table<T, V, Id, K = String>
231where
232    V: Deref<Target = [T]> + Clone + 'static,
233    T: PartialEq + Clone + 'static,
234    Id: PartialEq + Clone + 'static,
235    K: Clone + PartialEq + Send + Sync + 'static,
236{
237    rows: Signal<V>,
238    row_id: Rc<dyn Fn(&T) -> Id>,
239    sort_state: Signal<Option<TableSortState<K>>>,
240    sort_cycle: Signal<TableSortCycle>,
241    resizable_columns: Signal<bool>,
242    selectable: Signal<Selectable>,
243    selection_follows_focus: Signal<bool>,
244    selected_row_ids: Signal<Vec<Id>>,
245    on_sort: Option<Arc<dyn Fn(&mut EventContext, K, TableSortDirection) + Send + Sync>>,
246    on_row_select: Option<Box<dyn Fn(&mut EventContext, Id)>>,
247}
248
249enum TableEvent<K> {
250    RequestSort(K, TableSortDirection),
251    SelectRow(usize),
252}
253
254impl<T, V, Id, K> Table<T, V, Id, K>
255where
256    V: Deref<Target = [T]> + Clone + 'static,
257    T: PartialEq + Clone + 'static,
258    Id: PartialEq + Clone + 'static,
259    K: Clone + PartialEq + Send + Sync + 'static,
260{
261    /// Creates a new table view.
262    ///
263    /// Sorting is emit-only: header presses call `on_sort`, while sorted data should be provided
264    /// by the caller (for example via `Memo<Vec<T>>`).
265    ///
266    /// ```ignore
267    /// Table::new(cx, sorted_rows, columns, |row: &RowData| row.id)
268    ///     .sort_state(sort_state)
269    ///     .resizable_columns(true)
270    ///     .selectable(Selectable::Single)
271    ///     .selected_row_ids(selected_ids)
272    ///     .on_sort(|cx, column, direction| {
273    ///         cx.emit(AppEvent::SetSort(column, direction));
274    ///     })
275    ///     .on_row_select(|cx, id| {
276    ///         cx.emit(AppEvent::SelectRow(id));
277    ///     });
278    /// ```
279    pub fn new<S, C, R, H>(
280        cx: &mut Context,
281        rows: S,
282        columns: C,
283        row_id: impl Fn(&T) -> Id + 'static,
284    ) -> Handle<Self>
285    where
286        S: Res<V> + 'static,
287        C: Res<R> + 'static,
288        R: Deref<Target = [TableColumn<T, H, K>]> + Clone + 'static,
289        H: Clone + View,
290    {
291        let row_signal = rows.to_signal(cx);
292        let column_signal = columns.to_signal(cx);
293        let row_id: Rc<dyn Fn(&T) -> Id> = Rc::new(row_id);
294        let sort_state = Signal::new(None);
295        let sort_cycle = Signal::new(TableSortCycle::BiState);
296        let resizable_columns = Signal::new(false);
297        let selectable = Signal::new(Selectable::None);
298        let selection_follows_focus = Signal::new(false);
299        let selected_row_ids = Signal::new(Vec::new());
300        let selected_indices = Memo::new({
301            let row_id = row_id.clone();
302            move |_| {
303                row_signal.with(|rows| {
304                    selected_row_ids.with(|selected_ids| {
305                        rows.deref()
306                            .iter()
307                            .enumerate()
308                            .filter_map(|(index, row)| {
309                                let id = (row_id)(row);
310                                if selected_ids.contains(&id) { Some(index) } else { None }
311                            })
312                            .collect::<Vec<usize>>()
313                    })
314                })
315            }
316        });
317
318        let column_layout = Memo::new(move |_| {
319            column_signal.with(|columns| {
320                columns
321                    .deref()
322                    .iter()
323                    .map(|column| (column.key.clone(), column.hidden.get()))
324                    .collect::<Vec<_>>()
325            })
326        });
327
328        Self {
329            rows: row_signal,
330            row_id,
331            sort_state,
332            sort_cycle,
333            resizable_columns,
334            selectable,
335            selection_follows_focus,
336            selected_row_ids,
337            on_sort: None,
338            on_row_select: None,
339        }
340        .build(cx, move |cx| {
341            Binding::new(cx, column_layout, move |cx| {
342                let visible_columns = column_signal.with(|columns| {
343                    columns
344                        .deref()
345                        .iter()
346                        .filter(|column| !column.hidden.get())
347                        .cloned()
348                        .collect::<Vec<_>>()
349                });
350                let last_header_index = visible_columns.len().saturating_sub(1);
351
352                let header_columns = Rc::new(visible_columns);
353                let body_columns = header_columns.clone();
354
355                HStack::new(cx, move |cx| {
356                    for (column_index, column) in header_columns.iter().cloned().enumerate() {
357                        let width_signal = column.width;
358                        let sort_state = sort_state;
359                        let sort_cycle = sort_cycle;
360                        let resizable_columns = resizable_columns;
361                        let min_width = column.min_width;
362                        let sortable = column.sortable;
363                        let resizable = column.resizable;
364                        let is_last_column = column_index == last_header_index;
365                        let header_content = column.header_content.clone();
366                        let column_key = column.key.clone();
367                        let sort_direction = sort_state.map({
368                            let column_key = column_key.clone();
369                            move |state| sort_direction_for_column(state.as_ref(), &column_key)
370                        });
371
372                        if is_last_column {
373                            HStack::new(cx, move |cx| {
374                                let header = header_content(cx, sort_direction);
375
376                                let column_key = column_key.clone();
377                                header.on_press(move |cx| {
378                                    if sortable.get() {
379                                        let current_direction = sort_direction_for_column(
380                                            sort_state.get().as_ref(),
381                                            &column_key,
382                                        );
383                                        let next_direction = next_sort_direction(
384                                            sort_cycle.get(),
385                                            current_direction,
386                                        );
387                                        cx.emit(TableEvent::RequestSort(
388                                            column_key.clone(),
389                                            next_direction,
390                                        ));
391                                    }
392                                });
393                            })
394                            .class("table-header-cell")
395                            .toggle_class("sortable", sortable)
396                            .toggle_class("resizable", false)
397                            .width(Stretch(1.0))
398                            .min_width(Auto);
399                        } else {
400                            Resizable::new(
401                                cx,
402                                width_signal.map(|value| Pixels(*value)),
403                                ResizeStackDirection::Right,
404                                move |_cx, new_size| {
405                                    if resizable_columns.get() && resizable.get() {
406                                        width_signal.set(new_size.max(min_width.get()));
407                                    }
408                                },
409                                move |cx| {
410                                    let header = header_content(cx, sort_direction);
411
412                                    let column_key = column_key.clone();
413                                    header.on_press(move |cx| {
414                                        if sortable.get() {
415                                            let current_direction = sort_direction_for_column(
416                                                sort_state.get().as_ref(),
417                                                &column_key,
418                                            );
419                                            let next_direction = next_sort_direction(
420                                                sort_cycle.get(),
421                                                current_direction,
422                                            );
423                                            cx.emit(TableEvent::RequestSort(
424                                                column_key.clone(),
425                                                next_direction,
426                                            ));
427                                        }
428                                    });
429                                },
430                            )
431                            .class("table-header-cell")
432                            .toggle_class("sortable", sortable)
433                            .toggle_class(
434                                "resizable",
435                                resizable_columns.map(move |enabled| *enabled && resizable.get()),
436                            )
437                            .min_width(min_width.map(|value| Pixels(*value)));
438                        }
439                    }
440                })
441                .class("table-header-row")
442                .height(Auto)
443                .width(Stretch(1.0))
444                .min_width(Auto);
445
446                List::new(cx, row_signal, move |cx, row_index, row| {
447                    HStack::new(cx, |cx| {
448                        for (column_index, column) in body_columns.iter().enumerate() {
449                            let width_signal = column.width;
450                            let min_width = column.min_width;
451                            let cell_content = column.cell_content.clone();
452                            let is_last_column = column_index + 1 == body_columns.len();
453
454                            if is_last_column {
455                                VStack::new(cx, move |cx| {
456                                    cell_content(cx, row.map(|value| value.clone()));
457                                })
458                                .class("table-cell")
459                                .width(Stretch(1.0))
460                                .min_width(Auto)
461                                .height(Auto);
462                            } else {
463                                VStack::new(cx, move |cx| {
464                                    cell_content(cx, row.map(|value| value.clone()));
465                                })
466                                .class("table-cell")
467                                .width(width_signal.map(|value| Pixels(*value)))
468                                .min_width(min_width.map(|value| Pixels(*value)))
469                                .height(Auto);
470                            }
471                        }
472                    })
473                    .class("table-row")
474                    .toggle_class("odd", row_index % 2 == 1)
475                    .toggle_class("even", row_index % 2 == 0)
476                    .alignment(Alignment::Left)
477                    .height(Auto)
478                    .width(Stretch(1.0))
479                    .min_width(Auto);
480                })
481                .width(Stretch(1.0))
482                .min_width(Auto)
483                .height(Stretch(1.0))
484                .min_height(Auto)
485                .class("table-body")
486                .selection(selected_indices)
487                .selectable(selectable)
488                .selection_follows_focus(selection_follows_focus)
489                .on_select(move |cx, index| cx.emit(TableEvent::<K>::SelectRow(index)));
490            });
491        })
492    }
493}
494
495impl<T, V, Id, K> View for Table<T, V, Id, K>
496where
497    V: Deref<Target = [T]> + Clone + 'static,
498    T: PartialEq + Clone + 'static,
499    Id: PartialEq + Clone + 'static,
500    K: Clone + PartialEq + Send + Sync + 'static,
501{
502    fn element(&self) -> Option<&'static str> {
503        Some("table")
504    }
505
506    fn event(&mut self, cx: &mut EventContext, event: &mut Event) {
507        event.map(|table_event: &TableEvent<K>, _| match table_event {
508            TableEvent::RequestSort(key, direction) => {
509                if let Some(callback) = &self.on_sort {
510                    (callback)(cx, key.clone(), *direction);
511                }
512            }
513
514            TableEvent::SelectRow(index) => {
515                let rows = self.rows.get();
516                if let Some(row) = rows.deref().get(*index) {
517                    if let Some(callback) = &self.on_row_select {
518                        (callback)(cx, (self.row_id)(row));
519                    }
520                }
521            }
522        });
523    }
524}
525
526/// Modifiers for configuring controlled table state and callbacks.
527pub trait TableModifiers<Id, K = String>: Sized
528where
529    K: Clone + PartialEq + Send + Sync + 'static,
530{
531    /// Sets the current sort state.
532    fn sort_state(self, sort_state: impl Res<Option<TableSortState<K>>> + 'static) -> Self;
533
534    /// Enables or disables column resizing for all columns.
535    fn resizable_columns<U: Into<bool> + Clone + 'static>(
536        self,
537        flag: impl Res<U> + 'static,
538    ) -> Self;
539
540    /// Sets the sort cycle behavior for sortable columns.
541    fn sort_cycle<U: Into<TableSortCycle> + Clone + 'static>(
542        self,
543        cycle: impl Res<U> + 'static,
544    ) -> Self;
545
546    /// Sets the selectable state of the table rows.
547    fn selectable<U: Into<Selectable> + Clone + 'static>(
548        self,
549        selectable: impl Res<U> + 'static,
550    ) -> Self;
551
552    /// Sets whether selection follows focus.
553    fn selection_follows_focus<U: Into<bool> + Clone + 'static>(
554        self,
555        flag: impl Res<U> + 'static,
556    ) -> Self;
557
558    /// Sets externally controlled selected row ids.
559    fn selected_row_ids<R>(self, selected_row_ids: impl Res<R> + 'static) -> Self
560    where
561        R: Deref<Target = [Id]> + Clone + 'static;
562
563    /// Sets the callback triggered when a header requests sorting.
564    fn on_sort<F>(self, callback: F) -> Self
565    where
566        F: 'static + Fn(&mut EventContext, K, TableSortDirection) + Send + Sync;
567
568    /// Sets the callback triggered when a row is selected.
569    fn on_row_select<F>(self, callback: F) -> Self
570    where
571        F: 'static + Fn(&mut EventContext, Id);
572}
573
574impl<T, V, Id, K> TableModifiers<Id, K> for Handle<'_, Table<T, V, Id, K>>
575where
576    V: Deref<Target = [T]> + Clone + 'static,
577    T: PartialEq + Clone + 'static,
578    Id: PartialEq + Clone + 'static,
579    K: Clone + PartialEq + Send + Sync + 'static,
580{
581    fn sort_state(self, sort_state: impl Res<Option<TableSortState<K>>> + 'static) -> Self {
582        let sort_state = sort_state.to_signal(self.cx);
583        self.bind(sort_state, move |handle| {
584            let sort_state = sort_state.get();
585            handle.modify(|table: &mut Table<T, V, Id, K>| table.sort_state.set(sort_state));
586        })
587    }
588
589    fn resizable_columns<U: Into<bool> + Clone + 'static>(
590        self,
591        flag: impl Res<U> + 'static,
592    ) -> Self {
593        let flag = flag.to_signal(self.cx);
594        self.bind(flag, move |handle| {
595            let flag = flag.get().into();
596            handle.modify(|table: &mut Table<T, V, Id, K>| table.resizable_columns.set(flag));
597        })
598    }
599
600    fn sort_cycle<U: Into<TableSortCycle> + Clone + 'static>(
601        self,
602        cycle: impl Res<U> + 'static,
603    ) -> Self {
604        let cycle = cycle.to_signal(self.cx);
605        self.bind(cycle, move |handle| {
606            let cycle = cycle.get().into();
607            handle.modify(|table: &mut Table<T, V, Id, K>| table.sort_cycle.set(cycle));
608        })
609    }
610
611    fn selectable<U: Into<Selectable> + Clone + 'static>(
612        self,
613        selectable: impl Res<U> + 'static,
614    ) -> Self {
615        let selectable = selectable.to_signal(self.cx);
616        self.bind(selectable, move |handle| {
617            let selectable = selectable.get().into();
618            handle.modify(|table: &mut Table<T, V, Id, K>| table.selectable.set(selectable));
619        })
620    }
621
622    fn selection_follows_focus<U: Into<bool> + Clone + 'static>(
623        self,
624        flag: impl Res<U> + 'static,
625    ) -> Self {
626        let flag = flag.to_signal(self.cx);
627        self.bind(flag, move |handle| {
628            let flag = flag.get().into();
629            handle.modify(|table: &mut Table<T, V, Id, K>| table.selection_follows_focus.set(flag));
630        })
631    }
632
633    fn selected_row_ids<R>(self, selected_row_ids: impl Res<R> + 'static) -> Self
634    where
635        R: Deref<Target = [Id]> + Clone + 'static,
636    {
637        let selected_row_ids = selected_row_ids.to_signal(self.cx);
638        self.bind(selected_row_ids, move |handle| {
639            let ids = selected_row_ids.with(|ids| ids.deref().to_vec());
640            handle.modify(|table: &mut Table<T, V, Id, K>| table.selected_row_ids.set(ids));
641        })
642    }
643
644    fn on_sort<F>(self, callback: F) -> Self
645    where
646        F: 'static + Fn(&mut EventContext, K, TableSortDirection) + Send + Sync,
647    {
648        self.modify(|table: &mut Table<T, V, Id, K>| table.on_sort = Some(Arc::new(callback)))
649    }
650
651    fn on_row_select<F>(self, callback: F) -> Self
652    where
653        F: 'static + Fn(&mut EventContext, Id),
654    {
655        self.modify(|table: &mut Table<T, V, Id, K>| table.on_row_select = Some(Box::new(callback)))
656    }
657}