diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 19:58:59 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 19:58:59 +0100 |
| commit | 10ff558853f549d3f3c7c8432488bfad9da6973a (patch) | |
| tree | 271b05fead384e2d91e5c8332fea9d00b540f0cc /gui/src/theme.rs | |
| parent | v4.0.7: finish the Phase 1 boundary with a command shell (diff) | |
| download | zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.tar.gz zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.zip | |
v4.1.0: add the egui workspace member and the roulette style spike
Phase 3 of the GUI plan. gui/ is a separate package that depends on the core with default features off, so egui never enters the TUI or server build graph. It carries the theme tokens and two embedded fonts (Open Sans OFL, Hack MIT), paints the felt, chips, cursor and wheel from RouletteView, and composes the persistent header with the betting sidebar.
The reference screen runs on demo state: arrow keys move the shared Cursor, clicks place chips, and SPIN animates a 2.4 s wheel that settles with the winning pocket under the pointer. No network until Phase 4.
Verified x86_64-pc-windows-gnu cross-compile. Measured on Linux at 1080p with llvmpipe: 6.0 MB binary (4.7 MB on Windows), 52 ms to window, 137 MB idle RSS, 0% idle CPU.
Diffstat (limited to 'gui/src/theme.rs')
| -rw-r--r-- | gui/src/theme.rs | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/gui/src/theme.rs b/gui/src/theme.rs new file mode 100644 index 0000000..9270aa6 --- /dev/null +++ b/gui/src/theme.rs | |||
| @@ -0,0 +1,211 @@ | |||
| 1 | //! Look and feel for the desktop frontend. | ||
| 2 | //! | ||
| 3 | //! The palette is derived from the terminal client's ANSI scheme so the two | ||
| 4 | //! frontends read as the same product: near-black ground, low-contrast panels, | ||
| 5 | //! cyan for focus, and the felt green reserved for the roulette table. | ||
| 6 | //! | ||
| 7 | //! egui's defaults are a debug tool's palette. Every colour, size and corner | ||
| 8 | //! used by the app comes from this module; widgets never pick their own. | ||
| 9 | |||
| 10 | use std::sync::Arc; | ||
| 11 | |||
| 12 | use eframe::egui::style::WidgetVisuals; | ||
| 13 | use eframe::egui::{ | ||
| 14 | self, Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, | ||
| 15 | RichText, Stroke, TextStyle, Theme, Visuals, | ||
| 16 | }; | ||
| 17 | |||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | // Colour tokens | ||
| 20 | // --------------------------------------------------------------------------- | ||
| 21 | |||
| 22 | /// Window ground. Everything else sits above this. | ||
| 23 | pub const BG: Color32 = Color32::from_rgb(0x0a, 0x0e, 0x12); | ||
| 24 | /// Cards, header, sidebar. | ||
| 25 | pub const SURFACE: Color32 = Color32::from_rgb(0x11, 0x17, 0x1d); | ||
| 26 | /// Inputs, pills, raised rows. | ||
| 27 | pub const SURFACE_RAISED: Color32 = Color32::from_rgb(0x17, 0x1f, 0x27); | ||
| 28 | /// Hover state. | ||
| 29 | pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x1d, 0x27, 0x31); | ||
| 30 | /// Hairlines and table borders. | ||
| 31 | pub const LINE: Color32 = Color32::from_rgb(0x24, 0x2e, 0x38); | ||
| 32 | /// Primary text. | ||
| 33 | pub const TEXT: Color32 = Color32::from_rgb(0xd9, 0xe2, 0xea); | ||
| 34 | /// Labels and secondary text. | ||
| 35 | pub const TEXT_DIM: Color32 = Color32::from_rgb(0x8a, 0x98, 0xa6); | ||
| 36 | /// Disabled text, captions. | ||
| 37 | pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x5b, 0x67, 0x73); | ||
| 38 | /// Focus, cursor, selection. | ||
| 39 | pub const ACCENT: Color32 = Color32::from_rgb(0x4f, 0xb8, 0xc9); | ||
| 40 | /// Wins, "online", the spin button. | ||
| 41 | pub const WIN: Color32 = Color32::from_rgb(0x4e, 0xc2, 0x7b); | ||
| 42 | /// Losses and errors. | ||
| 43 | pub const LOSS: Color32 = Color32::from_rgb(0xe0, 0x5a, 0x5a); | ||
| 44 | /// Chips and pending bets. | ||
| 45 | pub const GOLD: Color32 = Color32::from_rgb(0xe0, 0xb6, 0x4a); | ||
| 46 | |||
| 47 | /// Roulette felt. Never used outside the table. | ||
| 48 | pub const FELT: Color32 = Color32::from_rgb(0x0d, 0x5c, 0x39); | ||
| 49 | pub const FELT_DARK: Color32 = Color32::from_rgb(0x09, 0x47, 0x2c); | ||
| 50 | pub const FELT_LINE: Color32 = Color32::from_rgb(0x1f, 0x7d, 0x52); | ||
| 51 | pub const FELT_TEXT: Color32 = Color32::from_rgb(0xec, 0xf7, 0xf0); | ||
| 52 | pub const ROULETTE_RED: Color32 = Color32::from_rgb(0xb0, 0x31, 0x3a); | ||
| 53 | pub const ROULETTE_BLACK: Color32 = Color32::from_rgb(0x15, 0x1b, 0x21); | ||
| 54 | pub const ROULETTE_ZERO: Color32 = Color32::from_rgb(0x10, 0x6b, 0x45); | ||
| 55 | |||
| 56 | // --------------------------------------------------------------------------- | ||
| 57 | // Spacing, radius, type scale | ||
| 58 | // --------------------------------------------------------------------------- | ||
| 59 | |||
| 60 | pub const SPACE_1: f32 = 4.0; | ||
| 61 | pub const SPACE_2: f32 = 8.0; | ||
| 62 | pub const SPACE_3: f32 = 12.0; | ||
| 63 | pub const SPACE_4: f32 = 16.0; | ||
| 64 | pub const SPACE_5: f32 = 24.0; | ||
| 65 | |||
| 66 | pub const RADIUS_SM: CornerRadius = CornerRadius::same(4); | ||
| 67 | pub const RADIUS_MD: CornerRadius = CornerRadius::same(8); | ||
| 68 | pub const RADIUS_LG: CornerRadius = CornerRadius::same(12); | ||
| 69 | |||
| 70 | pub const FONT_BODY: f32 = 14.0; | ||
| 71 | pub const FONT_SMALL: f32 = 12.0; | ||
| 72 | pub const FONT_LABEL: f32 = 11.0; | ||
| 73 | pub const FONT_TITLE: f32 = 24.0; | ||
| 74 | pub const FONT_MONO: f32 = 16.0; | ||
| 75 | |||
| 76 | // --------------------------------------------------------------------------- | ||
| 77 | // Fonts | ||
| 78 | // --------------------------------------------------------------------------- | ||
| 79 | |||
| 80 | /// Open Sans, SIL OFL 1.1. See `assets/fonts/OFL-OpenSans.txt`. | ||
| 81 | pub const FONT_SANS_BYTES: &[u8] = include_bytes!("../assets/fonts/OpenSans-Regular.ttf"); | ||
| 82 | /// Hack, MIT. See `assets/fonts/LICENSE-Hack.md`. | ||
| 83 | pub const FONT_MONO_BYTES: &[u8] = include_bytes!("../assets/fonts/Hack-Regular.ttf"); | ||
| 84 | |||
| 85 | pub fn sans(size: f32) -> FontId { | ||
| 86 | FontId::new(size, FontFamily::Proportional) | ||
| 87 | } | ||
| 88 | |||
| 89 | pub fn mono(size: f32) -> FontId { | ||
| 90 | FontId::new(size, FontFamily::Monospace) | ||
| 91 | } | ||
| 92 | |||
| 93 | /// A small uppercase label, the terminal's dim section headers. | ||
| 94 | pub fn label(text: &str) -> RichText { | ||
| 95 | RichText::new(text.to_uppercase()) | ||
| 96 | .font(mono(FONT_LABEL)) | ||
| 97 | .color(TEXT_DIM) | ||
| 98 | } | ||
| 99 | |||
| 100 | /// Group an amount in threes: 1234567 -> "1 234 567". | ||
| 101 | pub fn group_digits(amount: i64) -> String { | ||
| 102 | let negative = amount < 0; | ||
| 103 | let digits = amount.unsigned_abs().to_string(); | ||
| 104 | let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); | ||
| 105 | for (index, character) in digits.chars().enumerate() { | ||
| 106 | if index > 0 && (digits.len() - index) % 3 == 0 { | ||
| 107 | grouped.push(' '); | ||
| 108 | } | ||
| 109 | grouped.push(character); | ||
| 110 | } | ||
| 111 | if negative { | ||
| 112 | format!("-{grouped}") | ||
| 113 | } else { | ||
| 114 | grouped | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | /// `1000` -> `"1 000 Kr"`. | ||
| 119 | pub fn money(amount: i64) -> String { | ||
| 120 | format!("{} Kr", group_digits(amount)) | ||
| 121 | } | ||
| 122 | |||
| 123 | // --------------------------------------------------------------------------- | ||
| 124 | // Installation | ||
| 125 | // --------------------------------------------------------------------------- | ||
| 126 | |||
| 127 | /// Install the embedded fonts and the dark style. Call once, from the app | ||
| 128 | /// constructor, before the first frame. | ||
| 129 | pub fn install(ctx: &Context) { | ||
| 130 | let mut fonts = FontDefinitions::default(); | ||
| 131 | fonts.font_data.insert( | ||
| 132 | "zagan-sans".to_owned(), | ||
| 133 | Arc::new(FontData::from_static(FONT_SANS_BYTES)), | ||
| 134 | ); | ||
| 135 | fonts.font_data.insert( | ||
| 136 | "zagan-mono".to_owned(), | ||
| 137 | Arc::new(FontData::from_static(FONT_MONO_BYTES)), | ||
| 138 | ); | ||
| 139 | fonts | ||
| 140 | .families | ||
| 141 | .get_mut(&FontFamily::Proportional) | ||
| 142 | .expect("proportional family exists") | ||
| 143 | .insert(0, "zagan-sans".to_owned()); | ||
| 144 | fonts | ||
| 145 | .families | ||
| 146 | .get_mut(&FontFamily::Monospace) | ||
| 147 | .expect("monospace family exists") | ||
| 148 | .insert(0, "zagan-mono".to_owned()); | ||
| 149 | ctx.set_fonts(fonts); | ||
| 150 | |||
| 151 | ctx.set_theme(Theme::Dark); | ||
| 152 | let mut style = (*ctx.global_style()).clone(); | ||
| 153 | style.text_styles = [ | ||
| 154 | (TextStyle::Heading, sans(FONT_TITLE)), | ||
| 155 | (TextStyle::Body, sans(FONT_BODY)), | ||
| 156 | (TextStyle::Button, sans(FONT_BODY)), | ||
| 157 | (TextStyle::Small, sans(FONT_SMALL)), | ||
| 158 | (TextStyle::Monospace, mono(FONT_MONO)), | ||
| 159 | ] | ||
| 160 | .into(); | ||
| 161 | style.spacing.item_spacing = egui::vec2(SPACE_2, SPACE_2); | ||
| 162 | style.spacing.button_padding = egui::vec2(SPACE_3, SPACE_2); | ||
| 163 | style.spacing.interact_size = egui::vec2(36.0, 32.0); | ||
| 164 | style.spacing.window_margin = Margin::same(SPACE_3 as i8); | ||
| 165 | style.visuals = visuals(); | ||
| 166 | ctx.set_global_style(style); | ||
| 167 | } | ||
| 168 | |||
| 169 | fn widget( | ||
| 170 | bg_fill: Color32, | ||
| 171 | bg_stroke: Color32, | ||
| 172 | fg: Color32, | ||
| 173 | corner_radius: CornerRadius, | ||
| 174 | ) -> WidgetVisuals { | ||
| 175 | WidgetVisuals { | ||
| 176 | bg_fill, | ||
| 177 | weak_bg_fill: bg_fill, | ||
| 178 | bg_stroke: Stroke::new(1.0, bg_stroke), | ||
| 179 | corner_radius, | ||
| 180 | fg_stroke: Stroke::new(1.0, fg), | ||
| 181 | expansion: 0.0, | ||
| 182 | } | ||
| 183 | } | ||
| 184 | |||
| 185 | fn visuals() -> Visuals { | ||
| 186 | let mut visuals = Visuals::dark(); | ||
| 187 | visuals.override_text_color = Some(TEXT); | ||
| 188 | visuals.panel_fill = BG; | ||
| 189 | visuals.window_fill = SURFACE; | ||
| 190 | visuals.window_stroke = Stroke::new(1.0, LINE); | ||
| 191 | visuals.window_corner_radius = RADIUS_LG; | ||
| 192 | visuals.extreme_bg_color = BG; | ||
| 193 | visuals.faint_bg_color = SURFACE_RAISED; | ||
| 194 | visuals.code_bg_color = SURFACE_RAISED; | ||
| 195 | visuals.hyperlink_color = ACCENT; | ||
| 196 | visuals.warn_fg_color = GOLD; | ||
| 197 | visuals.error_fg_color = LOSS; | ||
| 198 | visuals.selection.bg_fill = ACCENT.gamma_multiply(0.30); | ||
| 199 | visuals.selection.stroke = Stroke::new(1.0, ACCENT); | ||
| 200 | visuals.widgets.noninteractive = widget(SURFACE, LINE, TEXT, RADIUS_MD); | ||
| 201 | visuals.widgets.inactive = widget(SURFACE_RAISED, LINE, TEXT, RADIUS_MD); | ||
| 202 | visuals.widgets.hovered = widget(SURFACE_HOVER, ACCENT.gamma_multiply(0.75), TEXT, RADIUS_MD); | ||
| 203 | visuals.widgets.active = widget( | ||
| 204 | ACCENT.gamma_multiply(0.25), | ||
| 205 | ACCENT, | ||
| 206 | Color32::WHITE, | ||
| 207 | RADIUS_MD, | ||
| 208 | ); | ||
| 209 | visuals.widgets.open = visuals.widgets.active; | ||
| 210 | visuals | ||
| 211 | } | ||
