Skip to main content

qframe/widgets/table/
mod.rs

1//! Tables: rows of cells under a header, virtualised, sortable and selectable.
2//!
3//! The columns, cells and rows an application builds are in `model`; `layout` decides column
4//! widths and which columns show; `paint` draws the header and the rows.
5
6mod layout;
7mod model;
8mod paint;
9#[cfg(test)]
10mod tests;
11
12use std::sync::Arc;
13
14use crate::event::{Event, MouseButton, MouseKind};
15use crate::geometry::{Rect, Size, clamp_u16};
16use crate::keymap::{Key, KeyChord, Modifiers};
17use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
18
19use super::row::LEAD;
20use super::row_menu::{self, RowAnchor, RowMenuItems};
21use super::rows::{self, RowScroll, Step};
22use super::{ContextItem, IndexMessage};
23use layout::Placed;
24pub use model::{Column, ColumnWidth, SortDirection, TableCell, TableRow};
25use paint::RowPaint;
26
27/// Cells between two columns. Columns are told apart by space, never by a drawn line.
28const COLUMN_GAP: u16 = 2;
29
30/// Cells taken by the check mark of a multi-select table.
31const MARK: u16 = 2;
32
33/// Builds a message from a column and a direction.
34type SortMessage<Msg> = Box<dyn Fn(usize, SortDirection) -> Msg>;
35
36/// Rows of cells under a header row. Only the rows on screen are drawn, so a table stays fast
37/// with any number of rows; pass the rows as an `Arc<[TableRow]>` kept in your state to avoid
38/// copying them every frame.
39///
40/// The header sits on a raised surface with faint titles. Columns are separated by space; their
41/// widths follow [`ColumnWidth`] and [`Column::min`], and when the minimums do not fit, the
42/// columns scroll sideways: arrows at the ends of the header show which side hides columns. The
43/// application owns the selection, the checked rows and the sort order and is told about changes
44/// through messages. A hovered or selected row raises its surface and shows the pillar; only its
45/// first cell slides one cell right. The check mark of a multi-select table never moves.
46///
47/// Keys while focused: ↑/↓ or k/j, PgUp/PgDn, Home/End move; Enter activates; Space toggles in
48/// multi-select tables and activates otherwise; ←/→ scroll columns that overflow; with
49/// [`Table::on_sort`], `s` sorts by the next sortable column and `shift+s` reverses the order.
50///
51/// The mouse does the same: a click on a row selects and activates it, a click on its check mark
52/// (or the cell after it) only toggles, a click on a sortable title sorts by it and a second
53/// click reverses it, and a click on a header arrow scrolls the columns one step.
54///
55/// Style keys: rows use `list-item` (`hover`, `selected`, `focus`, `pressed`) and
56/// `list-item.faint` like [`List`](super::List); `table-header` (`bg`, `fg`) with `hover` over a
57/// sortable title and `selected` on the sorted one; `table-sort` for the sort arrow;
58/// `table-scroll` (`fg`, `bg`) with `hover` for the header arrows; `list-header` for the empty
59/// text; `scrollbar`.
60pub struct Table<Msg> {
61    columns: Vec<Column>,
62    rows: Arc<[TableRow]>,
63    selected: Option<usize>,
64    checked: Option<Vec<bool>>,
65    sort: Option<(usize, SortDirection)>,
66    empty: String,
67    on_select: Option<IndexMessage<Msg>>,
68    on_activate: Option<IndexMessage<Msg>>,
69    on_toggle: Option<IndexMessage<Msg>>,
70    on_sort: Option<SortMessage<Msg>>,
71    menu: Option<RowMenuItems<Msg>>,
72}
73
74#[derive(Debug, Default)]
75struct TableMemory {
76    /// Widest cell of every column, for the rows it was measured on.
77    fit: Option<(Arc<[TableRow]>, Vec<u16>)>,
78    /// First visible column when the columns overflow.
79    column_offset: usize,
80    /// The largest useful `column_offset` in the last frame; zero when nothing overflows.
81    max_column_offset: usize,
82    /// Whether columns were hidden on the right in the last frame.
83    more: bool,
84    placed: Vec<Placed>,
85}
86
87impl<Msg: 'static> Table<Msg> {
88    /// A table with `columns` showing `rows`.
89    #[must_use]
90    pub fn new(columns: impl IntoIterator<Item = Column>, rows: impl Into<Arc<[TableRow]>>) -> Self {
91        Self {
92            columns: columns.into_iter().collect(),
93            rows: rows.into(),
94            selected: None,
95            checked: None,
96            sort: None,
97            empty: String::new(),
98            on_select: None,
99            on_activate: None,
100            on_toggle: None,
101            on_sort: None,
102            menu: None,
103        }
104    }
105
106    /// The selected row index.
107    #[must_use]
108    pub fn selected(mut self, index: Option<usize>) -> Self {
109        self.selected = index;
110        self
111    }
112
113    /// Turns on multiple selection; `checked[i]` tells whether row `i` is checked.
114    #[must_use]
115    pub fn checked(mut self, checked: Vec<bool>) -> Self {
116        self.checked = Some(checked);
117        self
118    }
119
120    /// Shows the sort arrow on `column` pointing in `direction`. The rows must already be in
121    /// that order; the table does not reorder them.
122    #[must_use]
123    pub fn sort(mut self, column: usize, direction: SortDirection) -> Self {
124        self.sort = Some((column, direction));
125        self
126    }
127
128    /// Text shown under the header when there are no rows.
129    #[must_use]
130    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
131        self.empty = text.into();
132        self
133    }
134
135    /// Message for moving the selection to a row.
136    #[must_use]
137    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
138        self.on_select = Some(Box::new(message));
139        self
140    }
141
142    /// Message for opening a row (Enter, click).
143    #[must_use]
144    pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
145        self.on_activate = Some(Box::new(message));
146        self
147    }
148
149    /// Message for checking or unchecking a row of a multi-select table (Space, click on the mark).
150    #[must_use]
151    pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
152        self.on_toggle = Some(Box::new(message));
153        self
154    }
155
156    /// Message asking to sort by a column in a direction; turns on sorting by clicking titles of
157    /// [`Column::sortable`] columns and with `s` / `shift+s`.
158    #[must_use]
159    pub fn on_sort(mut self, message: impl Fn(usize, SortDirection) -> Msg + 'static) -> Self {
160        self.on_sort = Some(Box::new(message));
161        self
162    }
163
164    /// Gives every row a context menu: `items(index)` builds the entries for the row of that
165    /// index, and the menu acts on the row it was opened on rather than on the selected one.
166    ///
167    /// A right press on a row opens the menu at the pointer; the menu key or Shift+F10 opens the
168    /// menu of the selected row below it, scrolling it into view first. The row the menu belongs
169    /// to stays raised while it is open, so it is clear what the entries act on. A right press on
170    /// a row that is not checked makes it the selection first, so a menu never acts on rows the
171    /// person did not mean.
172    #[must_use]
173    pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
174        self.menu = Some(Box::new(items));
175        self
176    }
177
178    /// The cells the rows have to themselves: the scrollbar column is not part of a row.
179    fn rows_width(area: Rect, overflows: bool) -> u16 {
180        area.width.saturating_sub(u16::from(overflows))
181    }
182
183    /// Offers `event` to the row menu. A right press picks the row under the pointer and makes it
184    /// the selection unless it is checked, because a menu on a checked row acts on the checked
185    /// rows, which the application knows about.
186    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
187        let area = cx.area();
188        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
189        let total = self.rows.len();
190        let visible = usize::from(body.height);
191        let overflows = total > visible;
192        row_menu::event(
193            cx,
194            event,
195            self.menu.as_ref(),
196            total,
197            |cx, x, y| {
198                if y < body.y || x >= area.x + i32::from(Self::rows_width(area, overflows)) {
199                    return None;
200                }
201                let offset = cx.memory::<RowScroll>().offset;
202                let row = usize::try_from(y - body.y).ok().map(|row| offset + row).filter(|row| *row < total)?;
203                let checked = self.checked.as_ref().is_some_and(|checked| checked.get(row).copied().unwrap_or(false));
204                if !checked {
205                    self.select(cx, row);
206                }
207                Some(RowAnchor { row, at: Rect::new(x, y, 1, 1), keyboard: false })
208            },
209            |cx| {
210                let row = self.selected.filter(|row| *row < total)?;
211                let memory = cx.memory::<RowScroll>();
212                if row < memory.offset {
213                    memory.offset = row;
214                } else if visible > 0 && row >= memory.offset + visible {
215                    memory.offset = row + 1 - visible;
216                }
217                let y = body.y + i32::try_from(row - memory.offset).unwrap_or(0);
218                let at = Rect::new(area.x, y, Self::rows_width(area, overflows), 1);
219                Some(RowAnchor { row, at, keyboard: true })
220            },
221        )
222    }
223
224    fn lead(&self) -> u16 {
225        LEAD + if self.checked.is_some() { MARK } else { 0 }
226    }
227
228    fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
229        if Some(index) != self.selected
230            && let Some(message) = &self.on_select
231        {
232            cx.emit(message(index));
233        }
234    }
235
236    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
237        let Some(message) = &self.on_activate else {
238            return false;
239        };
240        cx.memory::<RowScroll>().flashed = Some(index);
241        cx.flash();
242        cx.emit(message(index));
243        true
244    }
245
246    fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
247        match (&self.checked, &self.on_toggle) {
248            (Some(_), Some(message)) => {
249                cx.emit(message(index));
250                true
251            }
252            _ => false,
253        }
254    }
255
256    fn request_sort(&self, cx: &mut EventCx<'_, Msg>, column: usize, direction: SortDirection) -> bool {
257        match &self.on_sort {
258            Some(message) if self.columns.get(column).is_some_and(|c| c.sortable) => {
259                cx.emit(message(column, direction));
260                true
261            }
262            _ => false,
263        }
264    }
265
266    /// Sorting after a click on `column`'s title: the other direction when it is already sorted.
267    fn click_sort(&self, column: usize) -> SortDirection {
268        match self.sort {
269            Some((sorted, direction)) if sorted == column => direction.reversed(),
270            _ => SortDirection::Ascending,
271        }
272    }
273
274    /// Scrolls overflowing columns one column forward or back. Returns whether the columns
275    /// overflow at all, so the key or press is used even at an end.
276    fn scroll_columns(cx: &mut EventCx<'_, Msg>, forward: bool) -> bool {
277        let memory = cx.memory::<TableMemory>();
278        if memory.max_column_offset == 0 {
279            return false;
280        }
281        memory.column_offset = if forward {
282            (memory.column_offset + 1).min(memory.max_column_offset)
283        } else {
284            memory.column_offset.saturating_sub(1)
285        };
286        true
287    }
288
289    /// Which way the header's scroll arrow at column `x` scrolls, when one is drawn there: the
290    /// back arrow in the first cell while columns are hidden on the left, the forward arrow in
291    /// the last cell while columns are hidden on the right.
292    fn scroll_arrow_at(cx: &mut EventCx<'_, Msg>, area: Rect, x: i32) -> Option<bool> {
293        let memory = cx.memory::<TableMemory>();
294        if x == area.x && memory.column_offset > 0 {
295            Some(false)
296        } else if x == area.right() - 1 && memory.more {
297            Some(true)
298        } else {
299            None
300        }
301    }
302}
303
304impl<Msg: 'static> Widget<Msg> for Table<Msg> {
305    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
306        let rows = self.rows.len().max(1) + 1;
307        let widths =
308            self.columns.iter().fold(0u16, |sum, c| sum.saturating_add(c.title_width()).saturating_add(COLUMN_GAP));
309        Size::new(widths.saturating_add(self.lead() + 1), clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)))
310            .min(available)
311    }
312
313    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
314        if area.is_empty() {
315            return;
316        }
317        cx.register_hit(area);
318        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
319        let total = self.rows.len();
320        let visible = usize::from(body.height);
321        let overflows = total > visible;
322        let lead = self.lead();
323        let room = area.width.saturating_sub(lead + u16::from(overflows));
324
325        let (placed, column_offset, more) = {
326            let memory = cx.memory::<TableMemory>();
327            let widest = if self.columns.iter().any(|c| c.width == ColumnWidth::Fit) {
328                self.widest_cells(memory)
329            } else {
330                vec![0; self.columns.len()]
331            };
332            let (widths, overflow) = self.widths(&widest, room);
333            // Columns that scroll sideways keep the forward arrow's cell and one cell of air before
334            // it free, like the back arrow and the lead on the left, so the arrow never covers or
335            // touches a title or a value. A scrollbar column already gives the arrow its cell.
336            let arrow = if overflow { 2 - u16::from(overflows) } else { 0 };
337            let room = room.saturating_sub(arrow);
338            let max_offset = if overflow { Self::max_offset(&widths, room) } else { 0 };
339            memory.max_column_offset = max_offset;
340            memory.column_offset = memory.column_offset.min(max_offset);
341            let placed = Self::place(&widths, memory.column_offset, area.x + i32::from(lead), room);
342            let more =
343                placed.last().is_some_and(|last| last.column + 1 < widths.len() || last.width < widths[last.column]);
344            memory.placed.clone_from(&placed);
345            memory.more = more;
346            (placed, memory.column_offset, more)
347        };
348        self.paint_header(cx, area, &placed, column_offset, more);
349
350        if total == 0 {
351            let faint = cx.style("list-header", None, &[]).text();
352            let budget = area.width.saturating_sub(LEAD);
353            cx.text(area.x + i32::from(LEAD), body.y, &self.empty, faint, budget);
354            return;
355        }
356        let focused = cx.is_focused();
357        let pressed = cx.is_pressed();
358        // An open row menu takes the pointer: only the row it acts on stays raised, so the menu
359        // and the row it belongs to are read together.
360        let menu_row = row_menu::open_row(cx, self.menu.as_ref());
361        if menu_row.is_some() {
362            cx.request_overlay(area);
363        }
364        let offset = cx.memory::<RowScroll>().follow(self.selected, total, visible);
365        let row_width = Self::rows_width(area, overflows);
366        for (row, index) in (offset..total).take(visible).enumerate() {
367            let rect = Rect::new(area.x, body.y + i32::try_from(row).unwrap_or(0), row_width, 1);
368            self.paint_row(cx, rect, index, &placed, RowPaint { focused, pressed, menu_row });
369        }
370        rows::paint_scrollbar(cx, body, total, offset, None);
371    }
372
373    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
374        row_menu::paint(cx, self.menu.as_ref(), anchor);
375    }
376
377    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
378        if self.menu_event(cx, event) {
379            return true;
380        }
381        let area = cx.area();
382        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
383        let total = self.rows.len();
384        match event {
385            Event::Key(key) => {
386                if let Some(step) = Step::from_key(key) {
387                    let Some(target) = step.apply(self.selected, total, usize::from(body.height)) else {
388                        return false;
389                    };
390                    self.select(cx, target);
391                    return true;
392                }
393                if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
394                    return Self::scroll_columns(cx, key.is_plain(Key::Right));
395                }
396                if key.is_plain(Key::Enter) {
397                    return self.selected.is_some_and(|index| self.activate(cx, index));
398                }
399                if key.is_plain(Key::Space) {
400                    let Some(index) = self.selected else { return false };
401                    return self.toggle(cx, index) || self.activate(cx, index);
402                }
403                let shift_s = KeyChord { key: Key::Char('s'), mods: Modifiers { shift: true, ..Modifiers::default() } };
404                if self.on_sort.is_some() && (key.is_plain(Key::Char('s')) || key.chord == shift_s) {
405                    return match (key.chord == shift_s, self.sort) {
406                        (true, Some((column, direction))) => self.request_sort(cx, column, direction.reversed()),
407                        (true, None) => false,
408                        (false, current) => {
409                            let start = current.map_or(0, |(column, _)| column + 1);
410                            let count = self.columns.len();
411                            let next = (0..count)
412                                .map(|step| (start + step) % count.max(1))
413                                .find(|i| self.columns[*i].sortable);
414                            next.is_some_and(|column| self.request_sort(cx, column, SortDirection::Ascending))
415                        }
416                    };
417                }
418                false
419            }
420            Event::Mouse(mouse) => {
421                if rows::scroll_mouse(cx, mouse, body, total) {
422                    return true;
423                }
424                if mouse.kind != MouseKind::Down(MouseButton::Left) {
425                    return false;
426                }
427                if mouse.y == area.y {
428                    if let Some(forward) = Self::scroll_arrow_at(cx, area, mouse.x) {
429                        return Self::scroll_columns(cx, forward);
430                    }
431                    let placed = cx.memory::<TableMemory>().placed.clone();
432                    let Some(place) = placed.iter().find(|place| Self::spans(place, mouse.x)) else {
433                        return false;
434                    };
435                    return self.request_sort(cx, place.column, self.click_sort(place.column));
436                }
437                let offset = cx.memory::<RowScroll>().offset;
438                let Some(index) = usize::try_from(mouse.y - body.y).ok().map(|row| offset + row).filter(|i| *i < total)
439                else {
440                    return false;
441                };
442                if self.checked.is_some() && mouse.x < area.x + i32::from(LEAD + MARK) && self.toggle(cx, index) {
443                    return true;
444                }
445                self.select(cx, index);
446                self.activate(cx, index);
447                true
448            }
449            _ => false,
450        }
451    }
452
453    fn focusable(&self) -> bool {
454        !self.rows.is_empty()
455    }
456}