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}