Skip to main content

egui_map/map/
theme.rs

1//! Color palettes and visual styling for the [`Map`](super::Map) widget.
2//!
3//! [`Theme`] is a built-in set of named color palettes, each with a light and
4//! a dark variant ([`ColorMode`]); [`Theme::colors`] resolves a `(Theme,
5//! ColorMode)` pair to the actual [`ThemeColors`] palette. [`MapTheme`] is
6//! the customization point: implement it to install your own palette instead
7//! of one of the built-ins, the same way [`NodeTemplate`](super::objects::NodeTemplate)
8//! and [`SegmentTemplate`](super::objects::SegmentTemplate) let you replace
9//! the built-in node/segment rendering. [`Style`] is the non-palette visual
10//! configuration (stroke widths, font, background) that the active
11//! [`MapTheme`] colors are combined with when the widget paints.
12
13use crate::map::theme::{
14    ColorMode::{Dark, Light},
15    Theme::*,
16};
17use egui::ecolor::Color32;
18use egui::{FontId, Stroke};
19use std::ops::{Div, Mul};
20
21/// A built-in, named color palette, in both a light and a dark variant.
22///
23/// Resolve it to actual colors with [`Theme::colors`]. Install one on a
24/// [`Map`](super::Map) as its active [`MapTheme`] with
25/// [`Map::set_theme`](super::Map::set_theme) -- `Theme` implements
26/// [`MapTheme`] directly, so any variant can be passed straight in.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
28pub enum Theme {
29    /// Muted blues over slate grays.
30    SlateOcean,
31    /// Violet and teal on a soft neutral backdrop. The default theme.
32    #[default]
33    NebulaViolet,
34    /// Greens and blues reminiscent of a terminal color scheme.
35    TerminalGreen,
36    /// Warm oranges and pinks over charcoal/cream.
37    EmberForge,
38    /// Amber and teal on a warm neutral backdrop.
39    SolarAmber,
40    /// Cyan and gold over deep blue-gray.
41    ArticCyan,
42    /// Signal red and steel blue over near-black.
43    CrimsonSignal,
44    /// Indigo and teal on deep midnight blue.
45    MidnightIndigo,
46    /// Copper and teal over warm taupe.
47    CopperRose,
48    /// Lime green and violet over dark olive.
49    LimeCircuit,
50    /// Coral and ocean blue over sea-glass teal.
51    CoralReef,
52    /// Grayscale with a cool blue accent.
53    GraphiteMono,
54    /// Plum and jade over muted mauve.
55    PlumStatic,
56    /// Sand and clay over warm khaki.
57    SandstoneTrail,
58}
59
60impl Theme {
61    /// Resolves this theme to its actual [`ThemeColors`] for the given
62    /// [`ColorMode`].
63    pub const fn colors(self, mode: ColorMode) -> ThemeColors {
64        match (self, mode) {
65            (SlateOcean, Dark) => ThemeColors {
66                node: Color32::from_rgb(0x6E, 0x93, 0xBF),
67                segment: Color32::from_rgb(0x4A, 0x5A, 0x6E),
68                selected: Color32::from_rgb(0x38, 0xBD, 0xF8),
69                alert: Color32::from_rgb(0xFF, 0x8A, 0x4C),
70                text: Color32::from_rgb(0xD7, 0xDE, 0xE6),
71            },
72            (SlateOcean, Light) => ThemeColors {
73                node: Color32::from_rgb(0x2E, 0x4C, 0x6D),
74                segment: Color32::from_rgb(0x8A, 0x99, 0xA8),
75                selected: Color32::from_rgb(0x0E, 0xA5, 0xE9),
76                alert: Color32::from_rgb(0xE0, 0x59, 0x2A),
77                text: Color32::from_rgb(0x2B, 0x33, 0x3D),
78            },
79            (NebulaViolet, Light) => ThemeColors {
80                node: Color32::from_rgb(0x5B, 0x3E, 0x96),
81                segment: Color32::from_rgb(0xB3, 0xA4, 0xD6),
82                selected: Color32::from_rgb(0x16, 0xB8, 0xA6),
83                alert: Color32::from_rgb(0xE6, 0x33, 0x7A),
84                text: Color32::from_rgb(0x37, 0x2F, 0x45),
85            },
86            (NebulaViolet, Dark) => ThemeColors {
87                node: Color32::from_rgb(0xA7, 0x8B, 0xFA),
88                segment: Color32::from_rgb(0x5C, 0x4A, 0x80),
89                selected: Color32::from_rgb(0x2D, 0xD4, 0xBF),
90                alert: Color32::from_rgb(0xFF, 0x5F, 0xA3),
91                text: Color32::from_rgb(0xE4, 0xDC, 0xF2),
92            },
93            (TerminalGreen, Light) => ThemeColors {
94                node: Color32::from_rgb(0x1F, 0x7A, 0x3D),
95                segment: Color32::from_rgb(0x9B, 0xB8, 0x9E),
96                selected: Color32::from_rgb(0x25, 0x63, 0xEB),
97                alert: Color32::from_rgb(0xD6, 0xA4, 0x29),
98                text: Color32::from_rgb(0x2A, 0x33, 0x2C),
99            },
100            (TerminalGreen, Dark) => ThemeColors {
101                node: Color32::from_rgb(0x4A, 0xDE, 0x80),
102                segment: Color32::from_rgb(0x3A, 0x52, 0x40),
103                selected: Color32::from_rgb(0x60, 0xA5, 0xFA),
104                alert: Color32::from_rgb(0xFF, 0xC9, 0x4D),
105                text: Color32::from_rgb(0xD7, 0xE6, 0xDA),
106            },
107            (EmberForge, Light) => ThemeColors {
108                node: Color32::from_rgb(0x8C, 0x4A, 0x1F),
109                segment: Color32::from_rgb(0xC9, 0xA9, 0x8C),
110                selected: Color32::from_rgb(0xC4, 0x25, 0x8C),
111                alert: Color32::from_rgb(0x2E, 0x86, 0xAB),
112                text: Color32::from_rgb(0x36, 0x2E, 0x28),
113            },
114            (EmberForge, Dark) => ThemeColors {
115                node: Color32::from_rgb(0xD9, 0x7F, 0x3D),
116                segment: Color32::from_rgb(0x5A, 0x46, 0x32),
117                selected: Color32::from_rgb(0xF4, 0x72, 0xB6),
118                alert: Color32::from_rgb(0x4F, 0xC3, 0xE8),
119                text: Color32::from_rgb(0xED, 0xE0, 0xD3),
120            },
121            (SolarAmber, Dark) => ThemeColors {
122                node: Color32::from_rgb(0x8A, 0x6D, 0x1E),
123                segment: Color32::from_rgb(0xD8, 0xC4, 0x8F),
124                selected: Color32::from_rgb(0x2D, 0x6E, 0x5E),
125                alert: Color32::from_rgb(0xC1, 0x44, 0x2A),
126                text: Color32::from_rgb(0x36, 0x2E, 0x1C),
127            },
128            (SolarAmber, Light) => ThemeColors {
129                node: Color32::from_rgb(0xF0, 0xC2, 0x4C),
130                segment: Color32::from_rgb(0x5A, 0x4E, 0x2E),
131                selected: Color32::from_rgb(0x4F, 0xBF, 0x9E),
132                alert: Color32::from_rgb(0xE8, 0x65, 0x4A),
133                text: Color32::from_rgb(0xEF, 0xE4, 0xC4),
134            },
135            (ArticCyan, Dark) => ThemeColors {
136                node: Color32::from_rgb(0x12, 0x70, 0x8A),
137                segment: Color32::from_rgb(0x9A, 0xC6, 0xD1),
138                selected: Color32::from_rgb(0xF5, 0xA5, 0x24),
139                alert: Color32::from_rgb(0xE0, 0x52, 0x7A),
140                text: Color32::from_rgb(0x1F, 0x2E, 0x30),
141            },
142            (ArticCyan, Light) => ThemeColors {
143                node: Color32::from_rgb(0x4F, 0xE0, 0xFF),
144                segment: Color32::from_rgb(0x37, 0x5E, 0x68),
145                selected: Color32::from_rgb(0xFB, 0xBF, 0x24),
146                alert: Color32::from_rgb(0xFF, 0x6B, 0x95),
147                text: Color32::from_rgb(0xD3, 0xEA, 0xEF),
148            },
149            (CrimsonSignal, Dark) => ThemeColors {
150                node: Color32::from_rgb(0xE3, 0x5B, 0x6B),
151                segment: Color32::from_rgb(0x5C, 0x45, 0x48),
152                selected: Color32::from_rgb(0x58, 0xA6, 0xFF),
153                alert: Color32::from_rgb(0xFF, 0xC4, 0x59),
154                text: Color32::from_rgb(0xE8, 0xD6, 0xD8),
155            },
156            (CrimsonSignal, Light) => ThemeColors {
157                node: Color32::from_rgb(0x7A, 0x1F, 0x2B),
158                segment: Color32::from_rgb(0xB9, 0xA8, 0xA8),
159                selected: Color32::from_rgb(0x1F, 0x6F, 0xEB),
160                alert: Color32::from_rgb(0xE8, 0xA6, 0x28),
161                text: Color32::from_rgb(0x33, 0x26, 0x28),
162            },
163
164            (MidnightIndigo, Dark) => ThemeColors {
165                node: Color32::from_rgb(0x7B, 0x82, 0xE0),
166                segment: Color32::from_rgb(0x3A, 0x3D, 0x66),
167                selected: Color32::from_rgb(0x2D, 0xD4, 0xC8),
168                alert: Color32::from_rgb(0xFF, 0xD1, 0x66),
169                text: Color32::from_rgb(0xD8, 0xDA, 0xF0),
170            },
171            (MidnightIndigo, Light) => ThemeColors {
172                node: Color32::from_rgb(0x2B, 0x2F, 0x77),
173                segment: Color32::from_rgb(0xA6, 0xA9, 0xC9),
174                selected: Color32::from_rgb(0x00, 0xB8, 0xA9),
175                alert: Color32::from_rgb(0xE0, 0xA4, 0x00),
176                text: Color32::from_rgb(0x26, 0x29, 0x40),
177            },
178
179            (CopperRose, Dark) => ThemeColors {
180                node: Color32::from_rgb(0xE0, 0x8B, 0x6F),
181                segment: Color32::from_rgb(0x5E, 0x45, 0x3D),
182                selected: Color32::from_rgb(0x4F, 0xC3, 0xAE),
183                alert: Color32::from_rgb(0xFF, 0xC6, 0x5C),
184                text: Color32::from_rgb(0xEF, 0xDA, 0xD0),
185            },
186            (CopperRose, Light) => ThemeColors {
187                node: Color32::from_rgb(0x9C, 0x4A, 0x3C),
188                segment: Color32::from_rgb(0xD9, 0xB8, 0xAE),
189                selected: Color32::from_rgb(0x2F, 0x7A, 0x6B),
190                alert: Color32::from_rgb(0xE0, 0xA2, 0x3C),
191                text: Color32::from_rgb(0x3D, 0x2E, 0x29),
192            },
193
194            (LimeCircuit, Dark) => ThemeColors {
195                node: Color32::from_rgb(0xA8, 0xE0, 0x5F),
196                segment: Color32::from_rgb(0x44, 0x52, 0x30),
197                selected: Color32::from_rgb(0xA7, 0x8B, 0xFA),
198                alert: Color32::from_rgb(0xFF, 0x85, 0x52),
199                text: Color32::from_rgb(0xDC, 0xEA, 0xC0),
200            },
201            (LimeCircuit, Light) => ThemeColors {
202                node: Color32::from_rgb(0x4D, 0x7A, 0x1F),
203                segment: Color32::from_rgb(0xB9, 0xC7, 0x9A),
204                selected: Color32::from_rgb(0x7B, 0x3F, 0xE4),
205                alert: Color32::from_rgb(0xE8, 0x5D, 0x2E),
206                text: Color32::from_rgb(0x2E, 0x33, 0x20),
207            },
208
209            (CoralReef, Dark) => ThemeColors {
210                node: Color32::from_rgb(0xFF, 0x8B, 0x73),
211                segment: Color32::from_rgb(0x38, 0x65, 0x60),
212                selected: Color32::from_rgb(0x5C, 0xA8, 0xE0),
213                alert: Color32::from_rgb(0xFF, 0xC1, 0x5E),
214                text: Color32::from_rgb(0xD6, 0xED, 0xE8),
215            },
216            (CoralReef, Light) => ThemeColors {
217                node: Color32::from_rgb(0xD6, 0x5A, 0x45),
218                segment: Color32::from_rgb(0xA8, 0xD4, 0xCE),
219                selected: Color32::from_rgb(0x1D, 0x5C, 0x9E),
220                alert: Color32::from_rgb(0xF2, 0xA9, 0x3C),
221                text: Color32::from_rgb(0x33, 0x40, 0x3E),
222            },
223
224            (GraphiteMono, Dark) => ThemeColors {
225                node: Color32::from_rgb(0xD6, 0xD6, 0xD2),
226                segment: Color32::from_rgb(0x4A, 0x4A, 0x46),
227                selected: Color32::from_rgb(0x4F, 0xB3, 0xF5),
228                alert: Color32::from_rgb(0xFF, 0x6B, 0x5C),
229                text: Color32::from_rgb(0xE8, 0xE8, 0xE4),
230            },
231            (GraphiteMono, Light) => ThemeColors {
232                node: Color32::from_rgb(0x3A, 0x3A, 0x3A),
233                segment: Color32::from_rgb(0xB8, 0xB8, 0xB4),
234                selected: Color32::from_rgb(0x1F, 0x8F, 0xE0),
235                alert: Color32::from_rgb(0xE0, 0x48, 0x3A),
236                text: Color32::from_rgb(0x23, 0x23, 0x23),
237            },
238
239            (PlumStatic, Dark) => ThemeColors {
240                node: Color32::from_rgb(0xC9, 0x94, 0xBB),
241                segment: Color32::from_rgb(0x4A, 0x3A, 0x45),
242                selected: Color32::from_rgb(0x52, 0xC9, 0x9A),
243                alert: Color32::from_rgb(0xFF, 0xA0, 0x5C),
244                text: Color32::from_rgb(0xEB, 0xD9, 0xE5),
245            },
246            (PlumStatic, Light) => ThemeColors {
247                node: Color32::from_rgb(0x6B, 0x3B, 0x5E),
248                segment: Color32::from_rgb(0xC7, 0xAE, 0xC0),
249                selected: Color32::from_rgb(0x2E, 0x8B, 0x6E),
250                alert: Color32::from_rgb(0xE0, 0x79, 0x3D),
251                text: Color32::from_rgb(0x36, 0x2B, 0x33),
252            },
253
254            (SandstoneTrail, Dark) => ThemeColors {
255                node: Color32::from_rgb(0xC9, 0xAD, 0x72),
256                segment: Color32::from_rgb(0x4E, 0x45, 0x30),
257                selected: Color32::from_rgb(0x4F, 0xA8, 0xCC),
258                alert: Color32::from_rgb(0xF0, 0x70, 0x8A),
259                text: Color32::from_rgb(0xE6, 0xD9, 0xB8),
260            },
261            (SandstoneTrail, Light) => ThemeColors {
262                node: Color32::from_rgb(0x6B, 0x5A, 0x3A),
263                segment: Color32::from_rgb(0xDC, 0xCB, 0xA0),
264                selected: Color32::from_rgb(0x2A, 0x6E, 0x8C),
265                alert: Color32::from_rgb(0xD1, 0x49, 0x5B),
266                text: Color32::from_rgb(0x3A, 0x31, 0x21),
267            },
268        }
269    }
270}
271
272/// A resolved color palette: the actual colors a [`Theme`] (or any other
273/// [`MapTheme`] implementation) uses to paint the map under one
274/// [`ColorMode`].
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub struct ThemeColors {
277    /// Color used to fill node shapes.
278    pub node: Color32,
279    /// Color used for connection lines between nodes.
280    pub segment: Color32,
281    /// Color used for the selection highlight over the nearest node.
282    pub selected: Color32,
283    /// Color used for notification and alert animations.
284    pub alert: Color32,
285    /// Color used for node names and map labels.
286    pub text: Color32,
287}
288
289/// Which of a theme's two color variants to use.
290#[derive(Clone, Copy, Debug, PartialEq, Eq)]
291pub enum ColorMode {
292    /// The light variant.
293    Light,
294    /// The dark variant.
295    Dark,
296}
297
298/// Customization point for the color palette used to paint the map.
299///
300/// Implement this to install your own palette instead of one of the
301/// built-in [`Theme`] variants -- install it with
302/// [`Map::set_theme`](super::Map::set_theme). `Theme` itself implements
303/// `MapTheme` by delegating to [`Theme::colors`], so switching between a
304/// built-in palette and a custom one is just a different argument to
305/// `set_theme`.
306///
307/// ```
308/// use egui_map::map::theme::{ColorMode, MapTheme, ThemeColors};
309/// use egui::Color32;
310///
311/// struct HighContrast;
312///
313/// impl MapTheme for HighContrast {
314///     fn colors(&self, mode: ColorMode) -> ThemeColors {
315///         match mode {
316///             ColorMode::Light => ThemeColors {
317///                 node: Color32::BLACK,
318///                 segment: Color32::DARK_GRAY,
319///                 selected: Color32::RED,
320///                 alert: Color32::RED,
321///                 text: Color32::BLACK,
322///             },
323///             ColorMode::Dark => ThemeColors {
324///                 node: Color32::WHITE,
325///                 segment: Color32::LIGHT_GRAY,
326///                 selected: Color32::YELLOW,
327///                 alert: Color32::YELLOW,
328///                 text: Color32::WHITE,
329///             },
330///         }
331///     }
332/// }
333///
334/// let mut map = egui_map::map::Map::new();
335/// map.set_theme(std::rc::Rc::new(HighContrast));
336/// ```
337pub trait MapTheme {
338    /// Returns the color palette to use for the given [`ColorMode`].
339    fn colors(&self, mode: ColorMode) -> ThemeColors;
340}
341
342impl MapTheme for Theme {
343    fn colors(&self, mode: ColorMode) -> ThemeColors {
344        Theme::colors(*self, mode)
345    }
346}
347
348/// Visual style used to paint the map under a given theme.
349///
350/// Holds the non-palette visual configuration -- stroke widths, font,
351/// background -- that combines with the active [`MapTheme`]'s
352/// [`ThemeColors`] when the widget paints; the widget refreshes
353/// [`fill_color`](Self::fill_color), [`alert_color`](Self::alert_color), the
354/// [`line`](Self::line) color and [`text_color`](Self::text_color) from the
355/// installed theme whenever the color mode changes, so a `Style` never needs
356/// its own copy of those colors.
357///
358/// Multiplying or dividing a `Style` by a number scales the stroke widths
359/// and the font size, leaving colors untouched; the widget uses this to scale
360/// the active style with the current zoom factor. Fields that are `None` are
361/// left untouched by those operators.
362#[derive(Clone, Debug)]
363pub struct Style {
364    /// Stroke used for the widget border.
365    pub border: Option<Stroke>,
366    /// Stroke used for the connection lines between nodes.
367    pub line: Option<Stroke>,
368    /// Color used to fill node shapes.
369    pub fill_color: Color32,
370    /// Color used for text.
371    pub text_color: Color32,
372    /// Font used for map labels.
373    pub font: Option<FontId>,
374    /// Background color of the map canvas.
375    pub background_color: Color32,
376    /// Color used for notification pulse animations.
377    pub alert_color: Color32,
378}
379
380impl Style {
381    /// Creates a fully transparent style with no border, line or font.
382    pub fn new() -> Self {
383        Style {
384            border: None,
385            line: None,
386            fill_color: Color32::TRANSPARENT,
387            text_color: Color32::TRANSPARENT,
388            font: None,
389            background_color: Color32::TRANSPARENT,
390            alert_color: Color32::TRANSPARENT,
391        }
392    }
393}
394
395impl Default for Style {
396    fn default() -> Self {
397        Style::new()
398    }
399}
400
401impl Style {
402    /// Returns a copy with the stroke widths and font size scaled by `factor`.
403    /// Fields that are `None` are left untouched.
404    fn scaled(mut self, factor: f32) -> Self {
405        if let Some(border) = self.border.as_mut() {
406            border.width *= factor;
407        }
408        if let Some(line) = self.line.as_mut() {
409            line.width *= factor;
410        }
411        if let Some(font) = self.font.as_mut() {
412            font.size *= factor;
413        }
414        self
415    }
416}
417
418impl Mul<i64> for Style {
419    type Output = Self;
420
421    fn mul(self, rhs: i64) -> Self::Output {
422        self.scaled(rhs as f32)
423    }
424}
425
426impl Mul<i32> for Style {
427    type Output = Self;
428
429    fn mul(self, rhs: i32) -> Self::Output {
430        self.scaled(rhs as f32)
431    }
432}
433
434impl Mul<f32> for Style {
435    type Output = Self;
436
437    fn mul(self, rhs: f32) -> Self::Output {
438        self.scaled(rhs)
439    }
440}
441
442impl Mul<f64> for Style {
443    type Output = Self;
444
445    fn mul(self, rhs: f64) -> Self::Output {
446        self.scaled(rhs as f32)
447    }
448}
449
450impl Div<i64> for Style {
451    type Output = Self;
452
453    fn div(self, rhs: i64) -> Self::Output {
454        self.scaled(1.0 / rhs as f32)
455    }
456}
457
458impl Div<i32> for Style {
459    type Output = Self;
460
461    fn div(self, rhs: i32) -> Self::Output {
462        self.scaled(1.0 / rhs as f32)
463    }
464}
465
466impl Div<f32> for Style {
467    type Output = Self;
468
469    fn div(self, rhs: f32) -> Self::Output {
470        self.scaled(1.0 / rhs)
471    }
472}
473
474impl Div<f64> for Style {
475    type Output = Self;
476
477    fn div(self, rhs: f64) -> Self::Output {
478        self.scaled(1.0 / rhs as f32)
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    const ALL_THEMES: [Theme; 14] = [
487        Theme::SlateOcean,
488        Theme::NebulaViolet,
489        Theme::TerminalGreen,
490        Theme::EmberForge,
491        Theme::SolarAmber,
492        Theme::ArticCyan,
493        Theme::CrimsonSignal,
494        Theme::MidnightIndigo,
495        Theme::CopperRose,
496        Theme::LimeCircuit,
497        Theme::CoralReef,
498        Theme::GraphiteMono,
499        Theme::PlumStatic,
500        Theme::SandstoneTrail,
501    ];
502
503    #[test]
504    fn every_theme_has_distinct_light_and_dark_palettes() {
505        // Regression test for a typo (`Ligth` instead of `Light`) that once
506        // made an unqualified identifier bind irrefutably in a match arm,
507        // silently turning `(SolarAmber, Light)` into a catch-all -- it only
508        // still looked right because `(SolarAmber, Dark)`, listed first,
509        // matched exactly and won for that case, leaving the buggy arm to
510        // fire (and return the *wrong*, `Dark`-shaped) colors for `Light`.
511        for theme in ALL_THEMES {
512            let light = theme.colors(ColorMode::Light);
513            let dark = theme.colors(ColorMode::Dark);
514            assert_ne!(
515                light, dark,
516                "{theme:?}'s Light and Dark palettes must not be identical"
517            );
518        }
519    }
520
521    #[test]
522    fn solar_amber_light_matches_its_defined_palette() {
523        // Pins the exact SolarAmber/Light values so a regression back to the
524        // `Ligth` typo -- which made this arm silently reuse whatever
525        // `(SolarAmber, Dark)` produced -- is caught even though `assert_ne`
526        // alone only proves the two differ, not that `Light` is *this*
527        // palette.
528        assert_eq!(
529            Theme::SolarAmber.colors(ColorMode::Light),
530            ThemeColors {
531                node: Color32::from_rgb(0xF0, 0xC2, 0x4C),
532                segment: Color32::from_rgb(0x5A, 0x4E, 0x2E),
533                selected: Color32::from_rgb(0x4F, 0xBF, 0x9E),
534                alert: Color32::from_rgb(0xE8, 0x65, 0x4A),
535                text: Color32::from_rgb(0xEF, 0xE4, 0xC4),
536            }
537        );
538    }
539
540    #[test]
541    fn theme_implements_map_theme_by_delegating_to_colors() {
542        let theme: &dyn MapTheme = &Theme::TerminalGreen;
543        assert_eq!(
544            theme.colors(ColorMode::Dark),
545            Theme::TerminalGreen.colors(ColorMode::Dark)
546        );
547        assert_eq!(
548            theme.colors(ColorMode::Light),
549            Theme::TerminalGreen.colors(ColorMode::Light)
550        );
551    }
552}