teksilo_core/styles/component_style_slots.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Typed `Rc<dyn FooStyle>` slot bag — the theme-wide override channel
5//! for the four-tier styling system.
6//!
7//! Each themable widget reads its slot like:
8//!
9//! ```ignore
10//! let style = self.style_override
11//! .clone()
12//! .or_else(|| ctx.theme().style_slots.button.clone())
13//! .unwrap_or_else(|| Rc::new(RecipeButtonStyle::default()));
14//! ```
15//!
16//! Per-call `.style(...)` overrides win first; theme-wide
17//! `style_slots.button = Some(...)` wins second; the widget's local
18//! `Recipe*Style` default is the fallback.
19//!
20//! `Option` per slot rather than a populated default because the
21//! `Recipe*Style` types live in `teksilo-widgets` and can't be imported by
22//! `teksilo-core` (cycle). Apps that want theme-wide custom styles
23//! install them explicitly:
24//!
25//! ```ignore
26//! let mut theme = intui::light();
27//! theme.style_slots.button = Some(Rc::new(MyGlassButton));
28//! ```
29
30use crate::styles::{
31 SharedAvatarStyle, SharedBadgeStyle, SharedBannerStyle, SharedButtonStyle, SharedCalendarStyle,
32 SharedCardStyle, SharedChartStyle, SharedCheckboxStyle, SharedColorPickerStyle,
33 SharedComboBoxStyle, SharedDateEditStyle, SharedDialogStyle, SharedDropTargetStyle,
34 SharedDropZoneStyle, SharedGridViewStyle, SharedIconButtonStyle, SharedLinkStyle,
35 SharedListContainerStyle, SharedMenuItemStyle, SharedPanelStyle, SharedPopoverStyle,
36 SharedProgressBarStyle, SharedRadioStyle, SharedRadioTileStyle, SharedRichTextEditorStyle,
37 SharedScrollBarStyle, SharedSearchFieldStyle, SharedSegmentedControlStyle, SharedSliderStyle,
38 SharedSnackbarStyle, SharedSpinBoxStyle, SharedSplitButtonStyle, SharedSplitterStyle,
39 SharedStandardItemStyle, SharedTabStyle, SharedTableStyle, SharedTextInputStyle,
40 SharedTextSelectionStyle, SharedToastStyle, SharedToggleStyle, SharedTooltipStyle,
41 SharedWebViewStyle,
42};
43
44/// Typed slot bag living on [`crate::styles::Theme`]. One slot per
45/// themable widget. `None` means "use the widget's local default
46/// `Recipe*Style`"; `Some(rc)` installs the override theme-wide.
47#[derive(Default, Clone)]
48pub struct ComponentStyleSlots {
49 pub button: Option<SharedButtonStyle>,
50 pub split_button: Option<SharedSplitButtonStyle>,
51 pub splitter: Option<SharedSplitterStyle>,
52 pub icon_button: Option<SharedIconButtonStyle>,
53 pub toggle: Option<SharedToggleStyle>,
54 pub checkbox: Option<SharedCheckboxStyle>,
55 pub radio: Option<SharedRadioStyle>,
56 pub radio_tile: Option<SharedRadioTileStyle>,
57 pub slider: Option<SharedSliderStyle>,
58 pub text_input: Option<SharedTextInputStyle>,
59 pub combo_box: Option<SharedComboBoxStyle>,
60 pub menu_item: Option<SharedMenuItemStyle>,
61 pub panel: Option<SharedPanelStyle>,
62 pub card: Option<SharedCardStyle>,
63 pub chart: Option<SharedChartStyle>,
64 pub popover: Option<SharedPopoverStyle>,
65 pub tooltip: Option<SharedTooltipStyle>,
66 pub scroll_bar: Option<SharedScrollBarStyle>,
67 pub standard_item: Option<SharedStandardItemStyle>,
68 pub tab: Option<SharedTabStyle>,
69 pub dialog: Option<SharedDialogStyle>,
70 pub snackbar: Option<SharedSnackbarStyle>,
71 pub toast: Option<SharedToastStyle>,
72 pub banner: Option<SharedBannerStyle>,
73 pub badge: Option<SharedBadgeStyle>,
74 pub progress_bar: Option<SharedProgressBarStyle>,
75 pub link: Option<SharedLinkStyle>,
76 pub segmented_control: Option<SharedSegmentedControlStyle>,
77 pub avatar: Option<SharedAvatarStyle>,
78 pub calendar: Option<SharedCalendarStyle>,
79 pub color_picker: Option<SharedColorPickerStyle>,
80 pub spin_box: Option<SharedSpinBoxStyle>,
81 pub date_edit: Option<SharedDateEditStyle>,
82 pub search_field: Option<SharedSearchFieldStyle>,
83 pub rich_text_editor: Option<SharedRichTextEditorStyle>,
84 pub table: Option<SharedTableStyle>,
85 pub list_container: Option<SharedListContainerStyle>,
86 pub drop_zone: Option<SharedDropZoneStyle>,
87 pub drop_target: Option<SharedDropTargetStyle>,
88 pub grid_view: Option<SharedGridViewStyle>,
89 pub web_view: Option<SharedWebViewStyle>,
90 /// Touch text-selection chrome — the selection handles and the magnifier.
91 /// See [`TextSelectionStyle`](crate::styles::TextSelectionStyle).
92 pub text_selection: Option<SharedTextSelectionStyle>,
93}
94
95/// Every slot's name, written **once**, expanded through by each probe below.
96///
97/// [`ComponentStyleSlots::NAMES`], [`ComponentStyleSlots::installed`],
98/// [`ComponentStyleSlots::unchanged_against`] and the `Debug` and `PartialEq`
99/// impls all expand this one roster, so none of them can be the copy that fell
100/// behind: a hand-written list per asker is how one of them loses a slot, which
101/// for `installed` means an override that is silently never reported. Every
102/// probe except `NAMES` also destructures `Self` without a `..` rest pattern, so
103/// a slot added to the struct and not to this roster is a compile error.
104macro_rules! for_every_slot {
105 ($probe:ident) => {
106 $probe!(
107 button,
108 split_button,
109 splitter,
110 icon_button,
111 toggle,
112 checkbox,
113 radio,
114 radio_tile,
115 slider,
116 text_input,
117 combo_box,
118 menu_item,
119 panel,
120 card,
121 chart,
122 popover,
123 tooltip,
124 scroll_bar,
125 standard_item,
126 tab,
127 dialog,
128 snackbar,
129 toast,
130 banner,
131 badge,
132 progress_bar,
133 link,
134 segmented_control,
135 avatar,
136 calendar,
137 color_picker,
138 spin_box,
139 date_edit,
140 search_field,
141 rich_text_editor,
142 table,
143 list_container,
144 drop_zone,
145 drop_target,
146 grid_view,
147 web_view,
148 text_selection
149 )
150 };
151}
152
153impl ComponentStyleSlots {
154 /// Every slot's name, in declaration order: the roster that
155 /// [`installed`](Self::installed) and
156 /// [`unchanged_against`](Self::unchanged_against) answer from.
157 ///
158 /// For a test that wants to say which slots a preset fills and which it
159 /// deliberately leaves to the widget's default, as set equality against
160 /// this list rather than against a hand-copied one.
161 pub const NAMES: &'static [&'static str] = {
162 macro_rules! probe {
163 ($($name:ident),* $(,)?) => {
164 &[$(stringify!($name)),*]
165 };
166 }
167 for_every_slot!(probe)
168 };
169
170 /// The names of the slots that carry an override, in declaration order.
171 ///
172 /// The direct "what is installed here" question, kept for diagnostics and
173 /// tests. Its original consumer — the target-conformance audit's
174 /// [`unprojected_style_slots`](crate::accessibility::target_audit::unprojected_style_slots)
175 /// — now asks [`unchanged_against`](Self::unchanged_against) instead,
176 /// comparing the theme derived at two densities.
177 ///
178 /// The body destructures `Self` **without** a `..` rest pattern, so adding a
179 /// slot to the struct and forgetting it in `for_every_slot!` is a compile
180 /// error rather than a silently unreported override.
181 pub fn installed(&self) -> Vec<&'static str> {
182 macro_rules! probe {
183 ($($name:ident),* $(,)?) => {{
184 let Self { $($name),* } = self;
185 let mut out = Vec::new();
186 $(if $name.is_some() {
187 out.push(stringify!($name));
188 })*
189 out
190 }};
191 }
192 for_every_slot!(probe)
193 }
194
195 /// The slots installed here that are **the same object** in `other` — the
196 /// ones a re-derivation left alone.
197 ///
198 /// One consumer, and it is the same one [`installed`](Self::installed)
199 /// has: the target-conformance audit's
200 /// [`unprojected_style_slots`](crate::accessibility::target_audit::unprojected_style_slots),
201 /// which asks a theme for the styles no
202 /// [`DensityProjection`](crate::styles::DensityProjection) rebuilds by
203 /// deriving it at two densities and comparing.
204 ///
205 /// `Rc::ptr_eq`, not any comparison of contents: a Tier-3 style is a trait
206 /// object with no equality of its own, and identity is the right question.
207 /// A projection that re-derives a slot builds a fresh `Rc` for it, so a slot
208 /// still pointing at the same allocation at two densities is one the ladder
209 /// did not reach — whoever installed it.
210 ///
211 /// The body destructures `self` without a `..` rest pattern, for the reason
212 /// [`installed`](Self::installed) does: adding a slot to the struct and
213 /// forgetting it in `for_every_slot!` is a compile error rather than a
214 /// silent omission. Both probes expand through that one roster, so neither
215 /// can be the copy that fell behind.
216 pub fn unchanged_against(&self, other: &Self) -> Vec<&'static str> {
217 fn same<T: ?Sized>(a: &Option<std::rc::Rc<T>>, b: &Option<std::rc::Rc<T>>) -> bool {
218 matches!((a, b), (Some(x), Some(y)) if std::rc::Rc::ptr_eq(x, y))
219 }
220 macro_rules! probe {
221 ($($name:ident),* $(,)?) => {{
222 // The destructure is what makes this exhaustive: a slot added
223 // to the struct and not listed below fails to compile here.
224 // `other` is then read field by field under the same names.
225 let Self { $($name),* } = self;
226 let mut out = Vec::new();
227 $(if same($name, &other.$name) {
228 out.push(stringify!($name));
229 })*
230 out
231 }};
232 }
233 for_every_slot!(probe)
234 }
235
236 /// Whether no slot carries an override — the state every shipped preset
237 /// that ships raw tokens alone is in.
238 pub fn is_empty(&self) -> bool {
239 self.installed().is_empty()
240 }
241}
242
243impl std::fmt::Debug for ComponentStyleSlots {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 // Hand-rolled because `Rc<dyn FooStyle>` doesn't impl Debug.
246 // Show which slots are populated (Some/None): the actual
247 // chrome behaviour isn't introspectable.
248 macro_rules! probe {
249 ($($name:ident),* $(,)?) => {{
250 // No `..`: a slot missing from the roster fails to compile.
251 let Self { $($name),* } = self;
252 f.debug_struct("ComponentStyleSlots")
253 $(.field(stringify!($name), &$name.is_some()))*
254 .finish()
255 }};
256 }
257 for_every_slot!(probe)
258 }
259}
260
261impl PartialEq for ComponentStyleSlots {
262 fn eq(&self, other: &Self) -> bool {
263 // Rc trait-object pointer-equality is the only meaningful "are
264 // these the same style" check. Used for theme-equality (mostly
265 // tests + cache keys).
266 fn rc_eq<T: ?Sized>(a: &Option<std::rc::Rc<T>>, b: &Option<std::rc::Rc<T>>) -> bool {
267 match (a, b) {
268 (None, None) => true,
269 (Some(x), Some(y)) => std::rc::Rc::ptr_eq(x, y),
270 _ => false,
271 }
272 }
273 macro_rules! probe {
274 ($($name:ident),* $(,)?) => {{
275 // No `..`: a slot missing from the roster fails to compile.
276 let Self { $($name),* } = self;
277 true $(&& rc_eq($name, &other.$name))*
278 }};
279 }
280 for_every_slot!(probe)
281 }
282}
283
284// `styles.rs` declares this module with `#[path]`, so a child module resolves
285// from `styles/`, not from a directory named after this file.
286#[cfg(test)]
287#[path = "component_style_slots/tests.rs"]
288mod tests;