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}