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}