1use 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
14const 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#[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 pub fn new() -> Self {
40 Self { themes: Theme::builtin().to_vec() }
41 }
42
43 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 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
75fn flattened(color: Color, canvas: Color) -> Color {
77 canvas.lerp(color.with_alpha(1.0), color.a)
78}
79
80#[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 pub fn new() -> Self {
99 Self { picker: ThemePicker::new() }
100 }
101
102 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(©ing.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}