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    menu_on_activate: bool,
73}
74
75#[derive(Debug, Default)]
76struct TableMemory {
77    /// Widest cell of every column, for the rows it was measured on.
78    fit: Option<(Arc<[TableRow]>, Vec<u16>)>,
79    /// First visible column when the columns overflow.
80    column_offset: usize,
81    /// The largest useful `column_offset` in the last frame; zero when nothing overflows.
82    max_column_offset: usize,
83    /// Whether columns were hidden on the right in the last frame.
84    more: bool,
85    placed: Vec<Placed>,
86}
87
88impl<Msg: 'static> Table<Msg> {
89    /// A table with `columns` showing `rows`.
90    #[must_use]
91    pub fn new(columns: impl IntoIterator<Item = Column>, rows: impl Into<Arc<[TableRow]>>) -> Self {
92        Self {
93            columns: columns.into_iter().collect(),
94            rows: rows.into(),
95            selected: None,
96            checked: None,
97            sort: None,
98            empty: String::new(),
99            on_select: None,
100            on_activate: None,
101            on_toggle: None,
102            on_sort: None,
103            menu: None,
104            menu_on_activate: false,
105        }
106    }
107
108    /// The selected row index.
109    #[must_use]
110    pub fn selected(mut self, index: Option<usize>) -> Self {
111        self.selected = index;
112        self
113    }
114
115    /// Turns on multiple selection; `checked[i]` tells whether row `i` is checked.
116    #[must_use]
117    pub fn checked(mut self, checked: Vec<bool>) -> Self {
118        self.checked = Some(checked);
119        self
120    }
121
122    /// Shows the sort arrow on `column` pointing in `direction`. The rows must already be in
123    /// that order; the table does not reorder them.
124    #[must_use]
125    pub fn sort(mut self, column: usize, direction: SortDirection) -> Self {
126        self.sort = Some((column, direction));
127        self
128    }
129
130    /// Text shown under the header when there are no rows.
131    #[must_use]
132    pub fn empty_text(mut self, text: impl Into<String>) -> Self {
133        self.empty = text.into();
134        self
135    }
136
137    /// Message for moving the selection to a row.
138    #[must_use]
139    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
140        self.on_select = Some(Box::new(message));
141        self
142    }
143
144    /// Message for opening a row (Enter, click).
145    #[must_use]
146    pub fn on_activate(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
147        self.on_activate = Some(Box::new(message));
148        self
149    }
150
151    /// Message for checking or unchecking a row of a multi-select table (Space, click on the mark).
152    #[must_use]
153    pub fn on_toggle(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
154        self.on_toggle = Some(Box::new(message));
155        self
156    }
157
158    /// Message asking to sort by a column in a direction; turns on sorting by clicking titles of
159    /// [`Column::sortable`] columns and with `s` / `shift+s`.
160    #[must_use]
161    pub fn on_sort(mut self, message: impl Fn(usize, SortDirection) -> Msg + 'static) -> Self {
162        self.on_sort = Some(Box::new(message));
163        self
164    }
165
166    /// Gives every row a context menu: `items(index)` builds the entries for the row of that
167    /// index, and the menu acts on the row it was opened on rather than on the selected one.
168    ///
169    /// A right press on a row opens the menu at the pointer; the menu key or Shift+F10 opens the
170    /// menu of the selected row below it, scrolling it into view first. The row the menu belongs
171    /// to stays raised while it is open, so it is clear what the entries act on. A right press on
172    /// a row that is not checked makes it the selection first, so a menu never acts on rows the
173    /// person did not mean.
174    #[must_use]
175    pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
176        self.menu = Some(Box::new(items));
177        self
178    }
179
180    /// Makes a row's [context menu](Self::context_menu) its action: Enter opens the menu of the
181    /// selected row below it and a click opens the menu of the clicked row where it was clicked,
182    /// instead of sending [`on_activate`](Self::on_activate).
183    ///
184    /// For a table whose rows are acted on only through a few choices: a right click is not what
185    /// most people try in a terminal and many keyboards have no menu key, so the menu is also
186    /// reached the way any row is opened. A row whose menu has no entries opens nothing. Off by
187    /// default; without a context menu it does nothing.
188    #[must_use]
189    pub fn menu_on_activate(mut self, on: bool) -> Self {
190        self.menu_on_activate = on;
191        self
192    }
193
194    /// The cells the rows have to themselves: the scrollbar column is not part of a row.
195    fn rows_width(area: Rect, overflows: bool) -> u16 {
196        area.width.saturating_sub(u16::from(overflows))
197    }
198
199    /// Offers `event` to the row menu. A right press picks the row under the pointer and makes it
200    /// the selection unless it is checked, because a menu on a checked row acts on the checked
201    /// rows, which the application knows about.
202    fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
203        let area = cx.area();
204        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
205        let total = self.rows.len();
206        let visible = usize::from(body.height);
207        let overflows = total > visible;
208        row_menu::event(
209            cx,
210            event,
211            self.menu.as_ref(),
212            total,
213            |cx, x, y| {
214                if y < body.y || x >= area.x + i32::from(Self::rows_width(area, overflows)) {
215                    return None;
216                }
217                let offset = cx.memory::<RowScroll>().offset;
218                let row = usize::try_from(y - body.y).ok().map(|row| offset + row).filter(|row| *row < total)?;
219                let checked = self.checked.as_ref().is_some_and(|checked| checked.get(row).copied().unwrap_or(false));
220                if !checked {
221                    self.select(cx, row);
222                }
223                Some(RowAnchor { row, at: Rect::new(x, y, 1, 1), keyboard: false })
224            },
225            |cx| self.selected_anchor(cx),
226        )
227    }
228
229    /// Where the menu of the selected row unfolds from for the keyboard: below the whole row,
230    /// scrolled into view first.
231    fn selected_anchor(&self, cx: &mut EventCx<'_, Msg>) -> Option<RowAnchor> {
232        let area = cx.area();
233        let body_y = area.y + 1;
234        let total = self.rows.len();
235        let visible = usize::from(area.height.saturating_sub(1));
236        let overflows = total > visible;
237        let row = self.selected.filter(|row| *row < total)?;
238        let memory = cx.memory::<RowScroll>();
239        if row < memory.offset {
240            memory.offset = row;
241        } else if visible > 0 && row >= memory.offset + visible {
242            memory.offset = row + 1 - visible;
243        }
244        let y = body_y + i32::try_from(row - memory.offset).unwrap_or(0);
245        let at = Rect::new(area.x, y, Self::rows_width(area, overflows), 1);
246        Some(RowAnchor { row, at, keyboard: true })
247    }
248
249    /// Whether Enter and a click open the row's menu rather than the row.
250    fn activation_is_menu(&self) -> bool {
251        self.menu_on_activate && self.menu.is_some()
252    }
253
254    fn lead(&self) -> u16 {
255        LEAD + if self.checked.is_some() { MARK } else { 0 }
256    }
257
258    fn select(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
259        if Some(index) != self.selected
260            && let Some(message) = &self.on_select
261        {
262            cx.emit(message(index));
263        }
264    }
265
266    fn activate(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
267        let Some(message) = &self.on_activate else {
268            return false;
269        };
270        cx.memory::<RowScroll>().flashed = Some(index);
271        cx.flash();
272        cx.emit(message(index));
273        true
274    }
275
276    fn toggle(&self, cx: &mut EventCx<'_, Msg>, index: usize) -> bool {
277        match (&self.checked, &self.on_toggle) {
278            (Some(_), Some(message)) => {
279                cx.emit(message(index));
280                true
281            }
282            _ => false,
283        }
284    }
285
286    fn request_sort(&self, cx: &mut EventCx<'_, Msg>, column: usize, direction: SortDirection) -> bool {
287        match &self.on_sort {
288            Some(message) if self.columns.get(column).is_some_and(|c| c.sortable) => {
289                cx.emit(message(column, direction));
290                true
291            }
292            _ => false,
293        }
294    }
295
296    /// Sorting after a click on `column`'s title: the other direction when it is already sorted.
297    fn click_sort(&self, column: usize) -> SortDirection {
298        match self.sort {
299            Some((sorted, direction)) if sorted == column => direction.reversed(),
300            _ => SortDirection::Ascending,
301        }
302    }
303
304    /// Scrolls overflowing columns one column forward or back. Returns whether the columns
305    /// overflow at all, so the key or press is used even at an end.
306    fn scroll_columns(cx: &mut EventCx<'_, Msg>, forward: bool) -> bool {
307        let memory = cx.memory::<TableMemory>();
308        if memory.max_column_offset == 0 {
309            return false;
310        }
311        memory.column_offset = if forward {
312            (memory.column_offset + 1).min(memory.max_column_offset)
313        } else {
314            memory.column_offset.saturating_sub(1)
315        };
316        true
317    }
318
319    /// Which way the header's scroll arrow at column `x` scrolls, when one is drawn there: the
320    /// back arrow in the first cell while columns are hidden on the left, the forward arrow in
321    /// the last cell while columns are hidden on the right.
322    fn scroll_arrow_at(cx: &mut EventCx<'_, Msg>, area: Rect, x: i32) -> Option<bool> {
323        let memory = cx.memory::<TableMemory>();
324        if x == area.x && memory.column_offset > 0 {
325            Some(false)
326        } else if x == area.right() - 1 && memory.more {
327            Some(true)
328        } else {
329            None
330        }
331    }
332}
333
334impl<Msg: 'static> Widget<Msg> for Table<Msg> {
335    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
336        let rows = self.rows.len().max(1) + 1;
337        let widths =
338            self.columns.iter().fold(0u16, |sum, c| sum.saturating_add(c.title_width()).saturating_add(COLUMN_GAP));
339        Size::new(widths.saturating_add(self.lead() + 1), clamp_u16(i32::try_from(rows).unwrap_or(i32::MAX)))
340            .min(available)
341    }
342
343    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
344        if area.is_empty() {
345            return;
346        }
347        cx.register_hit(area);
348        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
349        let total = self.rows.len();
350        let visible = usize::from(body.height);
351        let overflows = total > visible;
352        let lead = self.lead();
353        let room = area.width.saturating_sub(lead + u16::from(overflows));
354
355        let (placed, column_offset, more) = {
356            let memory = cx.memory::<TableMemory>();
357            let widest = if self.columns.iter().any(|c| c.width == ColumnWidth::Fit) {
358                self.widest_cells(memory)
359            } else {
360                vec![0; self.columns.len()]
361            };
362            let (widths, overflow) = self.widths(&widest, room);
363            // Columns that scroll sideways keep the forward arrow's cell and one cell of air before
364            // it free, like the back arrow and the lead on the left, so the arrow never covers or
365            // touches a title or a value. A scrollbar column already gives the arrow its cell.
366            let arrow = if overflow { 2 - u16::from(overflows) } else { 0 };
367            let room = room.saturating_sub(arrow);
368            let max_offset = if overflow { Self::max_offset(&widths, room) } else { 0 };
369            memory.max_column_offset = max_offset;
370            memory.column_offset = memory.column_offset.min(max_offset);
371            let placed = Self::place(&widths, memory.column_offset, area.x + i32::from(lead), room);
372            let more =
373                placed.last().is_some_and(|last| last.column + 1 < widths.len() || last.width < widths[last.column]);
374            memory.placed.clone_from(&placed);
375            memory.more = more;
376            (placed, memory.column_offset, more)
377        };
378        self.paint_header(cx, area, &placed, column_offset, more);
379
380        if total == 0 {
381            let faint = cx.style("list-header", None, &[]).text();
382            let budget = area.width.saturating_sub(LEAD);
383            cx.text(area.x + i32::from(LEAD), body.y, &self.empty, faint, budget);
384            return;
385        }
386        let focused = cx.is_focused();
387        let pressed = cx.is_pressed();
388        // An open row menu takes the pointer: only the row it acts on stays raised, so the menu
389        // and the row it belongs to are read together.
390        let menu_row = row_menu::open_row(cx, self.menu.as_ref());
391        if menu_row.is_some() {
392            cx.request_overlay(area);
393        }
394        let offset = cx.memory::<RowScroll>().follow(self.selected, total, visible);
395        let row_width = Self::rows_width(area, overflows);
396        for (row, index) in (offset..total).take(visible).enumerate() {
397            let rect = Rect::new(area.x, body.y + i32::try_from(row).unwrap_or(0), row_width, 1);
398            self.paint_row(cx, rect, index, &placed, RowPaint { focused, pressed, menu_row });
399        }
400        rows::paint_scrollbar(cx, body, total, offset, None);
401    }
402
403    fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
404        row_menu::paint(cx, self.menu.as_ref(), anchor);
405    }
406
407    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
408        if self.menu_event(cx, event) {
409            return true;
410        }
411        let area = cx.area();
412        let body = Rect::new(area.x, area.y + 1, area.width, area.height.saturating_sub(1));
413        let total = self.rows.len();
414        match event {
415            Event::Key(key) => {
416                if let Some(step) = Step::from_key(key) {
417                    let Some(target) = step.apply(self.selected, total, usize::from(body.height)) else {
418                        return false;
419                    };
420                    self.select(cx, target);
421                    return true;
422                }
423                if key.is_plain(Key::Left) || key.is_plain(Key::Right) {
424                    return Self::scroll_columns(cx, key.is_plain(Key::Right));
425                }
426                if key.is_plain(Key::Enter) {
427                    if self.activation_is_menu() {
428                        return self
429                            .selected_anchor(cx)
430                            .is_some_and(|anchor| row_menu::open_as_action(cx, self.menu.as_ref(), &anchor));
431                    }
432                    return self.selected.is_some_and(|index| self.activate(cx, index));
433                }
434                if key.is_plain(Key::Space) {
435                    let Some(index) = self.selected else { return false };
436                    return self.toggle(cx, index) || self.activate(cx, index);
437                }
438                let shift_s = KeyChord { key: Key::Char('s'), mods: Modifiers { shift: true, ..Modifiers::default() } };
439                if self.on_sort.is_some() && (key.is_plain(Key::Char('s')) || key.chord == shift_s) {
440                    return match (key.chord == shift_s, self.sort) {
441                        (true, Some((column, direction))) => self.request_sort(cx, column, direction.reversed()),
442                        (true, None) => false,
443                        (false, current) => {
444                            let start = current.map_or(0, |(column, _)| column + 1);
445                            let count = self.columns.len();
446                            let next = (0..count)
447                                .map(|step| (start + step) % count.max(1))
448                                .find(|i| self.columns[*i].sortable);
449                            next.is_some_and(|column| self.request_sort(cx, column, SortDirection::Ascending))
450                        }
451                    };
452                }
453                false
454            }
455            Event::Mouse(mouse) => {
456                if rows::scroll_mouse(cx, mouse, body, total) {
457                    return true;
458                }
459                if mouse.kind != MouseKind::Down(MouseButton::Left) {
460                    return false;
461                }
462                if mouse.y == area.y {
463                    if let Some(forward) = Self::scroll_arrow_at(cx, area, mouse.x) {
464                        return Self::scroll_columns(cx, forward);
465                    }
466                    let placed = cx.memory::<TableMemory>().placed.clone();
467                    let Some(place) = placed.iter().find(|place| Self::spans(place, mouse.x)) else {
468                        return false;
469                    };
470                    return self.request_sort(cx, place.column, self.click_sort(place.column));
471                }
472                let offset = cx.memory::<RowScroll>().offset;
473                let Some(index) = usize::try_from(mouse.y - body.y).ok().map(|row| offset + row).filter(|i| *i < total)
474                else {
475                    return false;
476                };
477                if self.checked.is_some() && mouse.x < area.x + i32::from(LEAD + MARK) && self.toggle(cx, index) {
478                    return true;
479                }
480                self.select(cx, index);
481                if self.activation_is_menu() {
482                    let anchor = RowAnchor { row: index, at: Rect::new(mouse.x, mouse.y, 1, 1), keyboard: false };
483                    row_menu::open_as_action(cx, self.menu.as_ref(), &anchor);
484                } else {
485                    self.activate(cx, index);
486                }
487                true
488            }
489            _ => false,
490        }
491    }
492
493    fn focusable(&self) -> bool {
494        !self.rows.is_empty()
495    }
496}