Skip to main content

vizia_core/views/
table.rs

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