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}