Skip to main content

teksilo_widgets/table_view/
column.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Column descriptor + supporting enums for `TableView` and `TreeTableView`.
5//!
6//! Columns are declared once per table; the table consumes a `Vec<Column<T>>`
7//! and shares it with its body subtree. The cell delegate is `Rc`-erased so a
8//! `Column<T>` is cheap to clone for any internal pane that needs its own
9//! copy.
10//!
11//! The part of a column the header needs — id, title, widths, the gestures it
12//! allows, pinning — is a [`ColumnSpec`], which carries no row type. A
13//! `Column<T>` holds one beside its cell delegate; a
14//! [`TableHeader`](crate::TableHeader) takes them on their own.
15
16use std::rc::Rc;
17
18use teksilo_core::widget::Widget;
19use teksilo_data::SortDirection;
20use teksilo_i18n::LocalizedString;
21
22/// How a column's width is determined during layout.
23#[derive(Debug, Clone, Copy, PartialEq)]
24pub enum ColumnWidth {
25    /// Exact pixel width. Clamped by `min_width` / `max_width`.
26    Fixed(f32),
27    /// Share of the leftover space proportional to the flex factor —
28    /// behaves like CSS `flex-grow`. The factor must be `> 0.0`.
29    Flex(f32),
30    /// Intrinsic content width (currently approximated by the table's
31    /// `min_column_width_default` token; refined to probe the
32    /// header label and visible cells).
33    Auto,
34}
35
36impl Default for ColumnWidth {
37    fn default() -> Self {
38        ColumnWidth::Flex(1.0)
39    }
40}
41
42/// Whether a column is pinned to one side of the table.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum PinnedSide {
45    /// Pinned against the leading edge — stays visible during horizontal
46    /// scroll.
47    Leading,
48    /// Not pinned — scrolls horizontally with the body.
49    #[default]
50    None,
51    /// Pinned against the trailing edge.
52    Trailing,
53}
54
55/// Horizontal alignment of a cell's content within its column.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum Alignment {
58    #[default]
59    Leading,
60    Center,
61    Trailing,
62}
63
64/// Strategy when a cell's text overflows its column.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum TruncationPolicy {
67    /// `…`-elide the trailing portion. **Default.**
68    #[default]
69    Ellipsis,
70    /// Don't truncate; let the cell content draw beyond the column edge
71    /// (the body pane's clip will hide it).
72    None,
73    /// Fade the trailing portion — gradient mask.
74    Fade,
75}
76
77/// Whether the table draws grid lines between rows / columns.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum GridLines {
80    #[default]
81    None,
82    Horizontal,
83    Vertical,
84    Both,
85}
86
87/// Whether column resize commits the new width on every drag tick (`Live`)
88/// or only on `Ended` (`OnRelease`).
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum ColumnResizePolicy {
91    #[default]
92    Live,
93    OnRelease,
94}
95
96/// Which gestures open a cell editor — a **set**, composed with `|`, after
97/// Qt's `QAbstractItemView::EditTriggers`.
98///
99/// A set rather than an enum of named combinations, because the combinations
100/// are the caller's to choose: "one click" and "F2 or one click" are ordinary
101/// requests that a closed enum of `F2 / F2OrType / F2OrTypeOrDoubleClick /
102/// DoubleClick / None` could not express at all.
103///
104/// Set table-wide with [`TableView::edit_triggers`](crate::TableView::edit_triggers)
105/// / [`TreeTableView::edit_triggers`](crate::TreeTableView::edit_triggers), and
106/// per column with [`Column::edit_triggers`] — the column wins where it sets
107/// one. Only cells of an [`editable`](Column::editable) column ever open an
108/// editor, whatever the triggers say; the two are the same split Qt makes
109/// between a view's `editTriggers` and an item's `ItemIsEditable`.
110///
111/// **`SINGLE_CLICK` claims the press.** A cell that edits on one click does not
112/// also select its row — the same trade any interactive cell content already
113/// makes, and the reason it is per column: put it on the columns that are
114/// nothing but a value, and leave the row's own column alone.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub struct EditTriggers(u8);
117
118impl EditTriggers {
119    /// Editing is never opened by the view. Cells of an editable column still
120    /// render normally; nothing reaches `on_cell_edit_request`.
121    pub const NONE: Self = Self(0);
122    /// **F2** on the focused cell.
123    pub const F2: Self = Self(1 << 0);
124    /// Any printable character typed on the focused cell. Note that the
125    /// keystroke that opens the editor is **not** delivered into it — the
126    /// editor does not exist until the next build — so this reads as "F2 with
127    /// an extra key", and it shadows type-ahead on every editable column.
128    pub const ANY_KEY: Self = Self(1 << 1);
129    /// A single click on the cell. Claims the press, so that cell no longer
130    /// selects its row.
131    pub const SINGLE_CLICK: Self = Self(1 << 2);
132    /// A double click on the cell. It takes the gesture from row activation on
133    /// **this column** — a column that edits on double-click must not also open
134    /// its row on the same click — while every other column still activates.
135    pub const DOUBLE_CLICK: Self = Self(1 << 3);
136    /// Every trigger at once.
137    pub const ALL: Self = Self(0b0000_1111);
138
139    /// `true` when every trigger in `other` is present.
140    pub const fn contains(self, other: Self) -> bool {
141        self.0 & other.0 == other.0
142    }
143
144    /// `true` when nothing opens an editor.
145    pub const fn is_empty(self) -> bool {
146        self.0 == 0
147    }
148
149    pub const fn union(self, other: Self) -> Self {
150        Self(self.0 | other.0)
151    }
152
153    pub const fn intersection(self, other: Self) -> Self {
154        Self(self.0 & other.0)
155    }
156}
157
158impl Default for EditTriggers {
159    /// `F2 | ANY_KEY | DOUBLE_CLICK` — what the old
160    /// `EditTriggers::F2OrTypeOrDoubleClick` default named. (It only ever
161    /// delivered the first two: the click arm had no implementation anywhere.)
162    fn default() -> Self {
163        Self::F2.union(Self::ANY_KEY).union(Self::DOUBLE_CLICK)
164    }
165}
166
167impl std::ops::BitOr for EditTriggers {
168    type Output = Self;
169    fn bitor(self, rhs: Self) -> Self {
170        self.union(rhs)
171    }
172}
173
174impl std::ops::BitOrAssign for EditTriggers {
175    fn bitor_assign(&mut self, rhs: Self) {
176        *self = self.union(rhs);
177    }
178}
179
180impl std::ops::BitAnd for EditTriggers {
181    type Output = Self;
182    fn bitand(self, rhs: Self) -> Self {
183        self.intersection(rhs)
184    }
185}
186
187/// Tab / Shift-Tab traversal policy across cells of a row.
188///
189/// Regardless of the policy, **Ctrl+Tab / Ctrl+Shift+Tab always move focus
190/// out of the table** to the next / previous focusable widget — the reliable
191/// escape from `CellsThenRows`, so keyboard focus is never trapped.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum TabTraversal {
194    /// Tab moves to the next cell within the row, then wraps to the first
195    /// cell of the next row. **Default.** (Ctrl+Tab leaves the table.)
196    #[default]
197    CellsThenRows,
198    /// Tab leaves the table once the focused cell is reached at the row
199    /// boundary; the focus owner is whatever follows the table in tab
200    /// order.
201    OutOfTable,
202}
203
204/// Per-cell context handed to a column's cell delegate during build.
205#[derive(Debug, Clone)]
206pub struct CellContext {
207    /// Row index in the visible-list space (post sort/filter).
208    pub row_index: usize,
209    /// Column id (slug supplied at construction).
210    pub col_id: String,
211    /// Visible-column index, starting at 0 in display order.
212    pub col_index: usize,
213    /// Whether this row (or this specific cell, in cell-selection mode) is
214    /// part of the current selection.
215    pub is_selected: bool,
216    /// Whether this cell currently carries the keyboard focus.
217    pub is_focused: bool,
218    /// Whether the pointer is hovering this cell.
219    pub is_hovered: bool,
220    /// Whether `editing_cell_signal` matches this cell.
221    pub is_editing: bool,
222    /// `TreeTableView` only — depth of the row in the hierarchy. `None` for
223    /// flat tables.
224    pub depth: Option<usize>,
225    /// `TreeTableView` only — true on the column hosting the twist arrow.
226    pub is_tree_column: bool,
227}
228
229/// Per-column-header context handed to a column's header delegate.
230#[derive(Debug, Clone)]
231pub struct ColumnContext {
232    pub col_id: String,
233    pub col_index: usize,
234    /// Active sort direction if this column is the current sort column.
235    pub sort: Option<SortDirection>,
236    /// Current filter text (empty = no filter).
237    pub filter_text: String,
238    pub is_hovered: bool,
239}
240
241/// What a column is to its header: id, title, width policy and the user
242/// gestures it allows — every part of a [`Column`] except how its cells are
243/// built.
244///
245/// [`TableHeader`](crate::TableHeader) takes a list of these, which is what
246/// lets an application put the table header over rows it lays out itself.
247/// A [`Column<T>`] carries one too and forwards its builders to it, so the
248/// two declare a column with the same calls and the same defaults.
249///
250/// The id must be a **stable, unique string**: it is the key the sort, the
251/// filters, the width map and the column order use.
252#[derive(Debug, Clone)]
253pub struct ColumnSpec {
254    pub(crate) id: String,
255    pub(crate) header_label: LocalizedString,
256    pub(crate) width: ColumnWidth,
257    pub(crate) min_width: Option<f32>,
258    pub(crate) max_width: Option<f32>,
259    pub(crate) alignment: Alignment,
260    pub(crate) resizable: bool,
261    pub(crate) reorderable: bool,
262    pub(crate) sortable: bool,
263    pub(crate) filterable: bool,
264    pub(crate) pinned: PinnedSide,
265}
266
267impl ColumnSpec {
268    /// A column with a stable id and a localized title. Defaults: a
269    /// `Flex(1.0)` width, resizable, reorderable, neither sortable nor
270    /// filterable, unpinned.
271    pub fn new(id: impl Into<String>, title: impl Into<LocalizedString>) -> Self {
272        Self {
273            id: id.into(),
274            header_label: title.into(),
275            width: ColumnWidth::default(),
276            min_width: None,
277            max_width: None,
278            alignment: Alignment::default(),
279            resizable: true,
280            reorderable: true,
281            sortable: false,
282            filterable: false,
283            pinned: PinnedSide::None,
284        }
285    }
286
287    /// How the column's width is resolved. Default `Flex(1.0)`.
288    pub fn width(mut self, w: ColumnWidth) -> Self {
289        self.width = w;
290        self
291    }
292
293    /// The narrowest the column resolves or resizes to. Default: the table
294    /// style's `MIN_COLUMN_WIDTH_DEFAULT`.
295    pub fn min_width(mut self, px: f32) -> Self {
296        self.min_width = Some(px);
297        self
298    }
299
300    /// The widest the column resolves or resizes to. Default: unbounded.
301    pub fn max_width(mut self, px: f32) -> Self {
302        self.max_width = Some(px);
303        self
304    }
305
306    /// Horizontal alignment of the column's content. Default `Leading`.
307    pub fn alignment(mut self, a: Alignment) -> Self {
308        self.alignment = a;
309        self
310    }
311
312    /// Whether the header's divider grip resizes the column. Default `true`.
313    pub fn resizable(mut self, b: bool) -> Self {
314        self.resizable = b;
315        self
316    }
317
318    /// Whether the header cell can be dragged to reorder the column.
319    /// Default `true`.
320    pub fn reorderable(mut self, b: bool) -> Self {
321        self.reorderable = b;
322        self
323    }
324
325    /// Whether a click on the header cycles the sort. Default `false`.
326    pub fn sortable(mut self, b: bool) -> Self {
327        self.sortable = b;
328        self
329    }
330
331    /// Whether the header offers the filter popover. Default `false`.
332    pub fn filterable(mut self, b: bool) -> Self {
333        self.filterable = b;
334        self
335    }
336
337    /// Which side, if any, the column is pinned to. Default `None`.
338    pub fn pinned(mut self, side: PinnedSide) -> Self {
339        self.pinned = side;
340        self
341    }
342
343    /// Stable column id.
344    pub fn id(&self) -> &str {
345        &self.id
346    }
347}
348
349/// Read access to a column's [`ColumnSpec`], whether the column is a bare
350/// spec (a [`TableHeader`](crate::TableHeader)'s) or a full [`Column<T>`] —
351/// so the width solver and the display order are written once for both.
352pub(crate) trait HasColumnSpec {
353    fn column_spec(&self) -> &ColumnSpec;
354}
355
356impl HasColumnSpec for ColumnSpec {
357    fn column_spec(&self) -> &ColumnSpec {
358        self
359    }
360}
361
362impl<T: 'static> HasColumnSpec for Column<T> {
363    fn column_spec(&self) -> &ColumnSpec {
364        &self.spec
365    }
366}
367
368/// Single column declaration. Column ids must be **stable, unique strings**
369/// — they're the persistence key for sort, filter, width, and ordering.
370pub struct Column<T: 'static> {
371    /// Everything the header needs; see [`ColumnSpec`].
372    pub(crate) spec: ColumnSpec,
373    pub(crate) editable: bool,
374    /// Per-column override of the view's [`EditTriggers`]; `None` inherits.
375    pub(crate) edit_triggers: Option<EditTriggers>,
376    pub(crate) truncation: TruncationPolicy,
377    pub(crate) cell: Rc<dyn Fn(&T, &CellContext) -> Box<dyn Widget>>,
378    pub(crate) header_override: Option<Rc<dyn Fn(&ColumnContext) -> Box<dyn Widget>>>,
379}
380
381impl<T: 'static> Column<T> {
382    /// Create a column with a stable id, a localized header label, and a
383    /// cell builder that takes `&T` plus a [`CellContext`] and returns a
384    /// boxed widget.
385    pub fn new(
386        id: impl Into<String>,
387        header: impl Into<LocalizedString>,
388        cell: impl Fn(&T, &CellContext) -> Box<dyn Widget> + 'static,
389    ) -> Self {
390        Self {
391            spec: ColumnSpec::new(id, header),
392            editable: false,
393            edit_triggers: None,
394            truncation: TruncationPolicy::default(),
395            cell: Rc::new(cell),
396            header_override: None,
397        }
398    }
399
400    pub fn width(mut self, w: ColumnWidth) -> Self {
401        self.spec = self.spec.width(w);
402        self
403    }
404
405    pub fn min_width(mut self, px: f32) -> Self {
406        self.spec = self.spec.min_width(px);
407        self
408    }
409
410    pub fn max_width(mut self, px: f32) -> Self {
411        self.spec = self.spec.max_width(px);
412        self
413    }
414
415    pub fn alignment(mut self, a: Alignment) -> Self {
416        self.spec = self.spec.alignment(a);
417        self
418    }
419
420    pub fn resizable(mut self, b: bool) -> Self {
421        self.spec = self.spec.resizable(b);
422        self
423    }
424
425    pub fn reorderable(mut self, b: bool) -> Self {
426        self.spec = self.spec.reorderable(b);
427        self
428    }
429
430    pub fn sortable(mut self, b: bool) -> Self {
431        self.spec = self.spec.sortable(b);
432        self
433    }
434
435    pub fn filterable(mut self, b: bool) -> Self {
436        self.spec = self.spec.filterable(b);
437        self
438    }
439
440    /// Mark the column as editable. Default `false`. F2 / type-to-edit
441    /// only enter edit mode on cells of editable columns; the
442    /// `on_cell_edit_request` hook also fires only for these. Cells of
443    /// non-editable columns continue to render their static delegate
444    /// regardless of `editing_cell`.
445    pub fn editable(mut self, b: bool) -> Self {
446        self.editable = b;
447        self
448    }
449
450    /// Override the view's [`EditTriggers`] for this column alone.
451    ///
452    /// The reason the set is not only table-wide: a table's columns rarely
453    /// want the same gesture. A tree column has to keep click-to-select and
454    /// double-click-to-open, while the plain value columns beside it are
455    /// exactly where one click to edit belongs. Unset columns inherit the
456    /// view's set.
457    pub fn edit_triggers(mut self, triggers: EditTriggers) -> Self {
458        self.edit_triggers = Some(triggers);
459        self
460    }
461
462    /// The triggers in force for this column, given the view's set — the
463    /// question the body pane and the key handler both ask, and the one an
464    /// application's own tests want to ask about their column set.
465    ///
466    /// A non-editable column never opens an editor, whatever either says.
467    pub fn effective_edit_triggers(&self, view: EditTriggers) -> EditTriggers {
468        if !self.editable {
469            return EditTriggers::NONE;
470        }
471        self.edit_triggers.unwrap_or(view)
472    }
473
474    pub fn pinned(mut self, side: PinnedSide) -> Self {
475        self.spec = self.spec.pinned(side);
476        self
477    }
478
479    pub fn truncation(mut self, p: TruncationPolicy) -> Self {
480        self.truncation = p;
481        self
482    }
483
484    /// Override the default header rendering (label + sort/filter
485    /// indicators). The closure receives a [`ColumnContext`] reflecting
486    /// the current sort/filter state.
487    pub fn header_override(
488        mut self,
489        f: impl Fn(&ColumnContext) -> Box<dyn Widget> + 'static,
490    ) -> Self {
491        self.header_override = Some(Rc::new(f));
492        self
493    }
494
495    /// Stable column id (the persistence key for sort, filter, width,
496    /// and ordering signals).
497    pub fn id(&self) -> &str {
498        &self.spec.id
499    }
500}
501
502impl<T: 'static> Clone for Column<T> {
503    fn clone(&self) -> Self {
504        Self {
505            spec: self.spec.clone(),
506            editable: self.editable,
507            edit_triggers: self.edit_triggers,
508            truncation: self.truncation,
509            cell: self.cell.clone(),
510            header_override: self.header_override.clone(),
511        }
512    }
513}
514
515impl<T: 'static> std::fmt::Debug for Column<T> {
516    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517        f.debug_struct("Column")
518            .field("id", &self.spec.id)
519            .field("width", &self.spec.width)
520            .field("alignment", &self.spec.alignment)
521            .field("pinned", &self.spec.pinned)
522            .finish()
523    }
524}