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