Skip to main content

fc_dioxus/components/
theme_picker.rs

1//! A theme picker from theme-picker.css: every design as a live preview card, and
2//! selects for palette, scheme, and contrast.
3//!
4//! It chooses; [`FutureCss`] applies. The choice is a value the application feeds back
5//! into its root, so the picker never writes to the document behind the tree's back.
6//! Custom themes (upstream's editor, TOML import and export, and contrast validation)
7//! wait on a Rust theme model upstream; see COMPONENTS.md, "Waiting on upstream".
8use crate::{
9    core::{
10        assets,
11        attrs::{
12            component_attributes, theme_attributes, theme_pairs, FcData, Kind, ThemePickerData,
13        },
14        controlled::use_controllable,
15        platform::{DomBridge, MediaPreference},
16        state::Lifecycle,
17    },
18    prelude::*,
19    theme::use_stylesheets,
20};
21const BEM: Bem = Bem::new("fc-theme-picker");
22const CHECK: Bem = Bem::new("fc-check");
23const INPUT: Bem = Bem::new("fc-input");
24
25/// Light, dark, or the user's preference.
26#[non_exhaustive]
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
28pub enum Scheme {
29    /// Follow the system.
30    #[default]
31    System,
32    /// Light.
33    Light,
34    /// Dark.
35    Dark,
36}
37impl Scheme {
38    /// Every scheme, in menu order.
39    pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark];
40    /// The value upstream records.
41    #[must_use]
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::System => "system",
45            Self::Light => "light",
46            Self::Dark => "dark",
47        }
48    }
49    const fn label(self) -> &'static str {
50        match self {
51            Self::System => "Follow system",
52            Self::Light => "Light",
53            Self::Dark => "Dark",
54        }
55    }
56}
57
58/// Standard contrast following the system, or high contrast.
59#[non_exhaustive]
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
61pub enum Contrast {
62    /// Follow the system.
63    #[default]
64    System,
65    /// High contrast.
66    High,
67}
68impl Contrast {
69    /// Every setting, in menu order.
70    pub const ALL: [Self; 2] = [Self::System, Self::High];
71    /// The value upstream records.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::System => "system",
76            Self::High => "high",
77        }
78    }
79    const fn label(self) -> &'static str {
80        match self {
81            Self::System => "Follow system",
82            Self::High => "High contrast",
83        }
84    }
85}
86
87/// Everything the picker chooses.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct ThemeChoice {
90    /// The design preset.
91    pub design: Design,
92    /// The colour palette.
93    pub palette: Palette,
94    /// Light, dark, or system.
95    pub scheme: Scheme,
96    /// Standard or high contrast.
97    pub contrast: Contrast,
98}
99impl Default for ThemeChoice {
100    fn default() -> Self {
101        Self::from_design(Design::default())
102    }
103}
104impl ThemeChoice {
105    /// A design with its own paired palette, following the system scheme and contrast.
106    #[must_use]
107    pub const fn from_design(design: Design) -> Self {
108        Self {
109            design,
110            palette: design.palette(),
111            scheme: Scheme::System,
112            contrast: Contrast::System,
113        }
114    }
115    /// The [`Theme`] for [`FutureCss`]. High contrast has only light and dark variants,
116    /// so with the system scheme it needs to know which the system prefers.
117    ///
118    /// ```
119    /// use fc_dioxus::prelude::*;
120    /// let mut choice = ThemeChoice::default();
121    /// assert_eq!(choice.theme(true), Theme::FollowSystem);
122    /// choice.contrast = Contrast::High;
123    /// assert_eq!(choice.theme(true), Theme::HighContrastDark);
124    /// choice.scheme = Scheme::Light;
125    /// assert_eq!(choice.theme(true), Theme::HighContrastLight);
126    /// ```
127    #[must_use]
128    pub const fn theme(self, prefers_dark: bool) -> Theme {
129        let dark = match self.scheme {
130            Scheme::System => prefers_dark,
131            Scheme::Light => false,
132            Scheme::Dark => true,
133        };
134        match (self.contrast, self.scheme, dark) {
135            (Contrast::High, _, true) => Theme::HighContrastDark,
136            (Contrast::High, _, false) => Theme::HighContrastLight,
137            // Explicit, so a picker inside a themed root follows the OS, not the root.
138            (Contrast::System, Scheme::System, _) => Theme::FollowSystem,
139            (Contrast::System, _, true) => Theme::Dark,
140            (Contrast::System, _, false) => Theme::Light,
141        }
142    }
143}
144
145/// The theme picker. Picking a design also picks its paired palette, as upstream's
146/// binding does; the palette select can then override it.
147#[component]
148pub fn ThemePicker(
149    #[props(default)] value: Option<ReadSignal<ThemeChoice>>,
150    #[props(default)] default_value: ThemeChoice,
151    #[props(default)] onchange: Option<EventHandler<ThemeChoice>>,
152    #[props(default)] class: Option<String>,
153    #[props(extends = GlobalAttributes)] attributes: Vec<Attribute>,
154) -> Element {
155    let state = use_controllable(value, || default_value, onchange);
156    let group = use_id();
157    // Upstream resolves "follow system" with media queries; so do the previews, which
158    // must show high contrast in the scheme the page will actually use.
159    let dark = use_signal(|| false);
160    let forced = use_signal(|| false);
161    let more = use_signal(|| false);
162    // Followed, not read once: the CSS tracks the OS live, and so must the previews.
163    use_hook(move || {
164        for (preference, mut target) in [
165            (MediaPreference::DarkScheme, dark),
166            (MediaPreference::MoreContrast, more),
167            (MediaPreference::ForcedColors, forced),
168        ] {
169            spawn(async move {
170                let mut watch = DomBridge.watch(preference);
171                while let Some(on) = watch.next().await {
172                    target.set(on);
173                }
174            });
175        }
176    });
177    let choice = state.get();
178    let high = choice.contrast == Contrast::High || more() || forced();
179    let shown = if dark() && choice.scheme == Scheme::System || choice.scheme == Scheme::Dark {
180        "dark"
181    } else {
182        "light"
183    };
184    let status = format!(
185        "{} · {shown}{}",
186        choice.design.name(),
187        if forced() {
188            " · forced colors"
189        } else if high {
190            " · high contrast"
191        } else {
192            ""
193        }
194    );
195    let preview_theme = match (high, shown) {
196        (true, "dark") => "high-contrast-dark",
197        (true, _) => "high-contrast-light",
198        (false, scheme) => scheme,
199    };
200
201    let mut attributes = component_attributes(
202        attributes,
203        ClassList::new(BEM.block()).extend(class.as_deref()),
204        &[],
205    );
206    attributes.extend(
207        FcData {
208            kind: Some(Kind::ThemePicker),
209            state: Some(Lifecycle::Complete),
210            ..FcData::default()
211        }
212        .attributes(),
213    );
214    attributes.extend([
215        ThemePickerData::Design.attribute(choice.design.as_str()),
216        ThemePickerData::Palette.attribute(choice.palette.as_str()),
217        ThemePickerData::Scheme.attribute(choice.scheme.as_str()),
218        ThemePickerData::Contrast.attribute(choice.contrast.as_str()),
219    ]);
220    let group = group.to_string();
221
222    rsx! {
223        if use_stylesheets() {
224            document::Stylesheet { href: assets::COMPONENTS_CHECK_CSS }
225            document::Stylesheet { href: assets::COMPONENTS_FIELD_CSS }
226            document::Stylesheet { href: assets::COMPONENTS_THEME_PICKER_CSS }
227        }
228        div { ..attributes,
229            fieldset { class: BEM.element("themes"),
230                legend { class: BEM.element("legend"), "Design theme" }
231                for design in Design::ALL.iter().copied() {
232                    // Each card wears its own design and palette, so the preview is the
233                    // theme itself rather than a picture of it.
234                    label { key: "{design.as_str()}", class: BEM.element("option"),
235                        ..theme_attributes(&theme_pairs(Some(preview_theme), None, Some(design.as_str()), Some(design.palette().as_str()))),
236                        input { r#type: "radio", class: CHECK.element("input"), name: "{group}", value: design.as_str(),
237                            checked: choice.design == design,
238                            onchange: move |_| state.set(ThemeChoice { design, palette: design.palette(), ..state.get() }),
239                        }
240                        span {
241                            span { class: BEM.element("preview"), aria_hidden: "true",
242                                span { class: BEM.element("window"),
243                                    span { class: BEM.element("window-bar"), "Workspace" }
244                                    span { class: BEM.element("window-body"),
245                                        span { class: BEM.element("window-nav"), span { "Home" } span { "Projects" } }
246                                        span { class: BEM.element("window-content"),
247                                            span { class: BEM.element("window-feature"), "Your next idea" }
248                                            span { class: BEM.element("preview-card"), "Ready to create" }
249                                            span { class: BEM.element("preview-action"), "Continue" }
250                                        }
251                                    }
252                                }
253                            }
254                            strong { class: BEM.element("name"), "{design.name()}" }
255                            span { class: BEM.element("description"), "{design.description()}" }
256                        }
257                    }
258                }
259            }
260            div { class: BEM.element("controls"),
261                label { class: BEM.element("field"), "Color palette"
262                    select { class: INPUT.block(), value: choice.palette.as_str(),
263                        onchange: move |event| if let Some(palette) = Palette::from_attribute(&event.value()) {
264                            state.set(ThemeChoice { palette, ..state.get() });
265                        },
266                        for palette in Palette::ALL.iter().copied() {
267                            option { key: "{palette.as_str()}", value: palette.as_str(), selected: palette == choice.palette, "{palette.name()}" }
268                        }
269                    }
270                }
271                label { class: BEM.element("field"), "Color scheme"
272                    select { class: INPUT.block(), value: choice.scheme.as_str(),
273                        onchange: move |event| if let Some(scheme) = Scheme::ALL.into_iter().find(|s| s.as_str() == event.value()) {
274                            state.set(ThemeChoice { scheme, ..state.get() });
275                        },
276                        for scheme in Scheme::ALL {
277                            option { key: "{scheme.as_str()}", value: scheme.as_str(), selected: scheme == choice.scheme, "{scheme.label()}" }
278                        }
279                    }
280                }
281                label { class: BEM.element("field"), "Contrast"
282                    select { class: INPUT.block(), value: choice.contrast.as_str(),
283                        onchange: move |event| if let Some(contrast) = Contrast::ALL.into_iter().find(|c| c.as_str() == event.value()) {
284                            state.set(ThemeChoice { contrast, ..state.get() });
285                        },
286                        for contrast in Contrast::ALL {
287                            option { key: "{contrast.as_str()}", value: contrast.as_str(), selected: contrast == choice.contrast, "{contrast.label()}" }
288                        }
289                    }
290                }
291            }
292            p { class: BEM.element("status"), role: "status", "{status}" }
293        }
294    }
295}