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