Skip to main content

ravenrs_widgets/
theme.rs

1//! Choosing a theme, and changing its tokens.
2
3use ravenrs_core::Color;
4use ravenrs_layout::Align;
5use ravenrs_reactive::{Binding, State};
6use ravenrs_style::{Semantic, TextStyle, Theme};
7
8use crate::context::{BuildCx, NodeId};
9use crate::{
10    Alert, Button, ButtonSize, ButtonStyle, ColorPicker, DropdownMenu, FormField, FormGroup, FormLayout,
11    HStack, IconName, MenuItem, Spacer, Stepper, Text, Tone, VStack, View,
12};
13
14/// The contrast text has to reach on its ground.
15const LEGIBLE: f32 = 4.5;
16const RADIUS_MOST: i32 = 64;
17const SIZE_LEAST: i32 = 8;
18const SIZE_MOST: i32 = 96;
19const COLUMNS: usize = 3;
20
21/// A menu of themes that says which one is on, small enough for a sidebar's footer or a
22/// toolbar. For the three of them side by side, in a settings page, see
23/// [`ThemeSwitcher`](crate::ThemeSwitcher).
24#[derive(Clone, Debug)]
25#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
26pub struct ThemePicker {
27    themes: Vec<Theme>,
28}
29
30impl Default for ThemePicker {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36#[cfg_attr(feature = "live", ravenrs_live::expose)]
37impl ThemePicker {
38    /// A menu of the themes that come with RavenRS.
39    pub fn new() -> Self {
40        Self { themes: Theme::builtin().to_vec() }
41    }
42
43    /// Adds a theme of the application's own to the menu, under its name.
44    pub fn theme(mut self, theme: Theme) -> Self {
45        self.themes.push(theme);
46        self
47    }
48}
49
50impl View for ThemePicker {
51    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
52        let labels = cx.labels();
53        let state = cx.theme_state();
54        let on = state.with(|theme| theme.name);
55        // Those that come with RavenRS have words in each language and an icon of their own.
56        let look = |theme: &Theme| match theme.name {
57            "Raven Dark" => (labels.theme_dark.clone(), IconName::Moon),
58            "Raven Light" => (labels.theme_light.clone(), IconName::Sun),
59            "Raven Glass" => (labels.theme_glass.clone(), IconName::Layers),
60            other => (other.to_owned(), IconName::Palette),
61        };
62        let current =
63            self.themes.iter().find(|theme| theme.name == on).map_or(IconName::Palette, |t| look(t).1);
64        let mut menu = DropdownMenu::new(labels.theme.clone()).icon(current).size(ButtonSize::Small);
65        for theme in &self.themes {
66            let (caption, icon) = look(theme);
67            let (state, theme) = (state.clone(), *theme);
68            let shown = if theme.name == on { IconName::Check } else { icon };
69            menu = menu.item(MenuItem::new(caption).icon(shown).on_select(move || state.set(theme)));
70        }
71        cx.child(&menu)
72    }
73}
74
75/// A color as it looks on the canvas, whatever it lets through.
76fn flattened(color: Color, canvas: Color) -> Color {
77    canvas.lerp(color.with_alpha(1.0), color.a)
78}
79
80/// Changes the tokens of the theme on screen, which follows at once: colors, the radii and the
81/// sizes of text. It warns when text stops being legible on its ground, and copies what changed
82/// as JSON, or takes it back, so an application can keep it wherever it wants.
83#[derive(Clone, Debug)]
84#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
85pub struct ThemeEditor {
86    picker: ThemePicker,
87}
88
89impl Default for ThemeEditor {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95#[cfg_attr(feature = "live", ravenrs_live::expose)]
96impl ThemeEditor {
97    /// An editor over the theme of the application.
98    pub fn new() -> Self {
99        Self { picker: ThemePicker::new() }
100    }
101
102    /// Offers a theme of the application's own to start from.
103    pub fn theme(mut self, theme: Theme) -> Self {
104        self.picker = self.picker.theme(theme);
105        self
106    }
107}
108
109fn color_field(
110    state: &State<Theme>,
111    role: Semantic,
112    over: Option<Semantic>,
113    cx: &BuildCx<'_>,
114) -> FormField<ColorPicker> {
115    let (read, write) = (state.clone(), state.clone());
116    let binding = Binding::new(
117        move || Some(read.with(|theme| theme.palette.get(role))),
118        move |color: Option<Color>| {
119            if let Some(color) = color {
120                write.update(|theme| theme.palette.set(role, color));
121            }
122        },
123    );
124    let picker = ColorPicker::new(binding).alpha(true).label(role.token());
125    let field = FormField::new(role.token(), picker);
126    let Some(ground) = over else { return field };
127    let theme = state.get();
128    let canvas = theme.palette.bg_000;
129    let ratio = flattened(theme.palette.get(role), canvas)
130        .contrast_ratio(flattened(theme.palette.get(ground), canvas));
131    let tone = if ratio < LEGIBLE { Tone::Warning } else { Tone::Neutral };
132    field.note_text(Some(cx.labels().theme_contrast(ratio, ground.token())), tone)
133}
134
135fn stepper_field(
136    name: &str,
137    least: i32,
138    most: i32,
139    read: impl Fn(&Theme) -> f32 + 'static,
140    write: impl Fn(&mut Theme, f32) + 'static,
141    state: &State<Theme>,
142) -> FormField<Stepper> {
143    let (reading, writing) = (state.clone(), state.clone());
144    let binding = Binding::new(
145        move || reading.with(|theme| read(theme).round() as i32),
146        move |value: i32| writing.update(|theme| write(theme, value as f32)),
147    );
148    FormField::new(name, Stepper::new(binding).range(least, most).label(name))
149}
150
151impl View for ThemeEditor {
152    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
153        let labels = cx.labels();
154        let state = cx.theme_state();
155        let notice: State<Option<(String, Tone)>> = cx.use_state(|| None);
156        let changed = state.with(Theme::changed_tokens);
157        let group = |title: &str, roles: &[(Semantic, Option<Semantic>)], cx: &BuildCx<'_>| {
158            roles.iter().fold(FormGroup::titled(title).columns(COLUMNS), |group, (role, over)| {
159                group.field(color_field(&state, *role, *over, cx))
160            })
161        };
162        use Semantic as S;
163        let surfaces = group(
164            &labels.theme_surfaces,
165            &[
166                (S::Canvas, None),
167                (S::Surface, None),
168                (S::SurfaceElevated, None),
169                (S::Control, None),
170                (S::Border, None),
171            ],
172            cx,
173        );
174        let text = group(
175            &labels.theme_text,
176            &[(S::Ink, Some(S::SurfaceElevated)), (S::InkMuted, Some(S::SurfaceElevated))],
177            cx,
178        );
179        let accent = group(
180            &labels.theme_accent,
181            &[
182                (S::Accent, None),
183                (S::AccentHover, None),
184                (S::AccentPressed, None),
185                (S::OnAccent, Some(S::Accent)),
186                (S::FocusRing, None),
187            ],
188            cx,
189        );
190        let status = group(
191            &labels.theme_status,
192            &[
193                (S::Success, Some(S::SurfaceElevated)),
194                (S::Warning, Some(S::SurfaceElevated)),
195                (S::Danger, Some(S::SurfaceElevated)),
196            ],
197            cx,
198        );
199        let shape = ravenrs_style::Radii::TOKENS.into_iter().fold(
200            FormGroup::titled(&labels.theme_shape).columns(COLUMNS),
201            |group, token| {
202                group.field(stepper_field(
203                    &format!("radius-{token}"),
204                    0,
205                    RADIUS_MOST,
206                    move |theme| theme.radii.get(token).unwrap_or_default(),
207                    move |theme, radius| theme.radii.set(token, radius),
208                    &state,
209                ))
210            },
211        );
212        let sizes = TextStyle::TOKENS.into_iter().fold(
213            FormGroup::titled(&labels.theme_sizes).columns(COLUMNS),
214            |group, style| {
215                group.field(stepper_field(
216                    style.token(),
217                    SIZE_LEAST,
218                    SIZE_MOST,
219                    move |theme| theme.typography.get(style).size,
220                    move |theme, size| theme.typography.set_size(style, size),
221                    &state,
222                ))
223            },
224        );
225
226        let (restoring, restored) = (state.clone(), notice.clone());
227        let restore = Button::new(labels.theme_restore.clone())
228            .style(ButtonStyle::Ghost)
229            .size(ButtonSize::Small)
230            .disabled(changed == 0)
231            .on_click(move || {
232                restoring.update(|theme| *theme = theme.base());
233                restored.set(None);
234            });
235        let (clipboard, copying, copied) = (cx.clipboard(), state.clone(), notice.clone());
236        let copied_words = labels.copied.clone();
237        let copy = Button::new(labels.theme_copy.clone())
238            .icon(IconName::Copy)
239            .style(ButtonStyle::Secondary)
240            .size(ButtonSize::Small)
241            .on_click(move || {
242                clipboard.write(&copying.with(Theme::to_tokens_json));
243                copied.set(Some((copied_words.clone(), Tone::Success)));
244            });
245        let (clipboard, pasting, pasted) = (cx.clipboard(), state.clone(), notice.clone());
246        let refused = labels.theme_invalid.clone();
247        let paste = Button::new(labels.theme_paste.clone())
248            .style(ButtonStyle::Secondary)
249            .size(ButtonSize::Small)
250            .on_click(move || match clipboard.read().map(|text| Theme::from_tokens_json(&text)) {
251                Some(Ok(theme)) => {
252                    pasting.set(theme);
253                    pasted.set(None);
254                }
255                _ => pasted.set(Some((refused.clone(), Tone::Danger))),
256            });
257        let bar = HStack::new((
258            self.picker.clone(),
259            Text::new(labels.theme_changed(changed)).style(TextStyle::Callout).foreground(Semantic::InkMuted),
260            Spacer::new(),
261            restore,
262            copy,
263            paste,
264        ))
265        .spacing(8.0)
266        .align(Align::Center);
267        let form = FormLayout::new()
268            .group(surfaces)
269            .group(text)
270            .group(accent)
271            .group(status)
272            .group(shape)
273            .group(sizes);
274        cx.child(
275            &VStack::new((bar, notice.get().map(|(message, tone)| Alert::message(message).tone(tone)), form))
276                .spacing(16.0)
277                .align(Align::Stretch),
278        )
279    }
280}