| 1 | //! Nothing design tokens mapped onto egui: palettes for both themes, the |
| 2 | //! bundled fonts, and text helpers that keep the type budget in one place. |
| 3 | |
| 4 | use std::sync::Arc; |
| 5 | |
| 6 | use bench51_core::syntax::Tok; |
| 7 | use eframe::egui::{ |
| 8 | self, Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, RichText, |
| 9 | Shadow, Stroke, Style, TextStyle, Theme, Visuals, |
| 10 | epaint::text::{FontTweak, VariationCoords}, |
| 11 | }; |
| 12 | |
| 13 | /// The colour tokens of one theme (`references/tokens.md` §2). |
| 14 | pub struct Palette { |
| 15 | /// Page background: OLED black / warm off-white paper. |
| 16 | pub black: Color32, |
| 17 | pub surface: Color32, |
| 18 | pub surface_raised: Color32, |
| 19 | pub border: Color32, |
| 20 | pub border_visible: Color32, |
| 21 | pub text_disabled: Color32, |
| 22 | pub text_secondary: Color32, |
| 23 | pub text_primary: Color32, |
| 24 | pub text_display: Color32, |
| 25 | pub interactive: Color32, |
| 26 | } |
| 27 | |
| 28 | pub const DARK: Palette = Palette { |
| 29 | black: Color32::from_rgb(0x00, 0x00, 0x00), |
| 30 | surface: Color32::from_rgb(0x11, 0x11, 0x11), |
| 31 | surface_raised: Color32::from_rgb(0x1A, 0x1A, 0x1A), |
| 32 | border: Color32::from_rgb(0x22, 0x22, 0x22), |
| 33 | border_visible: Color32::from_rgb(0x33, 0x33, 0x33), |
| 34 | text_disabled: Color32::from_rgb(0x66, 0x66, 0x66), |
| 35 | text_secondary: Color32::from_rgb(0x99, 0x99, 0x99), |
| 36 | text_primary: Color32::from_rgb(0xE8, 0xE8, 0xE8), |
| 37 | text_display: Color32::from_rgb(0xFF, 0xFF, 0xFF), |
| 38 | interactive: Color32::from_rgb(0x5B, 0x9B, 0xF6), |
| 39 | }; |
| 40 | |
| 41 | pub const LIGHT: Palette = Palette { |
| 42 | black: Color32::from_rgb(0xF5, 0xF5, 0xF5), |
| 43 | surface: Color32::from_rgb(0xFF, 0xFF, 0xFF), |
| 44 | surface_raised: Color32::from_rgb(0xF0, 0xF0, 0xF0), |
| 45 | border: Color32::from_rgb(0xE8, 0xE8, 0xE8), |
| 46 | border_visible: Color32::from_rgb(0xCC, 0xCC, 0xCC), |
| 47 | text_disabled: Color32::from_rgb(0x99, 0x99, 0x99), |
| 48 | text_secondary: Color32::from_rgb(0x66, 0x66, 0x66), |
| 49 | text_primary: Color32::from_rgb(0x1A, 0x1A, 0x1A), |
| 50 | text_display: Color32::from_rgb(0x00, 0x00, 0x00), |
| 51 | interactive: Color32::from_rgb(0x00, 0x7A, 0xFF), |
| 52 | }; |
| 53 | |
| 54 | /// Signal red: breakpoints, errors, the PC bar. Never decorative. |
| 55 | pub const ACCENT: Color32 = Color32::from_rgb(0xD7, 0x19, 0x21); |
| 56 | /// Values that changed since the last step. |
| 57 | pub const WARNING: Color32 = Color32::from_rgb(0xD4, 0xA8, 0x43); |
| 58 | |
| 59 | /// The three type sizes: labels, body/data, and the one hero per screen. |
| 60 | pub const LABEL: f32 = 11.0; |
| 61 | pub const BODY: f32 = 14.0; |
| 62 | pub const HERO: f32 = 44.0; |
| 63 | /// Doto at welcome-screen scale. |
| 64 | pub const HERO_XL: f32 = 72.0; |
| 65 | /// Tracking for ALL CAPS labels (0.08em at 11px). |
| 66 | pub const LABEL_TRACKING: f32 = 0.9; |
| 67 | |
| 68 | pub fn palette(ctx: &Context) -> &'static Palette { |
| 69 | match ctx.theme() { |
| 70 | Theme::Dark => &DARK, |
| 71 | Theme::Light => &LIGHT, |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | pub fn mono_bold() -> FontFamily { |
| 76 | FontFamily::Name("mono-bold".into()) |
| 77 | } |
| 78 | |
| 79 | pub fn grotesk_medium() -> FontFamily { |
| 80 | FontFamily::Name("grotesk-medium".into()) |
| 81 | } |
| 82 | |
| 83 | pub fn doto() -> FontFamily { |
| 84 | FontFamily::Name("doto".into()) |
| 85 | } |
| 86 | |
| 87 | pub fn mono(size: f32) -> FontId { |
| 88 | FontId::monospace(size) |
| 89 | } |
| 90 | |
| 91 | impl Palette { |
| 92 | /// Instrument-panel label: Space Mono, ALL CAPS, tracked, secondary grey. |
| 93 | pub fn label(&self, text: impl AsRef<str>) -> RichText { |
| 94 | RichText::new(text.as_ref().to_uppercase()) |
| 95 | .font(mono(LABEL)) |
| 96 | .extra_letter_spacing(LABEL_TRACKING) |
| 97 | .color(self.text_secondary) |
| 98 | } |
| 99 | |
| 100 | /// Data value in Space Mono at body size. |
| 101 | pub fn data(&self, text: impl Into<String>) -> RichText { |
| 102 | RichText::new(text) |
| 103 | .font(mono(BODY)) |
| 104 | .color(self.text_primary) |
| 105 | } |
| 106 | |
| 107 | /// Dot-matrix hero number. One per screen. |
| 108 | pub fn hero(&self, text: impl Into<String>, size: f32) -> RichText { |
| 109 | RichText::new(text) |
| 110 | .font(FontId::new(size, doto())) |
| 111 | .color(self.text_display) |
| 112 | } |
| 113 | |
| 114 | /// Syntax colours: greyscale first, hierarchy by brightness and weight. |
| 115 | pub fn syntax(&self, tok: Tok) -> (Color32, bool) { |
| 116 | match tok { |
| 117 | Tok::Label => (self.text_display, true), |
| 118 | Tok::Mnemonic => (self.text_display, false), |
| 119 | Tok::Register | Tok::Sfr | Tok::Plain => (self.text_primary, false), |
| 120 | Tok::Number | Tok::Str | Tok::Directive => (self.text_secondary, false), |
| 121 | Tok::Comment => (self.text_disabled, false), |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | fn fonts() -> FontDefinitions { |
| 127 | let mut defs = FontDefinitions::default(); |
| 128 | let mut add = |name: &str, bytes: &'static [u8], tweak: FontTweak| { |
| 129 | defs.font_data.insert( |
| 130 | name.to_owned(), |
| 131 | Arc::new(FontData::from_static(bytes).tweak(tweak)), |
| 132 | ); |
| 133 | }; |
| 134 | add( |
| 135 | "space-grotesk", |
| 136 | include_bytes!("../assets/fonts/SpaceGrotesk-Regular.ttf"), |
| 137 | FontTweak::default(), |
| 138 | ); |
| 139 | add( |
| 140 | "space-grotesk-medium", |
| 141 | include_bytes!("../assets/fonts/SpaceGrotesk-Medium.ttf"), |
| 142 | FontTweak::default(), |
| 143 | ); |
| 144 | add( |
| 145 | "space-mono", |
| 146 | include_bytes!("../assets/fonts/SpaceMono-Regular.ttf"), |
| 147 | FontTweak::default(), |
| 148 | ); |
| 149 | add( |
| 150 | "space-mono-bold", |
| 151 | include_bytes!("../assets/fonts/SpaceMono-Bold.ttf"), |
| 152 | FontTweak::default(), |
| 153 | ); |
| 154 | // Round dots, bold enough to read as a display face. |
| 155 | add( |
| 156 | "doto", |
| 157 | include_bytes!("../assets/fonts/Doto[ROND,wght].ttf"), |
| 158 | FontTweak { |
| 159 | coords: VariationCoords::new([(b"ROND", 100.0), (b"wght", 800.0)]), |
| 160 | ..FontTweak::default() |
| 161 | }, |
| 162 | ); |
| 163 | |
| 164 | // egui's own fonts stay behind ours as fallbacks for symbols. |
| 165 | let prop_fallback = defs.families[&FontFamily::Proportional].clone(); |
| 166 | let mono_fallback = defs.families[&FontFamily::Monospace].clone(); |
| 167 | let with = |first: &[&str], rest: &[String]| -> Vec<String> { |
| 168 | first |
| 169 | .iter() |
| 170 | .map(|s| s.to_string()) |
| 171 | .chain(rest.iter().cloned()) |
| 172 | .collect() |
| 173 | }; |
| 174 | defs.families.insert( |
| 175 | FontFamily::Proportional, |
| 176 | with(&["space-grotesk"], &prop_fallback), |
| 177 | ); |
| 178 | defs.families |
| 179 | .insert(FontFamily::Monospace, with(&["space-mono"], &mono_fallback)); |
| 180 | defs.families |
| 181 | .insert(mono_bold(), with(&["space-mono-bold"], &mono_fallback)); |
| 182 | defs.families.insert( |
| 183 | grotesk_medium(), |
| 184 | with(&["space-grotesk-medium"], &prop_fallback), |
| 185 | ); |
| 186 | defs.families |
| 187 | .insert(doto(), with(&["doto", "space-mono"], &mono_fallback)); |
| 188 | defs |
| 189 | } |
| 190 | |
| 191 | fn visuals(p: &Palette, dark: bool) -> Visuals { |
| 192 | let mut v = if dark { |
| 193 | Visuals::dark() |
| 194 | } else { |
| 195 | Visuals::light() |
| 196 | }; |
| 197 | let technical = CornerRadius::same(4); |
| 198 | v.panel_fill = p.black; |
| 199 | v.window_fill = p.surface; |
| 200 | v.window_stroke = Stroke::new(1.0, p.border_visible); |
| 201 | v.window_shadow = Shadow::NONE; |
| 202 | v.popup_shadow = Shadow::NONE; |
| 203 | v.window_corner_radius = CornerRadius::same(16); |
| 204 | v.menu_corner_radius = CornerRadius::same(8); |
| 205 | v.window_highlight_topmost = false; |
| 206 | v.extreme_bg_color = p.surface; |
| 207 | v.text_edit_bg_color = Some(p.surface); |
| 208 | v.faint_bg_color = p.surface_raised; |
| 209 | v.code_bg_color = p.surface_raised; |
| 210 | v.hyperlink_color = p.interactive; |
| 211 | v.warn_fg_color = WARNING; |
| 212 | v.error_fg_color = ACCENT; |
| 213 | v.selection.bg_fill = if dark { |
| 214 | Color32::from_white_alpha(46) |
| 215 | } else { |
| 216 | Color32::from_black_alpha(36) |
| 217 | }; |
| 218 | v.selection.stroke = Stroke::new(1.0, p.text_display); |
| 219 | v.text_cursor.stroke = Stroke::new(2.0, p.text_display); |
| 220 | v.striped = false; |
| 221 | v.indent_has_left_vline = false; |
| 222 | v.collapsing_header_frame = false; |
| 223 | v.slider_trailing_fill = true; |
| 224 | v.resize_corner_size = 8.0; |
| 225 | |
| 226 | let w = &mut v.widgets; |
| 227 | w.noninteractive.bg_fill = p.surface; |
| 228 | w.noninteractive.weak_bg_fill = p.surface; |
| 229 | w.noninteractive.bg_stroke = Stroke::new(1.0, p.border); |
| 230 | w.noninteractive.fg_stroke = Stroke::new(1.0, p.text_primary); |
| 231 | w.noninteractive.corner_radius = technical; |
| 232 | |
| 233 | w.inactive.bg_fill = p.surface_raised; |
| 234 | w.inactive.weak_bg_fill = Color32::TRANSPARENT; |
| 235 | w.inactive.bg_stroke = Stroke::new(1.0, p.border_visible); |
| 236 | w.inactive.fg_stroke = Stroke::new(1.0, p.text_primary); |
| 237 | w.inactive.corner_radius = technical; |
| 238 | w.inactive.expansion = 0.0; |
| 239 | |
| 240 | // Hover: the border and text brighten. No scale, no glow. |
| 241 | w.hovered.bg_fill = p.surface_raised; |
| 242 | w.hovered.weak_bg_fill = p.surface_raised; |
| 243 | w.hovered.bg_stroke = Stroke::new(1.0, p.text_secondary); |
| 244 | w.hovered.fg_stroke = Stroke::new(1.5, p.text_display); |
| 245 | w.hovered.corner_radius = technical; |
| 246 | w.hovered.expansion = 0.0; |
| 247 | |
| 248 | w.active.bg_fill = p.surface_raised; |
| 249 | w.active.weak_bg_fill = p.surface_raised; |
| 250 | w.active.bg_stroke = Stroke::new(1.0, p.text_primary); |
| 251 | w.active.fg_stroke = Stroke::new(2.0, p.text_display); |
| 252 | w.active.corner_radius = technical; |
| 253 | w.active.expansion = 0.0; |
| 254 | |
| 255 | w.open = w.hovered; |
| 256 | v |
| 257 | } |
| 258 | |
| 259 | fn style(theme: Theme) -> Style { |
| 260 | let (p, dark) = match theme { |
| 261 | Theme::Dark => (&DARK, true), |
| 262 | Theme::Light => (&LIGHT, false), |
| 263 | }; |
| 264 | let mut s = Style { |
| 265 | visuals: visuals(p, dark), |
| 266 | ..Style::default() |
| 267 | }; |
| 268 | s.text_styles = [ |
| 269 | (TextStyle::Small, FontId::monospace(LABEL)), |
| 270 | (TextStyle::Body, FontId::proportional(BODY)), |
| 271 | (TextStyle::Button, FontId::monospace(12.0)), |
| 272 | (TextStyle::Heading, FontId::new(18.0, grotesk_medium())), |
| 273 | (TextStyle::Monospace, FontId::monospace(13.0)), |
| 274 | ] |
| 275 | .into(); |
| 276 | s.spacing.item_spacing = egui::vec2(8.0, 4.0); |
| 277 | s.spacing.button_padding = egui::vec2(10.0, 4.0); |
| 278 | s.spacing.interact_size.y = 22.0; |
| 279 | s.spacing.menu_margin = egui::Margin::same(8); |
| 280 | s.spacing.window_margin = egui::Margin::same(16); |
| 281 | s.spacing.menu_spacing = 2.0; |
| 282 | s.animation_time = 0.15; |
| 283 | s.interaction.selectable_labels = false; |
| 284 | s.interaction.tooltip_delay = 0.4; |
| 285 | s |
| 286 | } |
| 287 | |
| 288 | /// Installs the fonts and both themes on `ctx`. |
| 289 | pub fn install(ctx: &Context) { |
| 290 | ctx.set_fonts(fonts()); |
| 291 | ctx.set_style_of(Theme::Dark, style(Theme::Dark)); |
| 292 | ctx.set_style_of(Theme::Light, style(Theme::Light)); |
| 293 | } |