Skip to main content

egui_macview/controls/table_view/
mod.rs

1//! Authentic macOS AppKit Table View (`NSTableView` + `NSTableHeaderView`, SPEC-011).
2//!
3//! Grounded in Apple AppKit `NSTableView` and W3C DTCG tokens via [`MacTheme`].
4//! Features multi-column headers with clickable sort indicators, alternating row backgrounds,
5//! single/Shift-range/Cmd-toggle row selection, and consumed keyboard arrow navigation.
6
7pub mod header;
8
9pub use header::{MacSortDirection, MacTableColumn};
10
11use crate::layout::subpixel_snap_rect;
12use crate::theme::MacTheme;
13use egui::{
14    CornerRadius, Id, Key, Layout, Modifiers, Pos2, Rect, Response, Sense, Stroke, StrokeKind, Ui,
15    UiBuilder, Vec2,
16};
17use std::collections::BTreeSet;
18use std::fmt::Debug;
19use std::hash::Hash;
20
21/// Output returned by [`MacTableView::show`].
22#[derive(Debug, Clone)]
23pub struct MacTableViewResponse {
24    /// Overall widget interaction response.
25    pub response: Response,
26    /// Index of a column header clicked during this frame, if any.
27    pub clicked_header: Option<usize>,
28    /// Toggled sort direction for the clicked header, if any.
29    pub toggled_sort: Option<(usize, MacSortDirection)>,
30    /// Index of the row clicked during this frame, if any.
31    pub clicked_row: Option<usize>,
32    /// True if the row selection set changed during this frame.
33    pub selection_changed: bool,
34}
35
36/// Authentic macOS AppKit multi-column data table (`NSTableView` equivalent).
37///
38/// # Example
39///
40/// ```rust
41/// use egui_macview::{MacSortDirection, MacTableColumn, MacTableView, MacTheme};
42/// use std::collections::BTreeSet;
43///
44/// # let ctx = egui::Context::default();
45/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
46/// let theme = MacTheme::default();
47/// let mut selection = BTreeSet::from([0]);
48/// let cols = [
49///     MacTableColumn::new("Name", 140.0).sort(Some(MacSortDirection::Ascending)),
50///     MacTableColumn::new("Duration", 80.0),
51/// ];
52///
53/// MacTableView::new("media_table", &cols, 3, &mut selection)
54///     .theme(&theme)
55///     .uses_alternating_row_backgrounds(true)
56///     .show(ui, |cell_ui, row, col| {
57///         cell_ui.label(format!("Cell ({row}, {col})"));
58///     });
59/// # });
60/// # output.textures_delta.clear();
61/// ```
62pub struct MacTableView<'a> {
63    id: Id,
64    columns: &'a [MacTableColumn<'a>],
65    row_count: usize,
66    selection: &'a mut BTreeSet<usize>,
67    uses_alternating_row_backgrounds: bool,
68    row_height: Option<f32>,
69    enabled: bool,
70    theme: Option<&'a MacTheme>,
71}
72
73impl<'a> MacTableView<'a> {
74    /// Creates a new `MacTableView` with `columns`, `row_count`, and mutable `selection` set.
75    pub fn new(
76        id_source: impl Hash + Debug,
77        columns: &'a [MacTableColumn<'a>],
78        row_count: usize,
79        selection: &'a mut BTreeSet<usize>,
80    ) -> Self {
81        #[allow(deprecated)]
82        let id = Id::new(id_source);
83        Self {
84            id,
85            columns,
86            row_count,
87            selection,
88            uses_alternating_row_backgrounds: true,
89            row_height: None,
90            enabled: true,
91            theme: None,
92        }
93    }
94
95    /// Configures `usesAlternatingRowBackgroundColors`.
96    pub fn uses_alternating_row_backgrounds(mut self, alternating: bool) -> Self {
97        self.uses_alternating_row_backgrounds = alternating;
98        self
99    }
100
101    /// Overrides default row height in points.
102    pub fn row_height(mut self, height: f32) -> Self {
103        self.row_height = Some(height);
104        self
105    }
106
107    /// Enables or disables interaction.
108    pub fn enabled(mut self, enabled: bool) -> Self {
109        self.enabled = enabled;
110        self
111    }
112
113    /// Injects dynamic [`MacTheme`].
114    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
115        self.theme = Some(theme);
116        self
117    }
118
119    /// Renders the table view and invokes `cell_ui(ui, row_index, col_index)` for each cell.
120    pub fn show(
121        self,
122        ui: &mut Ui,
123        mut cell_ui: impl FnMut(&mut Ui, usize, usize),
124    ) -> MacTableViewResponse {
125        let fallback_theme;
126        let theme = match self.theme {
127            Some(t) => t,
128            None => {
129                fallback_theme = MacTheme::from_ctx(ui.ctx());
130                &fallback_theme
131            }
132        };
133
134        let tm = theme.table_view();
135        let hdr_h = tm.header_height.points();
136        let row_h = self.row_height.unwrap_or_else(|| tm.row_height.points());
137        let pad_h = tm.cell_padding_horizontal.points();
138        let pad_v = tm.cell_padding_vertical.points();
139        let sep_w = tm.separator_width.points();
140        let outer_r = CornerRadius::same(tm.corner_radius.points() as u8);
141        let row_r = CornerRadius::same(tm.row_corner_radius.points() as u8);
142
143        let cols_w: f32 = self.columns.iter().map(|c| c.width).sum();
144        let total_w = cols_w.max(ui.available_width());
145        let total_h = hdr_h + (self.row_count as f32) * row_h;
146
147        let (raw_rect, mut response) =
148            ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
149        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
150
151        let painter = ui.painter_at(rect);
152        painter.rect_filled(rect, outer_r, theme.control_background());
153
154        let header_rect = Rect::from_min_size(rect.min, Vec2::new(rect.width(), hdr_h));
155        let (clicked_header, toggled_sort) = header::render_table_header(
156            ui,
157            self.id,
158            theme,
159            header_rect,
160            self.columns,
161            self.enabled,
162        );
163
164        let anchor_id = self.id.with("selection_anchor");
165        let mut anchor = ui
166            .data(|d| d.get_temp::<usize>(anchor_id))
167            .or_else(|| self.selection.iter().next().copied())
168            .unwrap_or(0);
169
170        let mut clicked_row = None;
171        let mut selection_changed = false;
172
173        // Keyboard ArrowUp / ArrowDown navigation when enabled and a row is selected or focused
174        if self.enabled
175            && self.row_count > 0
176            && (!self.selection.is_empty() || response.has_focus())
177        {
178            if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
179                let cur = self.selection.iter().next_back().copied().unwrap_or(0);
180                let next = (cur + 1).min(self.row_count - 1);
181                self.selection.clear();
182                self.selection.insert(next);
183                anchor = next;
184                selection_changed = true;
185            } else if ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) {
186                let cur = self.selection.iter().next().copied().unwrap_or(0);
187                let prev = cur.saturating_sub(1);
188                self.selection.clear();
189                self.selection.insert(prev);
190                anchor = prev;
191                selection_changed = true;
192            }
193        }
194
195        let body_top = header_rect.bottom();
196
197        for row_idx in 0..self.row_count {
198            let row_y = body_top + (row_idx as f32) * row_h;
199            let row_rect = Rect::from_min_size(
200                Pos2::new(rect.left(), row_y),
201                Vec2::new(rect.width(), row_h),
202            );
203            let row_id = self.id.with(("row", row_idx));
204            let row_sense = if self.enabled {
205                Sense::click()
206            } else {
207                Sense::hover()
208            };
209            let row_resp = ui.interact(row_rect, row_id, row_sense);
210
211            if row_resp.clicked() && self.enabled {
212                clicked_row = Some(row_idx);
213                ui.memory_mut(|m| m.request_focus(response.id));
214                let mods = ui.input(|i| {
215                    let mut m = i.modifiers;
216                    for ev in &i.events {
217                        if let egui::Event::PointerButton { modifiers, .. } = ev {
218                            m |= *modifiers;
219                        }
220                    }
221                    m
222                });
223                if mods.shift {
224                    let lo = anchor.min(row_idx);
225                    let hi = anchor.max(row_idx);
226                    self.selection.clear();
227                    for idx in lo..=hi {
228                        self.selection.insert(idx);
229                    }
230                } else if mods.command || mods.ctrl {
231                    if !self.selection.remove(&row_idx) {
232                        self.selection.insert(row_idx);
233                    }
234                    anchor = row_idx;
235                } else {
236                    self.selection.clear();
237                    self.selection.insert(row_idx);
238                    anchor = row_idx;
239                }
240                selection_changed = true;
241            }
242
243            let is_selected = self.selection.contains(&row_idx);
244            let row_painter = ui.painter_at(row_rect);
245            if is_selected {
246                let sel_bg = if self.enabled {
247                    theme.selected_control()
248                } else {
249                    theme.selected_control_inactive()
250                };
251                row_painter.rect_filled(row_rect, row_r, sel_bg);
252            } else if row_resp.hovered() && self.enabled {
253                row_painter.rect_filled(row_rect, row_r, theme.control());
254            } else if self.uses_alternating_row_backgrounds {
255                let alt_bg = theme.alternating_content_background(row_idx);
256                row_painter.rect_filled(row_rect, 0, alt_bg);
257            }
258
259            let mut col_x = row_rect.left();
260            for (col_idx, col) in self.columns.iter().enumerate() {
261                let cell_rect = Rect::from_min_max(
262                    Pos2::new(col_x + pad_h, row_rect.top() + pad_v),
263                    Pos2::new(
264                        (col_x + col.width - pad_h).max(col_x + pad_h),
265                        row_rect.bottom() - pad_v,
266                    ),
267                );
268                let layout =
269                    Layout::left_to_right(egui::Align::Center).with_main_align(col.alignment);
270                let mut child_ui = ui.new_child(
271                    UiBuilder::new()
272                        .max_rect(cell_rect)
273                        .layout(layout)
274                        .id_salt(("cell", row_idx, col_idx)),
275                );
276                child_ui.set_clip_rect(cell_rect.intersect(ui.clip_rect()));
277                if is_selected {
278                    let fg = theme
279                        .selected_control_background_style()
280                        .primary_text_color(theme);
281                    child_ui.visuals_mut().override_text_color = Some(fg);
282                }
283                cell_ui(&mut child_ui, row_idx, col_idx);
284                col_x += col.width;
285            }
286        }
287
288        ui.data_mut(|d| d.insert_temp(anchor_id, anchor));
289
290        ui.painter_at(rect).rect_stroke(
291            rect,
292            outer_r,
293            Stroke::new(sep_w, theme.separator()),
294            StrokeKind::Inside,
295        );
296
297        if selection_changed || clicked_header.is_some() {
298            response.mark_changed();
299        }
300
301        MacTableViewResponse {
302            response,
303            clicked_header,
304            toggled_sort,
305            clicked_row,
306            selection_changed,
307        }
308    }
309}