Skip to main content

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}