1mod context;
19mod presets;
20mod style;
21
22pub use context::ThemeExt;
23#[cfg(feature = "ui")]
24pub(crate) use context::{ThemeDerived, follow_active_theme, theme_derived};
25pub(crate) use context::{ThemeScope, applied_theme};
26pub use presets::presets;
27pub use style::apply_to_egui;
28pub(crate) use style::{apply_to_style, contrast, readable_color};
29
30use crate::tokens::DESIGN_TOKENS;
31use egui::Color32;
32
33#[derive(Clone, Debug, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub struct Theme {
42 pub dark: bool,
44 pub ui: UiColors,
46 pub chart: ChartColors,
48 #[cfg_attr(feature = "serde", serde(default))]
50 pub text: TextSizes,
51 #[cfg_attr(feature = "serde", serde(default))]
53 pub spacing: Spacing,
54 #[cfg_attr(feature = "serde", serde(default))]
56 pub radius: Radii,
57}
58
59impl Default for Theme {
60 fn default() -> Self {
61 Self::dark()
62 }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
67#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
68pub struct UiColors {
69 pub panel_bg: Color32,
71 pub panel_bg_secondary: Color32,
73 pub panel_bg_floating: Color32,
75 pub modal_overlay: Color32,
77 pub text: Color32,
79 pub text_secondary: Color32,
81 pub text_muted: Color32,
83 pub text_disabled: Color32,
85 pub border: Color32,
87 pub border_control: Color32,
89 pub btn_bg_hover: Color32,
91 pub btn_bg_active: Color32,
93 pub selection_bg: Color32,
95 pub selection_text: Color32,
97 pub focus: Color32,
99 pub accent: Color32,
101 pub accent_hover: Color32,
103 pub accent_active: Color32,
105 pub accent_text: Color32,
107 pub danger_bg: Color32,
109 pub danger_bg_hover: Color32,
111 pub danger_bg_active: Color32,
113 pub danger_text: Color32,
115 pub success: Color32,
117 #[cfg_attr(feature = "serde", serde(default = "default_take_profit"))]
119 pub take_profit: Color32,
120 #[cfg_attr(feature = "serde", serde(default = "default_stop_loss"))]
122 pub stop_loss: Color32,
123 pub warning: Color32,
125 pub error: Color32,
127 pub info: Color32,
129}
130
131#[cfg(feature = "serde")]
132fn default_take_profit() -> Color32 {
133 Color32::from_rgb(0, 166, 147)
134}
135#[cfg(feature = "serde")]
136fn default_stop_loss() -> Color32 {
137 Color32::from_rgb(255, 166, 0)
138}
139#[cfg(feature = "serde")]
141fn default_order_notable() -> Color32 {
142 Theme::light().chart.order_notable
143}
144#[cfg(feature = "serde")]
145fn default_order_large() -> Color32 {
146 Theme::light().chart.order_large
147}
148
149impl Default for UiColors {
150 fn default() -> Self {
151 Theme::dark().ui
152 }
153}
154
155#[derive(Clone, Copy, Debug, PartialEq)]
157#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
158pub struct ChartColors {
159 pub bg: Color32,
161 pub bg_axis: Color32,
163 pub bg_tooltip: Color32,
165 pub grid_line: Color32,
167 pub grid_line_major: Color32,
169 pub axis_text: Color32,
171 pub axis_text_secondary: Color32,
173 pub crosshair_line: Color32,
175 pub crosshair_label_bg: Color32,
177 pub crosshair_label_text: Color32,
179 pub candle_up: Color32,
181 pub candle_down: Color32,
183 #[cfg_attr(feature = "serde", serde(default = "default_order_notable"))]
187 pub order_notable: Color32,
188 #[cfg_attr(feature = "serde", serde(default = "default_order_large"))]
190 pub order_large: Color32,
191 pub volume_alpha: u8,
193}
194
195impl Default for ChartColors {
196 fn default() -> Self {
197 Theme::dark().chart
198 }
199}
200
201impl ChartColors {
202 pub fn volume_up(&self) -> Color32 {
204 Color32::from_rgb(38, 166, 154).gamma_multiply_u8(self.volume_alpha)
205 }
206
207 pub fn volume_down(&self) -> Color32 {
209 Color32::from_rgb(239, 83, 80).gamma_multiply_u8(self.volume_alpha)
210 }
211}
212
213#[derive(Clone, Copy, Debug, PartialEq)]
218#[cfg_attr(
219 feature = "serde",
220 derive(serde::Serialize, serde::Deserialize),
221 serde(default)
222)]
223pub struct TextSizes {
224 pub micro: f32,
226 pub marker: f32,
228 pub tiny: f32,
230 pub small: f32,
232 pub label: f32,
234 pub body: f32,
236 pub title: f32,
238 pub heading: f32,
240 pub dialog_title: f32,
242 pub chart_title: f32,
244 pub toolbar: f32,
246 pub ohlc: f32,
248 pub axis: f32,
250 pub price_tag: f32,
252 pub bottom_bar: f32,
254}
255
256impl Default for TextSizes {
257 fn default() -> Self {
258 Self {
259 micro: 8.0,
260 marker: 9.0,
261 dialog_title: 20.0,
262 tiny: 10.0,
263 small: 11.0,
264 label: 12.0,
265 body: 13.0,
266 title: 14.0,
267 heading: 16.0,
268 chart_title: 13.0,
269 toolbar: 14.0,
270 ohlc: 13.0,
271 axis: 12.0,
272 price_tag: 12.0,
273 bottom_bar: 12.0,
274 }
275 }
276}
277
278impl TextSizes {
279 pub fn scaled(self, factor: f32) -> Self {
281 Self {
282 micro: self.micro * factor,
283 marker: self.marker * factor,
284 tiny: self.tiny * factor,
285 small: self.small * factor,
286 label: self.label * factor,
287 body: self.body * factor,
288 title: self.title * factor,
289 heading: self.heading * factor,
290 dialog_title: self.dialog_title * factor,
291 chart_title: self.chart_title * factor,
292 toolbar: self.toolbar * factor,
293 ohlc: self.ohlc * factor,
294 axis: self.axis * factor,
295 price_tag: self.price_tag * factor,
296 bottom_bar: self.bottom_bar * factor,
297 }
298 }
299}
300
301#[derive(Clone, Copy, Debug, PartialEq)]
303#[cfg_attr(
304 feature = "serde",
305 derive(serde::Serialize, serde::Deserialize),
306 serde(default)
307)]
308pub struct Spacing {
309 pub xs: f32,
311 pub sm: f32,
313 pub md: f32,
315 pub lg: f32,
317 pub xl: f32,
319 pub xxl: f32,
321 pub xxxl: f32,
323}
324
325impl Default for Spacing {
326 fn default() -> Self {
327 Self {
328 xs: DESIGN_TOKENS.spacing.xs,
329 sm: DESIGN_TOKENS.spacing.sm,
330 md: DESIGN_TOKENS.spacing.md,
331 lg: DESIGN_TOKENS.spacing.lg,
332 xl: DESIGN_TOKENS.spacing.xl,
333 xxl: DESIGN_TOKENS.spacing.xxl,
334 xxxl: DESIGN_TOKENS.spacing.xxxl,
335 }
336 }
337}
338
339#[derive(Clone, Copy, Debug, PartialEq)]
341#[cfg_attr(
342 feature = "serde",
343 derive(serde::Serialize, serde::Deserialize),
344 serde(default)
345)]
346pub struct Radii {
347 pub xs: f32,
349 pub sm: f32,
351 pub md: f32,
353 pub lg: f32,
355 pub xl: f32,
357}
358
359impl Default for Radii {
360 fn default() -> Self {
361 Self {
362 xs: DESIGN_TOKENS.rounding.xs,
363 sm: DESIGN_TOKENS.rounding.sm,
364 md: DESIGN_TOKENS.rounding.md,
365 lg: DESIGN_TOKENS.rounding.lg,
366 xl: DESIGN_TOKENS.rounding.xl,
367 }
368 }
369}
370
371pub(crate) fn trading_side_color(theme: &Theme, side: crate::model::OrderSide) -> Color32 {
373 match side {
374 crate::model::OrderSide::Buy => theme.ui.accent,
375 crate::model::OrderSide::Sell => theme.chart.candle_down,
376 }
377}
378
379impl Theme {
380 pub fn with_accent(mut self, accent: Color32) -> Self {
387 let ui = &mut self.ui;
388 let accent = ui.panel_bg.blend(accent);
389 let (on_accent, away) =
390 if contrast(Color32::WHITE, accent) >= contrast(Color32::BLACK, accent) {
391 (Color32::WHITE, Color32::BLACK)
392 } else {
393 (Color32::BLACK, Color32::WHITE)
394 };
395 let towards = if contrast(accent, away) >= 1.5 {
397 away
398 } else {
399 on_accent
400 };
401 let shade = |fill: Color32| {
402 (1..=255)
403 .map(|step| fill.lerp_to_gamma(towards, step as f32 / 255.0))
404 .find(|&next| contrast(next, fill) >= 1.2 && contrast(on_accent, next) >= 4.5)
405 .unwrap_or(fill)
406 };
407 ui.accent = accent;
408 ui.accent_hover = shade(accent);
409 ui.accent_active = shade(ui.accent_hover);
410 ui.accent_text = on_accent;
411 ui.selection_text = if self.dark && ui.text == Color32::WHITE {
412 ui.text_secondary
413 } else if self.dark {
414 Color32::WHITE
415 } else {
416 Color32::BLACK
417 };
418 let selection = if self.dark {
419 accent
420 } else {
421 ui.panel_bg.lerp_to_gamma(accent, 1.0 / 6.0)
422 };
423 let selection = readable_color(selection, ui.text, &[ui.panel_bg], 1.3);
424 ui.selection_bg = readable_color(selection, ui.panel_bg, &[ui.selection_text], 4.5);
425 if !self.dark {
426 ui.focus = readable_color(
427 accent,
428 ui.text,
429 &[
430 ui.panel_bg,
431 ui.panel_bg_secondary,
432 ui.panel_bg_floating,
433 ui.btn_bg_hover,
434 ],
435 3.0,
436 );
437 }
438 self
439 }
440}
441
442#[cfg(all(test, feature = "serde"))]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn partial_light_palettes_are_rejected_instead_of_mixing_presets() {
448 let partial = serde_json::json!({"dark": false, "ui": {
449 "panel_bg": [255, 255, 255, 255],
450 "panel_bg_floating": [255, 255, 255, 255],
451 "text": [19, 23, 34, 255]
452 }});
453 assert!(serde_json::from_value::<Theme>(partial).is_err());
454 for (_, theme) in presets() {
455 let mut saved = serde_json::to_value(&theme).unwrap();
456 for scale in ["text", "spacing", "radius"] {
457 saved.as_object_mut().unwrap().remove(scale);
458 }
459 assert_eq!(serde_json::from_value::<Theme>(saved).unwrap(), theme);
460 }
461 }
462
463 #[test]
464 fn saved_text_roles_default_when_missing_and_keep_overrides_when_scaled() {
465 for (role, default) in [
466 ("micro", 8.0),
467 ("marker", 9.0),
468 ("dialog_title", 20.0),
469 ("chart_title", 13.0),
470 ("toolbar", 14.0),
471 ("ohlc", 13.0),
472 ("axis", 12.0),
473 ("price_tag", 12.0),
474 ("bottom_bar", 12.0),
475 ] {
476 for override_size in [None, Some(19.0)] {
477 let mut saved = serde_json::to_value(Theme::dark()).unwrap();
478 saved["text"] = serde_json::json!({"body": 17.0});
479 if let Some(size) = override_size {
480 saved["text"][role] = serde_json::json!(size);
481 }
482 let mut theme: Theme = serde_json::from_value(saved).unwrap();
483 theme.text = theme.text.scaled(1.5);
484 let saved = serde_json::to_value(&theme).unwrap();
485 assert_eq!(
486 saved["text"][role],
487 serde_json::json!(override_size.unwrap_or(default) * 1.5),
488 "{role}: {override_size:?}"
489 );
490 assert_eq!(saved["text"]["body"], serde_json::json!(25.5));
491 assert_eq!(serde_json::from_value::<Theme>(saved).unwrap(), theme);
492 }
493 }
494 }
495}