Skip to main content

teksilo_widgets/styles/
recipe_table_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `TableStyle` impl + table-family design tokens.
5//!
6//! Design tokens for the table family live as `pub const`s on this
7//! module, shared by `TableView` and `TreeTableView` (TreeTableView adds
8//! tree-only indent / twist sizing on top of the standard table dims).
9//!
10//! ## Wiring status
11//!
12//! The `make_*` trait methods produce reference subtrees for custom
13//! styles that want to install themselves via
14//! `style_slots.table = Some(...)`. The default IntUI shape is the
15//! one `TableView` / `TreeTableView` paint today inline; they continue to
16//! own their paint passes for performance reasons (grid lines need a
17//! single batched pass over the virtualized viewport — composing one
18//! `RectWidget` per line would defeat virtualization). The full
19//! chrome decomposition is deferred.
20
21use teksilo_core::build_context::BuildContext;
22use teksilo_core::color_prop::ColorProp;
23use teksilo_core::signal::Signal;
24use teksilo_core::styles::{
25    SortDirection, TableGridRecipe, TableHeaderCellConfig, TableRowConfig, TableStyle,
26};
27use teksilo_core::widget_id::WidgetId;
28use teksilo_tokens::{CornerRadius, SurfaceRole};
29
30use crate::primitives::{RectWidget, ZStack};
31
32// ─── IntUI design tokens for TableView / TreeTableView ─────────────────
33
34/// Body row height. Headers use `HEADER_HEIGHT`.
35pub const ROW_HEIGHT: f32 = 28.0;
36/// Sticky header row height.
37pub const HEADER_HEIGHT: f32 = 32.0;
38/// Horizontal padding inside each cell (also applied to header cells).
39pub const CELL_PADDING_HORIZONTAL: f32 = 8.0;
40/// Vertical padding inside each cell.
41pub const CELL_PADDING_VERTICAL: f32 = 4.0;
42/// Half-width of a column's resize grip on the header strip.
43///
44/// The grabbable band is centred on the divider and reaches this far into the
45/// cell on **each** side of it — 8 dp total, the same `PM_HeaderGripMargin`
46/// convention `QHeaderView` uses. A one-sided band would leave the outer half
47/// of every divider owned by the neighbouring cell's label, where a grab that
48/// missed by a pixel cycles the sort or starts a column-reorder drag instead.
49pub const RESIZE_HANDLE_WIDTH: f32 = 4.0;
50/// Step, in logical pixels, applied by one assistive-technology
51/// `Increment` / `Decrement` on a resizable column header — the non-pointer
52/// path to the resize grip.
53pub const COLUMN_RESIZE_STEP: f32 = 8.0;
54/// Stroke width of grid lines drawn between rows / columns.
55pub const GRID_LINE_THICKNESS: f32 = 1.0;
56/// Outer-frame corner radius.
57pub const CORNER_RADIUS: f32 = 4.0;
58/// Edge length of the sort-direction chevron in the header.
59pub const SORT_INDICATOR_SIZE: f32 = 10.0;
60/// Edge length of the filter glyph in the header.
61pub const FILTER_INDICATOR_SIZE: f32 = 12.0;
62/// Spacing between adjacent header cells (in addition to grid lines).
63pub const HEADER_INTER_CELL_SPACING: f32 = 0.0;
64/// Inset between the focused-cell bounds and the focus-ring stroke.
65pub const FOCUS_RING_INSET: f32 = 1.0;
66/// Default minimum column width, used when a column does not set its own.
67pub const MIN_COLUMN_WIDTH_DEFAULT: f32 = 32.0;
68/// `TreeTableView` only — pixels per indent level on the tree column.
69pub const TREE_INDENT_PER_LEVEL: f32 = 16.0;
70/// `TreeTableView` only — edge length of the twist (expand/collapse) chevron.
71pub const TREE_TWIST_SIZE: f32 = 12.0;
72/// `TreeTableView` only — gap between the twist chevron and the cell content.
73pub const TREE_TWIST_LABEL_GAP: f32 = 4.0;
74
75/// Configurable dimensions for [`RecipeTableStyle`].
76///
77/// All fields default to the corresponding `pub const` in this module so
78/// that `TableRecipe::default()` reproduces the IntUI look exactly.
79#[derive(Debug, Clone, Copy, PartialEq)]
80pub struct TableRecipe {
81    /// Body row height. Headers use `header_height`.
82    pub row_height: f32,
83    /// Sticky header row height.
84    pub header_height: f32,
85    /// Horizontal padding inside each cell (also applied to header cells).
86    pub cell_padding_horizontal: f32,
87    /// Vertical padding inside each cell.
88    pub cell_padding_vertical: f32,
89    /// Half-width of a column's resize grip: the grabbable band reaches this
90    /// far on **each** side of a divider (see [`RESIZE_HANDLE_WIDTH`]).
91    pub resize_handle_width: f32,
92    /// Stroke width of grid lines drawn between rows / columns.
93    pub grid_line_thickness: f32,
94    /// Outer-frame corner radius.
95    pub corner_radius: f32,
96    /// Edge length of the sort-direction chevron in the header.
97    pub sort_indicator_size: f32,
98    /// Edge length of the filter glyph in the header.
99    pub filter_indicator_size: f32,
100    /// Spacing between adjacent header cells (in addition to grid lines).
101    pub header_inter_cell_spacing: f32,
102    /// Inset between the focused-cell bounds and the focus-ring stroke.
103    pub focus_ring_inset: f32,
104    /// Default minimum column width, used when a column does not set its own.
105    pub min_column_width_default: f32,
106    /// `TreeTableView` only — pixels per indent level on the tree column.
107    pub tree_indent_per_level: f32,
108    /// `TreeTableView` only — edge length of the twist (expand/collapse) chevron.
109    pub tree_twist_size: f32,
110    /// `TreeTableView` only — gap between the twist chevron and the cell content.
111    pub tree_twist_label_gap: f32,
112}
113
114impl Default for TableRecipe {
115    fn default() -> Self {
116        Self {
117            row_height: ROW_HEIGHT,
118            header_height: HEADER_HEIGHT,
119            cell_padding_horizontal: CELL_PADDING_HORIZONTAL,
120            cell_padding_vertical: CELL_PADDING_VERTICAL,
121            resize_handle_width: RESIZE_HANDLE_WIDTH,
122            grid_line_thickness: GRID_LINE_THICKNESS,
123            corner_radius: CORNER_RADIUS,
124            sort_indicator_size: SORT_INDICATOR_SIZE,
125            filter_indicator_size: FILTER_INDICATOR_SIZE,
126            header_inter_cell_spacing: HEADER_INTER_CELL_SPACING,
127            focus_ring_inset: FOCUS_RING_INSET,
128            min_column_width_default: MIN_COLUMN_WIDTH_DEFAULT,
129            tree_indent_per_level: TREE_INDENT_PER_LEVEL,
130            tree_twist_size: TREE_TWIST_SIZE,
131            tree_twist_label_gap: TREE_TWIST_LABEL_GAP,
132        }
133    }
134}
135
136/// Default `TableStyle` shipped with Teksilo. The trait methods return
137/// reference subtrees; the widgets themselves still own their batched
138/// paint passes for performance (pending the chrome-decomposition
139/// follow-up).
140#[derive(Debug, Default, Clone, Copy)]
141pub struct RecipeTableStyle {
142    pub recipe: TableRecipe,
143}
144
145impl RecipeTableStyle {
146    pub fn new(recipe: TableRecipe) -> Self {
147        Self { recipe }
148    }
149}
150
151impl TableStyle for RecipeTableStyle {
152    fn make_header_cell(&self, cfg: &TableHeaderCellConfig, ctx: &mut BuildContext) -> WidgetId {
153        // Reference shape: rounded surface that flips Hover when the
154        // pointer is over the cell, Pressed while a resize drag is
155        // active. The widget side handles label placement + sort
156        // indicator stacking; this is the body the cell sits on.
157        let hovered = cfg.is_hovered.clone();
158        let resizing = cfg.is_resizing.clone();
159        let role: Signal<SurfaceRole> = hovered.zip(&resizing).map(|(h, r)| {
160            if *r {
161                SurfaceRole::Pressed
162            } else if *h {
163                SurfaceRole::Hover
164            } else {
165                SurfaceRole::Transparent
166            }
167        });
168        let bg = ctx.add(
169            RectWidget::new()
170                .background(ColorProp::DynamicSurfaceRole(role))
171                .corner_radius(CornerRadius::uniform(self.recipe.corner_radius)),
172        );
173        ctx.add(ZStack::new().add_child(bg).add_child(cfg.label))
174    }
175
176    fn make_sort_indicator(&self, _direction: SortDirection, ctx: &mut BuildContext) -> WidgetId {
177        // Placeholder — TableView paints the indicator chevron directly
178        // for performance. Custom styles override.
179        ctx.add(crate::primitives::Spacer::new())
180    }
181
182    fn make_row_background(&self, cfg: &TableRowConfig, ctx: &mut BuildContext) -> WidgetId {
183        let alt = cfg.is_alt;
184        // Effective focus = the view holds keyboard focus AND the host window is
185        // active. Either signal absent → treat as satisfied (the stock TableView
186        // passes `None` and paints its own focus-/window-aware band directly).
187        let effective_focus: Option<Signal<bool>> = match (&cfg.is_focused, &cfg.is_window_active) {
188            (Some(f), Some(wa)) => Some(f.and(wa)),
189            (Some(f), None) => Some(f.clone()),
190            (None, Some(wa)) => Some(wa.clone()),
191            (None, None) => None,
192        };
193        let role: Signal<SurfaceRole> = match effective_focus {
194            // Focus-aware: vivid `Selected` only while focused+active, else the
195            // muted `SelectedInactive`.
196            Some(focus) => cfg
197                .is_selected
198                .clone()
199                .zip(&cfg.is_hovered)
200                .zip(&focus)
201                .map(move |((sel, hov), foc)| {
202                    if *sel {
203                        if *foc {
204                            SurfaceRole::Selected
205                        } else {
206                            SurfaceRole::SelectedInactive
207                        }
208                    } else if *hov {
209                        SurfaceRole::Hover
210                    } else if alt {
211                        SurfaceRole::AltRow
212                    } else {
213                        SurfaceRole::Transparent
214                    }
215                }),
216            None => cfg
217                .is_selected
218                .clone()
219                .zip(&cfg.is_hovered)
220                .map(move |(sel, hov)| {
221                    if *sel {
222                        SurfaceRole::Selected
223                    } else if *hov {
224                        SurfaceRole::Hover
225                    } else if alt {
226                        SurfaceRole::AltRow
227                    } else {
228                        SurfaceRole::Transparent
229                    }
230                }),
231        };
232        ctx.add(RectWidget::new().background(ColorProp::DynamicSurfaceRole(role)))
233    }
234
235    fn grid(&self) -> TableGridRecipe {
236        TableGridRecipe::default()
237    }
238}