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}