teksilo_core/styles/theme.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The complete theme aggregator.
5//!
6//! `Theme` lives in `teksilo-core` (not `teksilo-tokens`) so the per-widget
7//! style trait protocols and the typed `Rc<dyn FooStyle>` slots in
8//! [`ComponentStyleSlots`] can sit on the same struct without forcing a
9//! dependency cycle. See the `docs/styling-system.md` reference for the
10//! four-tier ladder this type anchors.
11//!
12//! Construct via a preset — there is no `Theme::default()` /
13//! `Theme::*_default()`. Apps explicitly pick one:
14//!
15//! ```
16//! use teksilo_core::presets::intui;
17//! let theme = intui::light();
18//! ```
19//!
20//! `appearance` is required and drives shadow density, OS-theme
21//! matching, and asset variant selection. `extensions` is a typed
22//! registry for app-attached extras; see [`ThemeExtensions`].
23
24use std::borrow::Cow;
25
26use serde::{Deserialize, Serialize};
27
28use teksilo_tokens::{
29 ColorTokens, InputTokens, LayoutTokens, MotionTokens, ShapeTokens, TargetDensity,
30 TypographyTokens,
31};
32
33use crate::styles::component_style_slots::ComponentStyleSlots;
34use crate::styles::theme_appearance::ThemeAppearance;
35use crate::styles::theme_extension::ThemeExtensions;
36
37/// Stable identity for a [`Theme`], independent of its token values.
38///
39/// Two themes that share a `ThemeId` are "the same theme" even after a
40/// token tweak, and two distinct themes are always distinguishable even
41/// if they happen to share an appearance (Light/Dark). This is what lets
42/// UI like `ThemeSwitcher` reliably match the active theme back to a list
43/// entry, where `appearance` alone would be ambiguous.
44///
45/// Preset constructors stamp a `family.variant` id (e.g. `"intui.light"`,
46/// `"fluent.dark"`). OS-driven themes (follow-system / native) carry the
47/// id `"system"`. A theme built from raw tokens defaults to `"custom"`.
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub struct ThemeId(Cow<'static, str>);
50
51impl ThemeId {
52 /// Construct from a static string (`ThemeId::new("intui.light")`) or an
53 /// owned `String` for app-supplied custom themes.
54 pub fn new(id: impl Into<Cow<'static, str>>) -> Self {
55 Self(id.into())
56 }
57
58 /// The id as a string slice.
59 pub fn as_str(&self) -> &str {
60 &self.0
61 }
62}
63
64impl Default for ThemeId {
65 fn default() -> Self {
66 Self(Cow::Borrowed("custom"))
67 }
68}
69
70impl std::fmt::Display for ThemeId {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct Theme {
78 /// Stable identity of this theme — see [`ThemeId`]. Serde-defaulted so
79 /// older serialized themes (which predate the field) still deserialize.
80 #[serde(default)]
81 pub id: ThemeId,
82 pub appearance: ThemeAppearance,
83 pub colors: ColorTokens,
84 pub layout: LayoutTokens,
85 pub typography: TypographyTokens,
86 pub shape: ShapeTokens,
87 pub motion: MotionTokens,
88 /// Input and density tokens — target sizes, gesture slop, scroll physics
89 /// and the touch kill switch. See [`InputTokens`] and
90 /// `docs/density-and-targets.md`.
91 ///
92 /// Serde-defaulted for the same reason [`Theme::id`](Self::id) is: `Theme`
93 /// derives `Deserialize`, and a theme serialized before this field existed
94 /// must still load. The default is the Compact ladder — today's behaviour.
95 #[serde(default)]
96 pub input: InputTokens,
97 /// Typed `Rc<dyn FooStyle>` slot bag for theme-wide style
98 /// installations. `None` per slot means "use the widget's local
99 /// `Recipe*Style` default"; apps install per-theme overrides via
100 /// `theme.style_slots.button = Some(Rc::new(MyButton))`. Per-call
101 /// `.style(...)` on a widget always wins over the slot.
102 #[serde(skip, default)]
103 pub style_slots: ComponentStyleSlots,
104 #[serde(skip, default)]
105 pub extensions: ThemeExtensions,
106}
107
108/// How a theme re-derives itself for another [`TargetDensity`], registered as
109/// a [`ThemeExtensions`] entry.
110///
111/// Absent — the default, and what every raw-token theme gets — means
112/// [`Theme::with_density`] swaps [`Theme::input`] and changes nothing else.
113/// That is right for a theme whose `style_slots` are all `None`, because each
114/// widget then builds its own `Recipe*Style` from `ctx.theme().input` at its
115/// next build.
116///
117/// A **preset that installs Tier-3 slots of its own** (Fluent, macOS,
118/// Material 3, KDE) needs more: its slots are `Some(..)`, so they would ride across
119/// a density switch still carrying the dimensions they were built with, and a
120/// Fluent button would stay 32 dp tall under Touch. Such a preset registers
121/// this — a function taking the *current* theme, so it can recover the palette
122/// it was built from (`theme.extension::<FluentPalette>()`) and rebuild only
123/// the slots it owns, leaving colours, id, other extensions and any slot the
124/// app installed itself alone.
125///
126/// It lives in the extension registry rather than as a `Theme` field for the
127/// reason the registry exists: it is optional, typed, non-serializable state
128/// that only some themes carry.
129#[derive(Clone, Copy)]
130pub struct DensityProjection(pub fn(&Theme, TargetDensity) -> Theme);
131
132impl std::fmt::Debug for DensityProjection {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.write_str("DensityProjection(<fn>)")
135 }
136}
137
138/// The window and accessibility state a frame is painted under: the inputs of
139/// [`Theme::for_paint_state`].
140///
141/// A fresh tree paints with `window_active: true, high_contrast: false`,
142/// under which [`Theme::for_paint_state`] returns the theme unchanged when no
143/// [`PaintProjection`] is registered.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub struct PaintState {
146 /// The host window is focused and not occluded.
147 pub window_active: bool,
148 /// The OS "increase contrast" preference is set.
149 pub high_contrast: bool,
150}
151
152/// How a theme re-colours itself for a [`PaintState`]: registered in the
153/// theme's [extension registry](ThemeExtensions) and consulted by
154/// [`Theme::for_paint_state`].
155///
156/// Absent, which is the default and what every raw-token theme and the IntUI
157/// preset get, means the generic chain: [`Theme::for_inactive_window`] when
158/// the window is inactive, then [`Theme::for_high_contrast`] when the
159/// high-contrast preference is set. That chain is a fixed transform of the
160/// tokens (desaturate the accent family, then lift the accent, the ring, the
161/// secondary text and the selection until they reach the high-contrast
162/// floors), which is right for a palette that defines no inactive or
163/// high-contrast colours of its own.
164///
165/// A preset whose source palette **does** define them (a desktop colour
166/// scheme with an inactive colour group, say) registers this to paint those
167/// colours instead. The function receives the *active* theme and the whole
168/// state at once, so it decides how the two flags combine; it replaces the
169/// generic chain entirely, and calling [`Theme::for_inactive_window`] or
170/// [`Theme::for_high_contrast`] from it is how it keeps a part of that chain.
171/// Only colours should change: the paint walker swaps the result in without a
172/// relayout, so geometry a hook changes is not honoured.
173///
174/// It lives in the extension registry for the reason [`DensityProjection`]
175/// does: it is optional, typed, non-serializable state only some themes carry.
176#[derive(Clone, Copy)]
177pub struct PaintProjection(pub fn(&Theme, PaintState) -> Theme);
178
179impl std::fmt::Debug for PaintProjection {
180 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181 f.write_str("PaintProjection(<fn>)")
182 }
183}
184
185impl Theme {
186 /// Build a Theme from raw token data. Most apps go through a
187 /// preset constructor (e.g. `teksilo_core::presets::intui::light`)
188 /// rather than calling this directly — presets aggregate the
189 /// matching `Recipe*Style` defaults under the same call.
190 pub fn new(
191 appearance: ThemeAppearance,
192 colors: ColorTokens,
193 layout: LayoutTokens,
194 typography: TypographyTokens,
195 shape: ShapeTokens,
196 motion: MotionTokens,
197 input: InputTokens,
198 ) -> Self {
199 Self {
200 id: ThemeId::default(),
201 appearance,
202 colors,
203 layout,
204 typography,
205 shape,
206 motion,
207 input,
208 style_slots: ComponentStyleSlots::default(),
209 extensions: ThemeExtensions::new(),
210 }
211 }
212
213 /// Register how this theme re-derives itself for another density. Preset
214 /// constructors call this; see [`DensityProjection`].
215 pub fn with_density_projection(self, project: fn(&Theme, TargetDensity) -> Theme) -> Self {
216 self.with_extension(DensityProjection(project))
217 }
218
219 /// Register how this theme re-colours itself for an inactive window and
220 /// the high-contrast preference; see [`PaintProjection`].
221 pub fn with_paint_projection(self, project: fn(&Theme, PaintState) -> Theme) -> Self {
222 self.with_extension(PaintProjection(project))
223 }
224
225 /// Set this theme's [`ThemeId`] and return self for chaining. Used by
226 /// preset constructors and apps building custom themes.
227 pub fn with_id(mut self, id: impl Into<Cow<'static, str>>) -> Self {
228 self.id = ThemeId::new(id);
229 self
230 }
231
232 /// Whether this theme paints on a dark background. Convenience for
233 /// `theme.appearance.is_dark()`.
234 pub fn is_dark(&self) -> bool {
235 self.appearance.is_dark()
236 }
237
238 /// A copy of this theme projected for an **inactive window** — the accent
239 /// family and focus indicators desaturated toward graphite (see
240 /// [`ColorTokens::for_inactive_window`](teksilo_tokens::ColorTokens::for_inactive_window)).
241 /// The paint walker swaps this in when the host window loses focus, so every
242 /// accent-coloured control greys out with no per-widget code. Only the
243 /// colours change; typography / layout / shape / motion are untouched, so
244 /// this never affects layout.
245 pub fn for_inactive_window(&self) -> Theme {
246 Theme {
247 colors: self.colors.for_inactive_window(),
248 ..self.clone()
249 }
250 }
251
252 /// Project into a high-contrast variant (WCAG 1.4.6 Enhanced / EN 301 549
253 /// §11.7), applied at paint time when the OS "increase contrast" preference
254 /// is set. See
255 /// [`ColorTokens::for_high_contrast`](teksilo_tokens::ColorTokens::for_high_contrast).
256 pub fn for_high_contrast(&self) -> Theme {
257 Theme {
258 colors: self.colors.for_high_contrast(),
259 ..self.clone()
260 }
261 }
262
263 /// The theme a frame is painted with under `state`: what the paint walker
264 /// swaps in, and what a recipe that bakes a colour at build time must
265 /// resolve against so it agrees with the paint-resolved controls.
266 ///
267 /// A theme that carries a [`PaintProjection`] gets that function's
268 /// answer. Otherwise this is the generic chain:
269 /// [`for_inactive_window`](Self::for_inactive_window) when
270 /// `!state.window_active`, then
271 /// [`for_high_contrast`](Self::for_high_contrast) when
272 /// `state.high_contrast` (so the desaturated accent and ring of an
273 /// inactive window are lifted to the high-contrast floors too), and an
274 /// unchanged copy when neither applies.
275 pub fn for_paint_state(&self, state: PaintState) -> Theme {
276 if let Some(PaintProjection(project)) = self.extension::<PaintProjection>().copied() {
277 return project(self, state);
278 }
279 Theme {
280 colors: generic_paint_colors(&self.colors, state),
281 ..self.clone()
282 }
283 }
284
285 /// The colours of [`for_paint_state`](Self::for_paint_state), without
286 /// cloning the rest of the theme when no [`PaintProjection`] is
287 /// registered. For a colour resolved on every paint (a recipe's
288 /// `ColorProp::Bound`), where only the palette is read.
289 pub fn paint_colors(&self, state: PaintState) -> ColorTokens {
290 if let Some(PaintProjection(project)) = self.extension::<PaintProjection>().copied() {
291 return project(self, state).colors;
292 }
293 generic_paint_colors(&self.colors, state)
294 }
295
296 /// A copy of this theme projected onto another [`TargetDensity`].
297 ///
298 /// This is a **token projection only**: it replaces [`Self::input`] with
299 /// [`InputTokens::for_density`] and carries `style_slots` and `extensions`
300 /// across verbatim. It deliberately does *not* re-run any recipe
301 /// constructor, because there is nothing in such a theme to re-run — every
302 /// `ComponentStyleSlots` slot is `None` in a raw-token theme and in the
303 /// IntUI preset, and each
304 /// widget builds its `Recipe*Style` lazily at its own build site, in
305 /// `teksilo-widgets`, from `ctx.theme().input`. Changing the tokens here is
306 /// therefore sufficient; the widgets read the new values on their next
307 /// build.
308 ///
309 /// A slot an app has installed itself is `Some(..)` and is preserved as it
310 /// is, so a custom Tier-3 style keeps whatever dimensions it was written
311 /// with. That is intentional (a hand-written style owns its own metrics)
312 /// but it means a custom style is not density-aware unless its author made
313 /// it so.
314 ///
315 /// A theme that carries a [`DensityProjection`] — every shipped preset that
316 /// installs slots — takes that function's answer instead, so its own chrome
317 /// does follow the density.
318 ///
319 /// Use `WidgetTree::set_input_density` rather than calling this and
320 /// `set_theme` by hand: a density change must rebuild, not merely relayout.
321 pub fn with_density(&self, density: TargetDensity) -> Theme {
322 if let Some(DensityProjection(project)) = self.extension::<DensityProjection>().copied() {
323 return project(self, density);
324 }
325 Theme {
326 input: InputTokens::for_density(density),
327 ..self.clone()
328 }
329 }
330
331 /// Look up a typed theme extension. See [`ThemeExtensions`].
332 pub fn extension<T: std::any::Any + Send + Sync>(&self) -> Option<&T> {
333 self.extensions.get::<T>()
334 }
335
336 /// Attach a typed extension and return self for chaining. See
337 /// [`ThemeExtensions`].
338 pub fn with_extension<T: std::any::Any + Send + Sync>(mut self, value: T) -> Self {
339 self.extensions.insert(value);
340 self
341 }
342}
343
344/// The generic chain behind [`Theme::for_paint_state`] when no
345/// [`PaintProjection`] is registered: the token halves of
346/// [`Theme::for_inactive_window`] and [`Theme::for_high_contrast`], which
347/// change only the colours.
348fn generic_paint_colors(colors: &ColorTokens, state: PaintState) -> ColorTokens {
349 match (state.window_active, state.high_contrast) {
350 (true, false) => colors.clone(),
351 (false, false) => colors.for_inactive_window(),
352 (true, true) => colors.for_high_contrast(),
353 (false, true) => colors.for_inactive_window().for_high_contrast(),
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::presets::intui;
361 use teksilo_tokens::TargetDensity;
362
363 const ALL_PAINT_STATES: [PaintState; 4] = [
364 PaintState {
365 window_active: true,
366 high_contrast: false,
367 },
368 PaintState {
369 window_active: false,
370 high_contrast: false,
371 },
372 PaintState {
373 window_active: true,
374 high_contrast: true,
375 },
376 PaintState {
377 window_active: false,
378 high_contrast: true,
379 },
380 ];
381
382 /// The paint walker's projection as it was written before
383 /// `for_paint_state` existed, kept verbatim as the oracle.
384 fn pre_hook_chain(theme: &Theme, state: PaintState) -> Theme {
385 let base = if state.window_active {
386 theme.clone()
387 } else {
388 theme.for_inactive_window()
389 };
390 if state.high_contrast {
391 base.for_high_contrast()
392 } else {
393 base
394 }
395 }
396
397 const HOOK_INACTIVE: &str = "#FF00FF";
398 const HOOK_HIGH_CONTRAST: &str = "#00FF00";
399
400 /// A projection that marks each state with a colour no generic transform
401 /// produces, so a test can tell which path painted.
402 fn marking_projection(theme: &Theme, state: PaintState) -> Theme {
403 let mut out = theme.clone();
404 if !state.window_active {
405 out.colors.accent = teksilo_tokens::Color::from_hex(HOOK_INACTIVE);
406 }
407 if state.high_contrast {
408 out.colors.accent = teksilo_tokens::Color::from_hex(HOOK_HIGH_CONTRAST);
409 }
410 out
411 }
412
413 /// Without a hook, the default output is the old chain exactly, for every
414 /// state and both IntUI appearances.
415 #[test]
416 fn for_paint_state_without_a_hook_is_the_pre_hook_chain() {
417 for theme in [intui::light(), intui::dark()] {
418 assert!(theme.extension::<PaintProjection>().is_none());
419 for state in ALL_PAINT_STATES {
420 let expected = pre_hook_chain(&theme, state);
421 assert_eq!(theme.for_paint_state(state), expected, "{state:?}");
422 assert_eq!(theme.paint_colors(state), expected.colors, "{state:?}");
423 }
424 // Active and not high-contrast is the identity.
425 assert_eq!(theme.for_paint_state(ALL_PAINT_STATES[0]), theme);
426 }
427 }
428
429 /// A registered hook replaces the generic chain, in both entry points.
430 #[test]
431 fn a_paint_projection_replaces_the_generic_chain() {
432 let theme = intui::light().with_paint_projection(marking_projection);
433 for state in ALL_PAINT_STATES {
434 let expected = marking_projection(&theme, state);
435 assert_eq!(theme.for_paint_state(state), expected, "{state:?}");
436 assert_eq!(theme.paint_colors(state), expected.colors, "{state:?}");
437 }
438 assert_eq!(
439 format!("{:?}", theme.extension::<PaintProjection>().unwrap()),
440 "PaintProjection(<fn>)"
441 );
442 }
443
444 /// `for_inactive_window` and `for_high_contrast` stay the documented
445 /// generic transforms: colours only, and a hook does not reach them.
446 #[test]
447 fn the_named_projections_keep_their_documented_behaviour() {
448 let plain = intui::light();
449 let hooked = plain.clone().with_paint_projection(marking_projection);
450 for theme in [&plain, &hooked] {
451 let inactive = theme.for_inactive_window();
452 assert_eq!(inactive.colors, theme.colors.for_inactive_window());
453 assert_ne!(inactive.colors.accent, theme.colors.accent);
454 let high = theme.for_high_contrast();
455 assert_eq!(high.colors, theme.colors.for_high_contrast());
456 for projected in [inactive, high] {
457 assert_eq!(projected.id, theme.id);
458 assert_eq!(projected.appearance, theme.appearance);
459 assert_eq!(projected.layout, theme.layout);
460 assert_eq!(projected.typography, theme.typography);
461 assert_eq!(projected.shape, theme.shape);
462 assert_eq!(projected.motion, theme.motion);
463 assert_eq!(projected.input, theme.input);
464 assert_eq!(
465 projected.extension::<PaintProjection>().is_some(),
466 theme.extension::<PaintProjection>().is_some(),
467 );
468 }
469 }
470 }
471
472 /// A leaf painting the theme's accent, read from the paint context the
473 /// walker hands it.
474 #[derive(Debug)]
475 struct AccentProbe;
476
477 impl crate::widget::Widget for AccentProbe {
478 fn layout_response(
479 &self,
480 proposal: teksilo_canvas::SizeProposal,
481 _ctx: &crate::widget::LayoutContext,
482 ) -> crate::widget::LayoutResponse {
483 proposal.resolve(0.0, 0.0).into()
484 }
485
486 fn paint(
487 &self,
488 bounds: teksilo_canvas::Rect,
489 canvas: &mut teksilo_canvas::Canvas,
490 ctx: &crate::widget::PaintContext,
491 ) {
492 canvas.fill_rounded_rect(
493 bounds,
494 teksilo_tokens::CornerRadius::ZERO,
495 ctx.theme.colors.accent,
496 );
497 }
498 }
499
500 fn painted_accent(tree: &mut crate::widget_tree::WidgetTree) -> [f32; 4] {
501 let frame = tree.render();
502 assert_eq!(frame.shapes.len(), 1);
503 frame.shapes[0].color
504 }
505
506 /// The paint walker asks the theme's hook, for window activation and for
507 /// the high-contrast preference.
508 #[test]
509 fn the_paint_walker_paints_the_hooks_colours() {
510 let theme = intui::light().with_paint_projection(marking_projection);
511 let active = theme.colors.accent.to_array();
512 let mut tree = crate::widget_tree::WidgetTree::new().with_theme(theme);
513 tree.add(AccentProbe);
514 tree.layout(teksilo_canvas::SizeProposal::exact(40.0, 20.0));
515 assert_eq!(painted_accent(&mut tree), active);
516
517 tree.set_window_active(false);
518 assert_eq!(
519 painted_accent(&mut tree),
520 teksilo_tokens::Color::from_hex(HOOK_INACTIVE).to_array(),
521 "an inactive window paints the hook's colours, not the generic desaturation"
522 );
523
524 tree.set_window_active(true);
525 tree.set_accessibility_preferences(true, false, 1.0);
526 assert_eq!(
527 painted_accent(&mut tree),
528 teksilo_tokens::Color::from_hex(HOOK_HIGH_CONTRAST).to_array(),
529 );
530 assert_eq!(
531 tree.paint_state(),
532 PaintState {
533 window_active: true,
534 high_contrast: true,
535 }
536 );
537 }
538
539 /// Without a hook the walker paints what it painted before the hook.
540 #[test]
541 fn the_paint_walker_without_a_hook_paints_the_generic_chain() {
542 let theme = intui::dark();
543 let mut tree = crate::widget_tree::WidgetTree::new().with_theme(theme.clone());
544 tree.add(AccentProbe);
545 tree.layout(teksilo_canvas::SizeProposal::exact(40.0, 20.0));
546 tree.set_window_active(false);
547 tree.set_accessibility_preferences(true, false, 1.0);
548 assert_eq!(
549 painted_accent(&mut tree),
550 pre_hook_chain(&theme, ALL_PAINT_STATES[3])
551 .colors
552 .accent
553 .to_array()
554 );
555 }
556
557 /// The whole point of `#[serde(default)]` on `input`: a theme serialized
558 /// before the field existed must still load. The fixture is built the only
559 /// way that cannot go stale — serialize a real theme, then delete the key.
560 #[test]
561 fn a_theme_without_an_input_key_still_deserializes() {
562 let mut value: serde_json::Value =
563 serde_json::to_value(intui::light()).expect("theme serializes");
564 assert!(
565 value
566 .as_object_mut()
567 .expect("theme is a JSON object")
568 .remove("input")
569 .is_some(),
570 "the fixture must actually have had an `input` key to remove"
571 );
572
573 let restored: Theme = serde_json::from_value(value).expect("pre-programme theme loads");
574 assert_eq!(restored.input, teksilo_tokens::InputTokens::default());
575 assert_eq!(restored.input.density, TargetDensity::Compact);
576 assert_eq!(restored.colors, intui::light().colors);
577 }
578
579 /// `with_density` swaps the token group and nothing else.
580 #[test]
581 fn with_density_projects_only_the_input_tokens() {
582 let base = intui::light();
583 let touch = base.with_density(TargetDensity::Touch);
584
585 assert_eq!(touch.input.density, TargetDensity::Touch);
586 assert_eq!(touch.input.target_size, 44.0);
587 // Everything else rides across verbatim.
588 assert_eq!(touch.id, base.id);
589 assert_eq!(touch.appearance, base.appearance);
590 assert_eq!(touch.colors, base.colors);
591 assert_eq!(touch.typography, base.typography);
592 assert_eq!(touch.shape, base.shape);
593 assert_eq!(touch.motion, base.motion);
594 }
595
596 /// A theme built by a preset is Compact, i.e. today's behaviour.
597 #[test]
598 fn presets_start_compact() {
599 assert_eq!(intui::light().input, teksilo_tokens::InputTokens::default());
600 assert_eq!(intui::dark().input.density, TargetDensity::Compact);
601 }
602
603 /// An app-installed Tier-3 style slot survives a density projection —
604 /// documented behaviour (a hand-written style owns its own metrics), not
605 /// an accident of `..self.clone()`.
606 #[test]
607 fn with_density_preserves_installed_style_slots() {
608 #[derive(Debug)]
609 struct TestButtonStyle;
610 impl crate::styles::ButtonStyle for TestButtonStyle {
611 fn make_body(
612 &self,
613 _cfg: &crate::styles::ButtonStyleConfig,
614 ctx: &mut crate::BuildContext,
615 ) -> crate::WidgetId {
616 ctx.add(crate::test_widgets::FillWidget::new())
617 }
618 }
619
620 let mut base = intui::light();
621 base.style_slots.button = Some(std::rc::Rc::new(TestButtonStyle));
622 let touch = base.with_density(TargetDensity::Touch);
623 assert!(touch.style_slots.button.is_some());
624 assert_eq!(touch.input.density, TargetDensity::Touch);
625 }
626}