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    SharedTableStyle, SortDirection, TableGridRecipe, TableHeaderCellConfig, TableRowConfig,
26    TableStyle,
27};
28use teksilo_core::widget_id::WidgetId;
29use teksilo_tokens::{CornerRadius, InputTokens, SurfaceRole, TargetRole};
30
31use crate::primitives::{RectWidget, ZStack};
32use teksilo_core::styles::density::{dp, spacing};
33
34// ─── IntUI design tokens for TableView / TreeTableView ─────────────────
35
36/// Body row height. Headers use `HEADER_HEIGHT`.
37pub const ROW_HEIGHT: f32 = 28.0;
38/// Sticky header row height.
39pub const HEADER_HEIGHT: f32 = 32.0;
40/// Horizontal padding inside each cell (also applied to header cells).
41pub const CELL_PADDING_HORIZONTAL: f32 = 8.0;
42/// Vertical padding inside each cell.
43pub const CELL_PADDING_VERTICAL: f32 = 4.0;
44/// Half-width of a column's resize grip on the header strip.
45///
46/// The grabbable band is centred on the divider and reaches this far into the
47/// cell on **each** side of it — 8 dp total, the same `PM_HeaderGripMargin`
48/// convention `QHeaderView` uses. A one-sided band would leave the outer half
49/// of every divider owned by the neighbouring cell's label, where a grab that
50/// missed by a pixel cycles the sort or starts a column-reorder drag instead.
51pub const RESIZE_HANDLE_WIDTH: f32 = 4.0;
52/// Step, in logical pixels, applied by one assistive-technology
53/// `Increment` / `Decrement` on a resizable column header — the non-pointer
54/// path to the resize grip.
55pub const COLUMN_RESIZE_STEP: f32 = 8.0;
56/// Stroke width of grid lines drawn between rows / columns.
57pub const GRID_LINE_THICKNESS: f32 = 1.0;
58/// Outer-frame corner radius.
59pub const CORNER_RADIUS: f32 = 4.0;
60/// Edge length of the sort-direction chevron in the header.
61pub const SORT_INDICATOR_SIZE: f32 = 10.0;
62/// Edge length of the filter glyph in the header.
63pub const FILTER_INDICATOR_SIZE: f32 = 12.0;
64/// Spacing between adjacent header cells (in addition to grid lines).
65pub const HEADER_INTER_CELL_SPACING: f32 = 0.0;
66/// Inset between the focused-cell bounds and the focus-ring stroke.
67pub const FOCUS_RING_INSET: f32 = 1.0;
68/// Default minimum column width, used when a column does not set its own.
69pub const MIN_COLUMN_WIDTH_DEFAULT: f32 = 32.0;
70/// `TreeTableView` only — pixels per indent level on the tree column.
71pub const TREE_INDENT_PER_LEVEL: f32 = 16.0;
72/// `TreeTableView` only — edge length of the twist (expand/collapse) chevron.
73pub const TREE_TWIST_SIZE: f32 = 12.0;
74/// `TreeTableView` only — gap between the twist chevron and the cell content.
75pub const TREE_TWIST_LABEL_GAP: f32 = 4.0;
76
77/// Configurable dimensions for [`RecipeTableStyle`].
78///
79/// All fields default to the corresponding `pub const` in this module so
80/// that `TableRecipe::default()` reproduces the IntUI look exactly.
81#[derive(Debug, Clone, Copy, PartialEq)]
82pub struct TableRecipe {
83    /// Body row height. Headers use `header_height`.
84    pub row_height: f32,
85    /// Sticky header row height.
86    pub header_height: f32,
87    /// Horizontal padding inside each cell (also applied to header cells).
88    pub cell_padding_horizontal: f32,
89    /// Vertical padding inside each cell.
90    pub cell_padding_vertical: f32,
91    /// Half-width of a column's resize grip: the grabbable band reaches this
92    /// far on **each** side of a divider (see [`RESIZE_HANDLE_WIDTH`]).
93    pub resize_handle_width: f32,
94    /// Stroke width of grid lines drawn between rows / columns.
95    pub grid_line_thickness: f32,
96    /// Outer-frame corner radius.
97    pub corner_radius: f32,
98    /// Edge length of the sort-direction chevron in the header.
99    pub sort_indicator_size: f32,
100    /// Edge length of the filter glyph in the header.
101    pub filter_indicator_size: f32,
102    /// Spacing between adjacent header cells (in addition to grid lines).
103    pub header_inter_cell_spacing: f32,
104    /// Inset between the focused-cell bounds and the focus-ring stroke.
105    pub focus_ring_inset: f32,
106    /// Default minimum column width, used when a column does not set its own.
107    pub min_column_width_default: f32,
108    /// `TreeTableView` only — pixels per indent level on the tree column.
109    pub tree_indent_per_level: f32,
110    /// `TreeTableView` only — edge length of the twist (expand/collapse) chevron.
111    pub tree_twist_size: f32,
112    /// `TreeTableView` only — gap between the twist chevron and the cell content.
113    pub tree_twist_label_gap: f32,
114}
115
116impl TableRecipe {
117    /// This recipe's dimensions resolved against a density's [`InputTokens`].
118    ///
119    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
120    /// ladder — so the shipped values below are the Compact column by
121    /// construction and cannot drift from it.
122    pub fn for_tokens(tokens: &InputTokens) -> Self {
123        Self {
124            row_height: dp(ROW_HEIGHT, TargetRole::Target, tokens),
125            header_height: dp(HEADER_HEIGHT, TargetRole::Target, tokens),
126            cell_padding_horizontal: spacing(CELL_PADDING_HORIZONTAL, tokens),
127            cell_padding_vertical: spacing(CELL_PADDING_VERTICAL, tokens),
128            resize_handle_width: RESIZE_HANDLE_WIDTH,
129            grid_line_thickness: GRID_LINE_THICKNESS,
130            corner_radius: CORNER_RADIUS,
131            sort_indicator_size: SORT_INDICATOR_SIZE,
132            filter_indicator_size: FILTER_INDICATOR_SIZE,
133            header_inter_cell_spacing: spacing(HEADER_INTER_CELL_SPACING, tokens),
134            focus_ring_inset: spacing(FOCUS_RING_INSET, tokens),
135            min_column_width_default: dp(MIN_COLUMN_WIDTH_DEFAULT, TargetRole::Target, tokens),
136            tree_indent_per_level: spacing(TREE_INDENT_PER_LEVEL, tokens),
137            tree_twist_size: TREE_TWIST_SIZE,
138            tree_twist_label_gap: spacing(TREE_TWIST_LABEL_GAP, tokens),
139        }
140    }
141}
142
143impl Default for TableRecipe {
144    fn default() -> Self {
145        Self::for_tokens(&InputTokens::default())
146    }
147}
148
149/// Default `TableStyle` shipped with Teksilo. The trait methods return
150/// reference subtrees; the widgets themselves still own their batched
151/// paint passes for performance (pending the chrome-decomposition
152/// follow-up).
153#[derive(Debug, Default, Clone, Copy)]
154pub struct RecipeTableStyle {
155    pub recipe: TableRecipe,
156}
157
158impl RecipeTableStyle {
159    pub fn new(recipe: TableRecipe) -> Self {
160        Self { recipe }
161    }
162
163    /// This style with every dimension resolved against a density's
164    /// [`InputTokens`], as `RecipeTableStyle::for_tokens(&ctx.theme().input)` at
165    /// the widget's own build site.
166    ///
167    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
168    /// tree gets exactly the values this module documents.
169    pub fn for_tokens(tokens: &InputTokens) -> Self {
170        Self {
171            recipe: TableRecipe::for_tokens(tokens),
172        }
173    }
174}
175
176impl TableStyle for RecipeTableStyle {
177    fn make_header_cell(&self, cfg: &TableHeaderCellConfig, ctx: &mut BuildContext) -> WidgetId {
178        // Reference shape: rounded surface that flips Hover when the
179        // pointer is over the cell, Pressed while a resize drag is
180        // active. The widget side handles label placement + sort
181        // indicator stacking; this is the body the cell sits on.
182        let hovered = cfg.is_hovered.clone();
183        let resizing = cfg.is_resizing.clone();
184        let role: Signal<SurfaceRole> = hovered.zip(&resizing).map(|(h, r)| {
185            if *r {
186                SurfaceRole::Pressed
187            } else if *h {
188                SurfaceRole::Hover
189            } else {
190                SurfaceRole::Transparent
191            }
192        });
193        let bg = ctx.add(
194            RectWidget::new()
195                .background(ColorProp::DynamicSurfaceRole(role))
196                .corner_radius(CornerRadius::uniform(self.recipe.corner_radius)),
197        );
198        ctx.add(ZStack::new().child(bg).child(cfg.label))
199    }
200
201    fn make_sort_indicator(&self, _direction: SortDirection, ctx: &mut BuildContext) -> WidgetId {
202        // Placeholder — TableView paints the indicator chevron directly
203        // for performance. Custom styles override.
204        ctx.add(crate::primitives::Spacer::new())
205    }
206
207    fn make_row_background(&self, cfg: &TableRowConfig, ctx: &mut BuildContext) -> WidgetId {
208        let alt = cfg.is_alt;
209        // Effective focus = the view holds keyboard focus AND the host window is
210        // active. Either signal absent → treat as satisfied (the stock TableView
211        // passes `None` and paints its own focus-/window-aware band directly).
212        let effective_focus: Option<Signal<bool>> = match (&cfg.is_focused, &cfg.is_window_active) {
213            (Some(f), Some(wa)) => Some(f.and(wa)),
214            (Some(f), None) => Some(f.clone()),
215            (None, Some(wa)) => Some(wa.clone()),
216            (None, None) => None,
217        };
218        let role: Signal<SurfaceRole> = match effective_focus {
219            // Focus-aware: vivid `Selected` only while focused+active, else the
220            // muted `SelectedInactive`.
221            Some(focus) => cfg
222                .is_selected
223                .clone()
224                .zip(&cfg.is_hovered)
225                .zip(&focus)
226                .map(move |((sel, hov), foc)| {
227                    if *sel {
228                        if *foc {
229                            SurfaceRole::Selected
230                        } else {
231                            SurfaceRole::SelectedInactive
232                        }
233                    } else if *hov {
234                        SurfaceRole::Hover
235                    } else if alt {
236                        SurfaceRole::AltRow
237                    } else {
238                        SurfaceRole::Transparent
239                    }
240                }),
241            None => cfg
242                .is_selected
243                .clone()
244                .zip(&cfg.is_hovered)
245                .map(move |(sel, hov)| {
246                    if *sel {
247                        SurfaceRole::Selected
248                    } else if *hov {
249                        SurfaceRole::Hover
250                    } else if alt {
251                        SurfaceRole::AltRow
252                    } else {
253                        SurfaceRole::Transparent
254                    }
255                }),
256        };
257        ctx.add(RectWidget::new().background(ColorProp::DynamicSurfaceRole(role)))
258    }
259
260    fn grid(&self) -> TableGridRecipe {
261        TableGridRecipe::default()
262    }
263
264    /// This style's own gutter, so a recipe built by a preset — Fluent's 12 dp
265    /// `ListViewItem` gutter, say — is the one the header cell pads by.
266    /// `tokens` is unused: the recipe was already resolved against a density
267    /// by [`TableRecipe::for_tokens`] at the widget's build site.
268    fn cell_padding_horizontal(&self, _tokens: &InputTokens) -> f32 {
269        self.recipe.cell_padding_horizontal
270    }
271
272    /// This style's own vertical gutter. See
273    /// [`cell_padding_horizontal`](Self::cell_padding_horizontal).
274    fn cell_padding_vertical(&self, _tokens: &InputTokens) -> f32 {
275        self.recipe.cell_padding_vertical
276    }
277
278    /// This style's own body row height, so a preset's row (macOS's 24 dp
279    /// `NSTableView` row, Fluent's 40 dp `ListViewItem`) is the one a
280    /// uniform-height table lays out. See
281    /// [`cell_padding_horizontal`](Self::cell_padding_horizontal) for why
282    /// `tokens` is unused.
283    fn row_height(&self, _tokens: &InputTokens) -> f32 {
284        self.recipe.row_height
285    }
286
287    /// This style's own column header height. See
288    /// [`row_height`](Self::row_height).
289    fn header_height(&self, _tokens: &InputTokens) -> f32 {
290        self.recipe.header_height
291    }
292}
293
294/// The `TableStyle` a `TableView` / `TreeTableView` builds against: the
295/// theme-wide `style_slots.table` if an app or a preset installed one, else
296/// this module's default resolved against the active density.
297///
298/// Shared by every site that needs it (the header cell's chrome and its
299/// gutter, the two views' filter-zone arithmetic, and the row and header
300/// heights the views and a standalone `TableHeader` lay out), so the gutter
301/// the cell pads by and the gutter the hit zone is measured from cannot
302/// disagree.
303pub fn resolve_table_style(ctx: &BuildContext) -> SharedTableStyle {
304    ctx.theme().style_slots.table.clone().unwrap_or_else(|| {
305        std::rc::Rc::new(RecipeTableStyle::for_tokens(&ctx.theme().input)) as SharedTableStyle
306    })
307}