Skip to main content

pfx_editor_style/
theme.rs

1use std::borrow::Cow;
2use std::sync::Arc;
3
4use egui::style::{Selection, WidgetVisuals, Widgets};
5use egui::text::LayoutJob;
6use egui::{
7    Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Id, Margin,
8    Shadow, Stroke, Style, TextStyle, ThemePreference, Vec2, Visuals,
9};
10
11#[derive(Clone, Debug, PartialEq, Eq)]
12pub struct Face {
13    pub name: Cow<'static, str>,
14    pub regular: Cow<'static, [u8]>,
15    pub bold: Cow<'static, [u8]>,
16}
17
18impl Face {
19    pub fn regular_name(&self) -> String {
20        format!("{}-Regular", self.name)
21    }
22
23    pub fn bold_name(&self) -> String {
24        format!("{}-Bold", self.name)
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct Display {
30    pub name: Cow<'static, str>,
31    pub bytes: Cow<'static, [u8]>,
32    pub licence: Cow<'static, str>,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub struct Caps {
37    pub upper: bool,
38    pub tracking: f32,
39}
40
41impl Caps {
42    pub const PLAIN: Caps = Caps {
43        upper: false,
44        tracking: 0.0,
45    };
46
47    pub fn text<'a>(self, text: &'a str) -> Cow<'a, str> {
48        if self.upper {
49            Cow::Owned(text.to_uppercase())
50        } else {
51            Cow::Borrowed(text)
52        }
53    }
54
55    pub fn job(self, text: &str, font: FontId, colour: Color32) -> LayoutJob {
56        let spacing = self.tracking * font.size;
57        let mut job = LayoutJob::simple(self.text(text).into_owned(), font, colour, f32::INFINITY);
58        for section in &mut job.sections {
59            section.format.extra_letter_spacing = spacing;
60        }
61        job
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66pub struct Syntax {
67    pub key: Color32,
68    pub table: Color32,
69    pub string: Color32,
70    pub number: Color32,
71    pub boolean: Color32,
72    pub date: Color32,
73    pub comment: Color32,
74    pub punctuation: Color32,
75    pub plain: Color32,
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub struct Diff {
80    pub add: Color32,
81    pub del: Color32,
82    pub change: Color32,
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct Theme {
87    pub name: Cow<'static, str>,
88    pub bg: Color32,
89    pub surface: Color32,
90    pub well: Color32,
91    pub line: Color32,
92    pub track: Color32,
93    pub text: Color32,
94    pub text2: Color32,
95    pub muted: Color32,
96    pub dim: Color32,
97    pub code: Color32,
98    pub on_accent: Color32,
99    pub accent: Color32,
100    pub accent_bright: Color32,
101    pub accent_deep: Color32,
102    pub secondary: Color32,
103    pub secondary_soft: Color32,
104    pub hot: Color32,
105    pub hot_dim: Color32,
106    pub ok: Color32,
107    pub ok_tint: Color32,
108    pub error: Color32,
109    pub error_tint: Color32,
110    pub warning: Color32,
111    pub syntax: Syntax,
112    pub diff: Diff,
113    pub size_code: f32,
114    pub size_small: f32,
115    pub size_body: f32,
116    pub size_heading: f32,
117    pub size_title: f32,
118    pub radius: u8,
119    pub stroke: f32,
120    pub panel_padding: i8,
121    pub item_spacing: f32,
122    pub section_gap: f32,
123    pub dash: f32,
124    pub row_height: f32,
125    pub body: Face,
126    pub display: Option<Display>,
127    pub titles: Caps,
128    pub labels: Caps,
129}
130
131impl Theme {
132    pub fn of(ctx: &Context) -> Arc<Theme> {
133        ctx.data(|data| data.get_temp::<Arc<Theme>>(key()))
134            .unwrap_or_else(|| panic!("{UNTHEMED}"))
135    }
136
137    pub fn font(&self, size: f32) -> FontId {
138        FontId::new(size, FontFamily::Monospace)
139    }
140
141    pub fn bold_font(&self, size: f32) -> FontId {
142        FontId::new(size, bold())
143    }
144
145    pub fn title_font(&self, size: f32) -> FontId {
146        match self.display {
147            Some(_) => FontId::new(size, display()),
148            None => self.font(size),
149        }
150    }
151
152    pub fn heading_font(&self, size: f32) -> FontId {
153        match self.display {
154            Some(_) => FontId::new(size, display()),
155            None => self.bold_font(size),
156        }
157    }
158}
159
160pub const UNTHEMED: &str = "no theme applied: call theme::apply_theme first";
161
162fn key() -> Id {
163    Id::new("editor style theme")
164}
165
166pub fn bold() -> FontFamily {
167    FontFamily::Name("bold".into())
168}
169
170pub fn display() -> FontFamily {
171    FontFamily::Name("display".into())
172}
173
174pub fn code() -> TextStyle {
175    TextStyle::Name("code".into())
176}
177
178pub fn title() -> TextStyle {
179    TextStyle::Name("title".into())
180}
181
182fn data(bytes: Cow<'static, [u8]>) -> Arc<FontData> {
183    Arc::new(match bytes {
184        Cow::Borrowed(bytes) => FontData::from_static(bytes),
185        Cow::Owned(bytes) => FontData::from_owned(bytes),
186    })
187}
188
189pub fn fonts(theme: &Theme) -> FontDefinitions {
190    let regular = theme.body.regular_name();
191    let bold_name = theme.body.bold_name();
192    let mut fonts = FontDefinitions::empty();
193    fonts
194        .font_data
195        .insert(regular.clone(), data(theme.body.regular.clone()));
196    fonts
197        .font_data
198        .insert(bold_name.clone(), data(theme.body.bold.clone()));
199    fonts
200        .families
201        .insert(FontFamily::Proportional, vec![regular.clone()]);
202    fonts
203        .families
204        .insert(FontFamily::Monospace, vec![regular.clone()]);
205    fonts
206        .families
207        .insert(bold(), vec![bold_name.clone(), regular.clone()]);
208    if let Some(face) = &theme.display {
209        let name = face.name.to_string();
210        fonts
211            .font_data
212            .insert(name.clone(), data(face.bytes.clone()));
213        fonts
214            .families
215            .insert(display(), vec![name, bold_name, regular]);
216    }
217    fonts
218}
219
220fn widget(theme: &Theme, fill: Color32, border: Color32, text: Color32) -> WidgetVisuals {
221    WidgetVisuals {
222        bg_fill: fill,
223        weak_bg_fill: fill,
224        bg_stroke: Stroke::new(theme.stroke, border),
225        corner_radius: CornerRadius::same(theme.radius),
226        fg_stroke: Stroke::new(theme.stroke, text),
227        expansion: 0.0,
228    }
229}
230
231pub fn visuals(theme: &Theme) -> Visuals {
232    let mut visuals = Visuals::dark();
233    visuals.dark_mode = true;
234    visuals.override_text_color = None;
235    visuals.weak_text_color = Some(theme.muted);
236    visuals.widgets = Widgets {
237        noninteractive: widget(theme, theme.bg, theme.line, theme.text),
238        inactive: widget(theme, theme.surface, theme.line, theme.text),
239        hovered: widget(theme, theme.accent_deep, theme.accent, theme.text),
240        active: widget(
241            theme,
242            theme.accent_bright,
243            theme.accent_bright,
244            theme.on_accent,
245        ),
246        open: widget(theme, theme.accent_deep, theme.accent, theme.text),
247    };
248    visuals.selection = Selection {
249        bg_fill: theme.accent_bright,
250        stroke: Stroke::new(theme.stroke, theme.on_accent),
251    };
252    visuals.hyperlink_color = theme.secondary;
253    visuals.faint_bg_color = theme.accent_deep;
254    visuals.extreme_bg_color = theme.well;
255    visuals.text_edit_bg_color = None;
256    visuals.code_bg_color = theme.surface;
257    visuals.warn_fg_color = theme.warning;
258    visuals.error_fg_color = theme.error;
259    visuals.window_corner_radius = CornerRadius::same(theme.radius);
260    visuals.window_shadow = Shadow::NONE;
261    visuals.window_fill = theme.bg;
262    visuals.window_stroke = Stroke::new(theme.stroke, theme.line);
263    visuals.window_highlight_topmost = false;
264    visuals.menu_corner_radius = CornerRadius::same(theme.radius);
265    visuals.panel_fill = theme.bg;
266    visuals.popup_shadow = Shadow::NONE;
267    visuals.text_cursor.stroke = Stroke::new(2.0_f32, theme.hot);
268    visuals.text_cursor.blink = false;
269    visuals.collapsing_header_frame = false;
270    visuals.indent_has_left_vline = true;
271    visuals.striped = true;
272    visuals.slider_trailing_fill = true;
273    visuals
274}
275
276pub fn style(theme: &Theme) -> Style {
277    let mut style = Style {
278        visuals: visuals(theme),
279        ..Style::default()
280    };
281    style.text_styles = [
282        (TextStyle::Small, theme.font(theme.size_small)),
283        (TextStyle::Body, theme.font(theme.size_body)),
284        (TextStyle::Button, theme.font(theme.size_body)),
285        (TextStyle::Monospace, theme.font(theme.size_code)),
286        (TextStyle::Heading, theme.heading_font(theme.size_heading)),
287        (code(), theme.font(theme.size_code)),
288        (title(), theme.heading_font(theme.size_title)),
289    ]
290    .into();
291    style.spacing.item_spacing = Vec2::splat(theme.item_spacing);
292    style.spacing.window_margin = Margin::same(theme.panel_padding);
293    style.spacing.menu_margin = Margin::same(theme.item_spacing as i8);
294    style.spacing.button_padding = Vec2::new(8.0, 3.0);
295    style.spacing.interact_size = Vec2::new(40.0, theme.row_height);
296    style.spacing.indent = theme.section_gap;
297    style.spacing.combo_height = 240.0;
298    style.interaction.selectable_labels = false;
299    style.animation_time = 0.0;
300    style
301}
302
303pub fn apply_theme(ctx: &Context, theme: &Theme) {
304    ctx.set_fonts(fonts(theme));
305    ctx.options_mut(|options| {
306        options.theme_preference = ThemePreference::Dark;
307        options.fallback_theme = egui::Theme::Dark;
308        options.zoom_with_keyboard = false;
309    });
310    ctx.set_style_of(egui::Theme::Dark, style(theme));
311    ctx.set_style_of(egui::Theme::Light, style(theme));
312    ctx.data_mut(|data| data.insert_temp(key(), Arc::new(theme.clone())));
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::fixture::{self, BOLD, REGULAR};
319
320    const REGULAR_NAME: &str = "Inconsolata-Regular";
321    const BOLD_NAME: &str = "Inconsolata-Bold";
322
323    #[test]
324    fn the_theme_applies_without_panicking() {
325        let theme = fixture::theme();
326        let ctx = Context::default();
327        apply_theme(&ctx, &theme);
328        let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
329            egui::CentralPanel::default().show(ui, |ui| {
330                ui.heading("OUTLINER");
331                ui.label("mv_knob");
332                ui.monospace("0.012");
333            });
334        });
335        assert!(!output.shapes.is_empty());
336        output.textures_delta.clear();
337        let style = ctx.global_style();
338        assert_eq!(style.visuals.panel_fill, theme.bg);
339        assert_eq!(style.visuals.extreme_bg_color, theme.well);
340        assert_eq!(style.visuals.widgets.active.bg_fill, theme.accent_bright);
341        assert_eq!(style.visuals.window_shadow, Shadow::NONE);
342        assert_eq!(style.text_styles[&TextStyle::Body].size, theme.size_body);
343        assert_eq!(style.text_styles[&title()].family, bold());
344    }
345
346    #[test]
347    fn one_family_carries_both_generic_families() {
348        let fonts = fonts(&fixture::theme());
349        assert_eq!(
350            fonts.families[&FontFamily::Proportional],
351            fonts.families[&FontFamily::Monospace]
352        );
353        assert_eq!(fonts.font_data.len(), 2);
354        assert!(!fonts.families.contains_key(&display()));
355    }
356
357    #[test]
358    fn apply_installs_only_the_themes_fonts_and_its_visuals_on_a_fresh_context() {
359        let theme = fixture::theme();
360        let ctx = Context::default();
361        apply_theme(&ctx, &theme);
362        let mut seen = None;
363        ctx.run_ui(egui::RawInput::default(), |ui| {
364            ui.ctx()
365                .fonts(|fonts| seen = Some(fonts.definitions().clone()));
366        })
367        .textures_delta
368        .clear();
369        let installed = seen.unwrap();
370        assert_eq!(installed.font_data.len(), 2);
371        assert_eq!(installed.font_data[REGULAR_NAME].font, REGULAR);
372        assert_eq!(installed.font_data[BOLD_NAME].font, BOLD);
373        assert_eq!(installed.families[&bold()][0], BOLD_NAME);
374        assert_eq!(installed.families[&FontFamily::Monospace], [REGULAR_NAME]);
375        for mode in [egui::Theme::Dark, egui::Theme::Light] {
376            let style = ctx.style_of(mode);
377            assert_eq!(style.visuals.panel_fill, theme.bg);
378            assert_eq!(style.visuals.widgets.inactive.bg_fill, theme.surface);
379            assert_eq!(style.visuals.selection.bg_fill, theme.accent_bright);
380        }
381        assert_eq!(
382            ctx.options(|options| options.theme_preference),
383            ThemePreference::Dark
384        );
385    }
386
387    #[test]
388    #[should_panic(expected = "no theme applied: call theme::apply_theme first")]
389    fn reading_a_theme_before_one_is_applied_panics() {
390        Theme::of(&Context::default());
391    }
392
393    #[test]
394    fn a_context_reads_back_the_last_theme_applied() {
395        let ctx = Context::default();
396        let first = fixture::theme();
397        apply_theme(&ctx, &first);
398        assert_eq!(*Theme::of(&ctx), first);
399        let theme = Theme {
400            name: Cow::Borrowed("test"),
401            hot: Color32::from_rgb(1, 2, 3),
402            size_body: 15.0,
403            ..fixture::theme()
404        };
405        apply_theme(&ctx, &theme);
406        assert_eq!(*Theme::of(&ctx), theme);
407        assert_eq!(ctx.global_style().text_styles[&TextStyle::Body].size, 15.0);
408        assert_eq!(
409            ctx.global_style().visuals.text_cursor.stroke.color,
410            Color32::from_rgb(1, 2, 3)
411        );
412        apply_theme(&ctx, &first);
413        assert_eq!(*Theme::of(&ctx), first);
414    }
415
416    #[test]
417    fn a_display_face_carries_titles_and_headings() {
418        let theme = Theme {
419            display: Some(Display {
420                name: Cow::Borrowed("Display"),
421                bytes: Cow::Borrowed(BOLD),
422                licence: Cow::Borrowed(fixture::LICENCE),
423            }),
424            ..fixture::theme()
425        };
426        let fonts = fonts(&theme);
427        assert_eq!(fonts.font_data.len(), 3);
428        assert_eq!(fonts.families[&display()][0], "Display");
429        let style = style(&theme);
430        assert_eq!(style.text_styles[&title()].family, display());
431        assert_eq!(style.text_styles[&TextStyle::Heading].family, display());
432        assert_eq!(theme.title_font(12.0).family, display());
433        assert_eq!(
434            fixture::theme().title_font(12.0).family,
435            FontFamily::Monospace
436        );
437        let ctx = Context::default();
438        apply_theme(&ctx, &theme);
439        ctx.run_ui(egui::RawInput::default(), |ui| {
440            egui::CentralPanel::default().show(ui, |ui| {
441                ui.label(egui::RichText::new("TITLE").text_style(title()));
442            });
443        })
444        .textures_delta
445        .clear();
446    }
447
448    #[test]
449    fn caps_upper_and_track_by_the_font_size() {
450        let theme = fixture::theme();
451        let caps = Caps {
452            upper: true,
453            tracking: 0.3,
454        };
455        let job = caps.job("outliner", theme.font(10.0), Color32::WHITE);
456        assert_eq!(job.text, "OUTLINER");
457        assert_eq!(job.sections[0].format.extra_letter_spacing, 3.0);
458        let plain = Caps::PLAIN.job("outliner", theme.font(10.0), Color32::WHITE);
459        assert_eq!(
460            plain,
461            LayoutJob::simple(
462                "outliner".into(),
463                theme.font(10.0),
464                Color32::WHITE,
465                f32::INFINITY
466            )
467        );
468    }
469}