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    /// Index of the row double-clicked or activated via Key::Enter during this frame, if any.
33    pub double_clicked_row: Option<usize>,
34    /// True if the row selection set changed during this frame.
35    pub selection_changed: bool,
36}
37
38/// Type alias for [`MacTableViewResponse`] adhering to AppKit output naming.
39pub type MacTableViewOutput = MacTableViewResponse;
40
41/// Authentic macOS AppKit multi-column data table (`NSTableView` equivalent).
42///
43/// # Example
44///
45/// ```rust
46/// use egui_macview::{MacSortDirection, MacTableColumn, MacTableView, MacTheme};
47/// use std::collections::BTreeSet;
48///
49/// # let ctx = egui::Context::default();
50/// # let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
51/// let theme = MacTheme::default();
52/// let mut selection = BTreeSet::from([0]);
53/// let cols = [
54///     MacTableColumn::new("Name", 140.0).sort(Some(MacSortDirection::Ascending)),
55///     MacTableColumn::new("Duration", 80.0),
56/// ];
57///
58/// MacTableView::new("media_table", &cols, 3, &mut selection)
59///     .theme(&theme)
60///     .uses_alternating_row_backgrounds(true)
61///     .show(ui, |cell_ui, row, col| {
62///         cell_ui.label(format!("Cell ({row}, {col})"));
63///     });
64/// # });
65/// # output.textures_delta.clear();
66/// ```
67pub struct MacTableView<'a> {
68    id: Id,
69    columns: &'a [MacTableColumn<'a>],
70    row_count: usize,
71    selection: &'a mut BTreeSet<usize>,
72    uses_alternating_row_backgrounds: bool,
73    row_height: Option<f32>,
74    enabled: bool,
75    theme: Option<&'a MacTheme>,
76}
77
78impl<'a> MacTableView<'a> {
79    /// Creates a new `MacTableView` with `columns`, `row_count`, and mutable `selection` set.
80    pub fn new(
81        id_source: impl Hash + Debug,
82        columns: &'a [MacTableColumn<'a>],
83        row_count: usize,
84        selection: &'a mut BTreeSet<usize>,
85    ) -> Self {
86        #[allow(deprecated)]
87        let id = Id::new(id_source);
88        Self {
89            id,
90            columns,
91            row_count,
92            selection,
93            uses_alternating_row_backgrounds: true,
94            row_height: None,
95            enabled: true,
96            theme: None,
97        }
98    }
99
100    /// Configures `usesAlternatingRowBackgroundColors`.
101    pub fn uses_alternating_row_backgrounds(mut self, alternating: bool) -> Self {
102        self.uses_alternating_row_backgrounds = alternating;
103        self
104    }
105
106    /// Overrides default row height in points.
107    pub fn row_height(mut self, height: f32) -> Self {
108        self.row_height = Some(height);
109        self
110    }
111
112    /// Enables or disables interaction.
113    pub fn enabled(mut self, enabled: bool) -> Self {
114        self.enabled = enabled;
115        self
116    }
117
118    /// Injects dynamic [`MacTheme`].
119    pub fn theme(mut self, theme: &'a MacTheme) -> Self {
120        self.theme = Some(theme);
121        self
122    }
123
124    /// Renders the table view and invokes `cell_ui(ui, row_index, col_index)` for each cell.
125    pub fn show(
126        self,
127        ui: &mut Ui,
128        mut cell_ui: impl FnMut(&mut Ui, usize, usize),
129    ) -> MacTableViewResponse {
130        let theme_cow = MacTheme::resolve(self.theme, ui.ctx());
131        let theme: &MacTheme = &theme_cow;
132
133        let tm = theme.table_view();
134        let hdr_h = tm.header_height.points();
135        let row_h = self.row_height.unwrap_or_else(|| tm.row_height.points());
136        let pad_h = tm.cell_padding_horizontal.points();
137        let pad_v = tm.cell_padding_vertical.points();
138        let sep_w = tm.separator_width.points();
139        let outer_r = CornerRadius::same(tm.corner_radius.points() as u8);
140        let row_r = CornerRadius::same(tm.row_corner_radius.points() as u8);
141
142        let cols_w: f32 = self.columns.iter().map(|c| c.width).sum();
143        let total_w = cols_w.max(ui.available_width());
144        let total_h = hdr_h + (self.row_count as f32) * row_h;
145
146        let (raw_rect, _) = ui.allocate_exact_size(Vec2::new(total_w, total_h), Sense::hover());
147        let mut response = ui.interact(raw_rect, self.id, Sense::focusable_noninteractive());
148        let rect = subpixel_snap_rect(raw_rect, ui.ctx().pixels_per_point());
149
150        let painter = ui.painter_at(rect);
151        painter.rect_filled(rect, outer_r, theme.control_background());
152
153        let header_rect = Rect::from_min_size(rect.min, Vec2::new(rect.width(), hdr_h));
154        let (clicked_header, toggled_sort) = header::render_table_header(
155            ui,
156            self.id,
157            theme,
158            header_rect,
159            self.columns,
160            self.enabled,
161        );
162
163        let mut clicked_row = None;
164        let mut double_clicked_row = None;
165        let mut selection_changed = false;
166        let prev_sel_len = self.selection.len();
167        self.selection.retain(|&idx| idx < self.row_count);
168        if self.selection.len() != prev_sel_len {
169            selection_changed = true;
170        }
171
172        let anchor_id = self.id.with("selection_anchor");
173        let mut anchor = ui
174            .data(|d| d.get_temp::<usize>(anchor_id))
175            .filter(|&a| a < self.row_count)
176            .or_else(|| self.selection.iter().next().copied())
177            .unwrap_or(0);
178
179        // Keyboard ArrowUp / ArrowDown navigation only when this table is focused,
180        // or when no widget holds keyboard focus and the pointer hovers this table
181        let is_focused_or_hovered = response.has_focus()
182            || ui.memory(|m| m.has_focus(self.id))
183            || (ui.memory(|m| m.focused().is_none()) && ui.rect_contains_pointer(rect));
184        if self.enabled && self.row_count > 0 {
185            if is_focused_or_hovered
186                && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown))
187            {
188                let cur = self.selection.iter().next_back().copied().unwrap_or(0);
189                let next = (cur + 1).min(self.row_count - 1);
190                self.selection.clear();
191                self.selection.insert(next);
192                anchor = next;
193                selection_changed = true;
194            } else if is_focused_or_hovered
195                && ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp))
196            {
197                let cur = self.selection.iter().next().copied().unwrap_or(0);
198                let prev = cur.saturating_sub(1);
199                self.selection.clear();
200                self.selection.insert(prev);
201                anchor = prev;
202                selection_changed = true;
203            } else if (is_focused_or_hovered || !self.selection.is_empty())
204                && (ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter))
205                    || ui.input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::ArrowDown))
206                    || ui.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::ArrowDown)))
207            {
208                if let Some(&first_sel) = self.selection.iter().next() {
209                    double_clicked_row = Some(first_sel);
210                }
211            }
212        }
213
214        let body_top = header_rect.bottom();
215        let clip_rect = ui.clip_rect();
216
217        let (first_row, last_row) = if row_h > 0.0 {
218            let start = ((clip_rect.top() - body_top) / row_h).floor().max(0.0) as usize;
219            let end = (((clip_rect.bottom() - body_top) / row_h).ceil().max(0.0) as usize)
220                .min(self.row_count);
221            // 1-row overscan margin prevents subpixel seam flickering on high-DPI displays
222            let start = start.saturating_sub(1).min(self.row_count);
223            let end = (end + 1).clamp(start, self.row_count);
224            (start, end)
225        } else {
226            (0, self.row_count)
227        };
228
229        for row_idx in first_row..last_row {
230            let row_y = body_top + (row_idx as f32) * row_h;
231            let row_rect = Rect::from_min_size(
232                Pos2::new(rect.left(), row_y),
233                Vec2::new(rect.width(), row_h),
234            );
235            let row_id = self.id.with(("row", row_idx));
236            let row_sense = if self.enabled {
237                Sense::click()
238            } else {
239                Sense::hover()
240            };
241            let row_resp = ui.interact(row_rect, row_id, row_sense);
242
243            if row_resp.double_clicked() && self.enabled {
244                double_clicked_row = Some(row_idx);
245            }
246
247            if row_resp.clicked() && self.enabled {
248                clicked_row = Some(row_idx);
249                ui.memory_mut(|m| m.request_focus(response.id));
250                let mods = ui.input(|i| {
251                    let mut m = i.modifiers;
252                    for ev in &i.events {
253                        if let egui::Event::PointerButton { modifiers, .. } = ev {
254                            m |= *modifiers;
255                        }
256                    }
257                    m
258                });
259                if mods.shift {
260                    let lo = anchor.min(row_idx);
261                    let hi = anchor.max(row_idx);
262                    self.selection.clear();
263                    for idx in lo..=hi {
264                        self.selection.insert(idx);
265                    }
266                } else if mods.command || mods.ctrl {
267                    if !self.selection.remove(&row_idx) {
268                        self.selection.insert(row_idx);
269                    }
270                    anchor = row_idx;
271                } else {
272                    self.selection.clear();
273                    self.selection.insert(row_idx);
274                    anchor = row_idx;
275                }
276                selection_changed = true;
277            }
278
279            let is_selected = self.selection.contains(&row_idx);
280            let row_painter = ui.painter_at(row_rect);
281            if is_selected {
282                let sel_bg = if self.enabled {
283                    theme.selected_control()
284                } else {
285                    theme.selected_control_inactive()
286                };
287                row_painter.rect_filled(row_rect, row_r, sel_bg);
288            } else if row_resp.hovered() && self.enabled {
289                row_painter.rect_filled(row_rect, row_r, theme.control());
290            } else if self.uses_alternating_row_backgrounds {
291                let alt_bg = theme.alternating_content_background(row_idx);
292                row_painter.rect_filled(row_rect, 0, alt_bg);
293            }
294
295            let mut col_x = row_rect.left();
296            for (col_idx, col) in self.columns.iter().enumerate() {
297                let cell_rect = Rect::from_min_max(
298                    Pos2::new(col_x + pad_h, row_rect.top() + pad_v),
299                    Pos2::new(
300                        (col_x + col.width - pad_h).max(col_x + pad_h),
301                        row_rect.bottom() - pad_v,
302                    ),
303                );
304                let layout =
305                    Layout::left_to_right(egui::Align::Center).with_main_align(col.alignment);
306                let mut child_ui = ui.new_child(
307                    UiBuilder::new()
308                        .max_rect(cell_rect)
309                        .layout(layout)
310                        .id_salt(("cell", row_idx, col_idx)),
311                );
312                child_ui.set_clip_rect(cell_rect.intersect(ui.clip_rect()));
313                if is_selected {
314                    let fg = theme
315                        .selected_control_background_style()
316                        .primary_text_color(theme);
317                    child_ui.visuals_mut().override_text_color = Some(fg);
318                }
319                cell_ui(&mut child_ui, row_idx, col_idx);
320                col_x += col.width;
321            }
322        }
323
324        let prev_anchor = ui.data(|d| d.get_temp::<usize>(anchor_id));
325        if prev_anchor != Some(anchor) {
326            ui.data_mut(|d| d.insert_temp(anchor_id, anchor));
327        }
328
329        ui.painter_at(rect).rect_stroke(
330            rect,
331            outer_r,
332            Stroke::new(sep_w, theme.separator()),
333            StrokeKind::Inside,
334        );
335
336        if selection_changed || clicked_header.is_some() {
337            response.mark_changed();
338        }
339
340        MacTableViewResponse {
341            response,
342            clicked_header,
343            toggled_sort,
344            clicked_row,
345            double_clicked_row,
346            selection_changed,
347        }
348    }
349}