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 | |
| 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 '')
| -rw-r--r-- | gui/Cargo.toml | 21 | ||||
| -rw-r--r-- | gui/assets/fonts/Hack-Regular.ttf | bin | 0 -> 309408 bytes | |||
| -rw-r--r-- | gui/assets/fonts/LICENSE-Hack.md | 45 | ||||
| -rw-r--r-- | gui/assets/fonts/OFL-OpenSans.txt | 88 | ||||
| -rw-r--r-- | gui/assets/fonts/OpenSans-Regular.ttf | bin | 0 -> 147528 bytes | |||
| -rw-r--r-- | gui/src/app.rs | 587 | ||||
| -rw-r--r-- | gui/src/lib.rs | 9 | ||||
| -rw-r--r-- | gui/src/main.rs | 25 | ||||
| -rw-r--r-- | gui/src/roulette.rs | 522 | ||||
| -rw-r--r-- | gui/src/theme.rs | 211 | ||||
| -rw-r--r-- | gui/tests/layout.rs | 82 |
11 files changed, 1590 insertions, 0 deletions
diff --git a/gui/Cargo.toml b/gui/Cargo.toml new file mode 100644 index 0000000..a2f4154 --- /dev/null +++ b/gui/Cargo.toml | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | [package] | ||
| 2 | name = "zagan-bet-gui" | ||
| 3 | version = "4.1.0" | ||
| 4 | edition = "2024" | ||
| 5 | license = "GPL-3.0-or-later" | ||
| 6 | description = "Desktop frontend for zagan-bet" | ||
| 7 | |||
| 8 | [[bin]] | ||
| 9 | name = "zagan-bet-gui" | ||
| 10 | path = "src/main.rs" | ||
| 11 | |||
| 12 | [dependencies] | ||
| 13 | # No default features: the GUI never links the server's native-tls. | ||
| 14 | zagan-bet = { path = "..", default-features = false } | ||
| 15 | # glow (OpenGL) rather than wgpu: no Vulkan runtime, and it cross-compiles to | ||
| 16 | # Windows with the mingw toolchain. Fonts are embedded, so no default_fonts. | ||
| 17 | eframe = { version = "0.37", default-features = false, features = [ | ||
| 18 | "glow", | ||
| 19 | "wayland", | ||
| 20 | "x11", | ||
| 21 | ] } | ||
diff --git a/gui/assets/fonts/Hack-Regular.ttf b/gui/assets/fonts/Hack-Regular.ttf new file mode 100644 index 0000000..92a90cb --- /dev/null +++ b/gui/assets/fonts/Hack-Regular.ttf | |||
| Binary files differ | |||
diff --git a/gui/assets/fonts/LICENSE-Hack.md b/gui/assets/fonts/LICENSE-Hack.md new file mode 100644 index 0000000..08927e5 --- /dev/null +++ b/gui/assets/fonts/LICENSE-Hack.md | |||
| @@ -0,0 +1,45 @@ | |||
| 1 | The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License | ||
| 2 | |||
| 3 | The work in the DejaVu project was committed to the public domain. | ||
| 4 | |||
| 5 | Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera" | ||
| 6 | |||
| 7 | ### MIT License | ||
| 8 | |||
| 9 | Copyright (c) 2018 Source Foundry Authors | ||
| 10 | |||
| 11 | Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 12 | of this software and associated documentation files (the "Software"), to deal | ||
| 13 | in the Software without restriction, including without limitation the rights | ||
| 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 15 | copies of the Software, and to permit persons to whom the Software is | ||
| 16 | furnished to do so, subject to the following conditions: | ||
| 17 | |||
| 18 | The above copyright notice and this permission notice shall be included in all | ||
| 19 | copies or substantial portions of the Software. | ||
| 20 | |||
| 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 27 | SOFTWARE. | ||
| 28 | |||
| 29 | ### BITSTREAM VERA LICENSE | ||
| 30 | |||
| 31 | Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc. | ||
| 32 | |||
| 33 | Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions: | ||
| 34 | |||
| 35 | The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces. | ||
| 36 | |||
| 37 | The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera". | ||
| 38 | |||
| 39 | This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names. | ||
| 40 | |||
| 41 | The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself. | ||
| 42 | |||
| 43 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. | ||
| 44 | |||
| 45 | Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org. | ||
diff --git a/gui/assets/fonts/OFL-OpenSans.txt b/gui/assets/fonts/OFL-OpenSans.txt new file mode 100644 index 0000000..2e76eef --- /dev/null +++ b/gui/assets/fonts/OFL-OpenSans.txt | |||
| @@ -0,0 +1,88 @@ | |||
| 1 | Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) | ||
| 2 | |||
| 3 | ----------------------------------------------------------- | ||
| 4 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | ||
| 5 | ----------------------------------------------------------- | ||
| 6 | |||
| 7 | PREAMBLE | ||
| 8 | The goals of the Open Font License (OFL) are to stimulate worldwide | ||
| 9 | development of collaborative font projects, to support the font | ||
| 10 | creation efforts of academic and linguistic communities, and to | ||
| 11 | provide a free and open framework in which fonts may be shared and | ||
| 12 | improved in partnership with others. | ||
| 13 | |||
| 14 | The OFL allows the licensed fonts to be used, studied, modified and | ||
| 15 | redistributed freely as long as they are not sold by themselves. The | ||
| 16 | fonts, including any derivative works, can be bundled, embedded, | ||
| 17 | redistributed and/or sold with any software provided that any reserved | ||
| 18 | names are not used by derivative works. The fonts and derivatives, | ||
| 19 | however, cannot be released under any other type of license. The | ||
| 20 | requirement for fonts to remain under this license does not apply to | ||
| 21 | any document created using the fonts or their derivatives. | ||
| 22 | |||
| 23 | DEFINITIONS | ||
| 24 | "Font Software" refers to the set of files released by the Copyright | ||
| 25 | Holder(s) under this license and clearly marked as such. This may | ||
| 26 | include source files, build scripts and documentation. | ||
| 27 | |||
| 28 | "Reserved Font Name" refers to any names specified as such after the | ||
| 29 | copyright statement(s). | ||
| 30 | |||
| 31 | "Original Version" refers to the collection of Font Software | ||
| 32 | components as distributed by the Copyright Holder(s). | ||
| 33 | |||
| 34 | "Modified Version" refers to any derivative made by adding to, | ||
| 35 | deleting, or substituting -- in part or in whole -- any of the | ||
| 36 | components of the Original Version, by changing formats or by porting | ||
| 37 | the Font Software to a new environment. | ||
| 38 | |||
| 39 | "Author" refers to any designer, engineer, programmer, technical | ||
| 40 | writer or other person who contributed to the Font Software. | ||
| 41 | |||
| 42 | PERMISSION & CONDITIONS | ||
| 43 | Permission is hereby granted, free of charge, to any person obtaining | ||
| 44 | a copy of the Font Software, to use, study, copy, merge, embed, | ||
| 45 | modify, redistribute, and sell modified and unmodified copies of the | ||
| 46 | Font Software, subject to the following conditions: | ||
| 47 | |||
| 48 | 1) Neither the Font Software nor any of its individual components, in | ||
| 49 | Original or Modified Versions, may be sold by itself. | ||
| 50 | |||
| 51 | 2) Original or Modified Versions of the Font Software may be bundled, | ||
| 52 | redistributed and/or sold with any software, provided that each copy | ||
| 53 | contains the above copyright notice and this license. These can be | ||
| 54 | included either as stand-alone text files, human-readable headers or | ||
| 55 | in the appropriate machine-readable metadata fields within text or | ||
| 56 | binary files as long as those fields can be easily viewed by the user. | ||
| 57 | |||
| 58 | 3) No Modified Version of the Font Software may use the Reserved Font | ||
| 59 | Name(s) unless explicit written permission is granted by the | ||
| 60 | corresponding Copyright Holder. This restriction only applies to the | ||
| 61 | primary font name as presented to the users. | ||
| 62 | |||
| 63 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | ||
| 64 | Software shall not be used to promote, endorse or advertise any | ||
| 65 | Modified Version, except to acknowledge the contribution(s) of the | ||
| 66 | Copyright Holder(s) and the Author(s) or with their explicit written | ||
| 67 | permission. | ||
| 68 | |||
| 69 | 5) The Font Software, modified or unmodified, in part or in whole, | ||
| 70 | must be distributed entirely under this license, and must not be | ||
| 71 | distributed under any other license. The requirement for fonts to | ||
| 72 | remain under this license does not apply to any document created using | ||
| 73 | the Font Software. | ||
| 74 | |||
| 75 | TERMINATION | ||
| 76 | This license becomes null and void if any of the above conditions are | ||
| 77 | not met. | ||
| 78 | |||
| 79 | DISCLAIMER | ||
| 80 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
| 81 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | ||
| 82 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | ||
| 83 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | ||
| 84 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | ||
| 85 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | ||
| 86 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | ||
| 87 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | ||
| 88 | OTHER DEALINGS IN THE FONT SOFTWARE. | ||
diff --git a/gui/assets/fonts/OpenSans-Regular.ttf b/gui/assets/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..8529c43 --- /dev/null +++ b/gui/assets/fonts/OpenSans-Regular.ttf | |||
| Binary files differ | |||
diff --git a/gui/src/app.rs b/gui/src/app.rs new file mode 100644 index 0000000..fde117a --- /dev/null +++ b/gui/src/app.rs | |||
| @@ -0,0 +1,587 @@ | |||
| 1 | //! The desktop shell: header, roulette table, and betting sidebar. | ||
| 2 | //! | ||
| 3 | //! Phase 3 is a style spike. The table is painted from a demo `RouletteView` | ||
| 4 | //! built with the core's own types, but no command is sent yet: Phase 4 wires | ||
| 5 | //! the transport and replaces the demo state with `ClientCore`. | ||
| 6 | |||
| 7 | use std::f32::consts::TAU; | ||
| 8 | |||
| 9 | use eframe::egui::{ | ||
| 10 | self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, | ||
| 11 | RichText, Sense, Stroke, Ui, Vec2, vec2, | ||
| 12 | }; | ||
| 13 | use zagan_bet::client::games::roulette::{Cursor, Direction}; | ||
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | ||
| 15 | use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color}; | ||
| 16 | use zagan_bet::version::VERSION; | ||
| 17 | |||
| 18 | use crate::{roulette, theme}; | ||
| 19 | |||
| 20 | const HEADER_HEIGHT: f32 = 64.0; | ||
| 21 | const SIDEBAR_WIDTH: f32 = 336.0; | ||
| 22 | const SPIN_SECONDS: f64 = 2.4; | ||
| 23 | /// Demo winners, cycled: a red, a zero, a red, a black, a black, a black. | ||
| 24 | const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11]; | ||
| 25 | const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100]; | ||
| 26 | |||
| 27 | pub struct ZaganApp { | ||
| 28 | demo: Demo, | ||
| 29 | } | ||
| 30 | |||
| 31 | impl ZaganApp { | ||
| 32 | pub fn new(cc: &eframe::CreationContext<'_>) -> Self { | ||
| 33 | theme::install(&cc.egui_ctx); | ||
| 34 | Self { demo: Demo::new() } | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | impl eframe::App for ZaganApp { | ||
| 39 | fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { | ||
| 40 | self.demo.tick(ui.ctx()); | ||
| 41 | self.demo.keys(ui.ctx()); | ||
| 42 | |||
| 43 | Panel::top("header") | ||
| 44 | .exact_size(HEADER_HEIGHT) | ||
| 45 | .frame( | ||
| 46 | Frame::new() | ||
| 47 | .fill(theme::SURFACE) | ||
| 48 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), | ||
| 49 | ) | ||
| 50 | .show(ui, |ui| header(ui, &self.demo)); | ||
| 51 | |||
| 52 | Panel::right("sidebar") | ||
| 53 | .exact_size(SIDEBAR_WIDTH) | ||
| 54 | .frame( | ||
| 55 | Frame::new() | ||
| 56 | .fill(theme::SURFACE) | ||
| 57 | .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)), | ||
| 58 | ) | ||
| 59 | .show(ui, |ui| { | ||
| 60 | egui::ScrollArea::vertical() | ||
| 61 | .auto_shrink([false, false]) | ||
| 62 | .show(ui, |ui| sidebar(ui, &mut self.demo)); | ||
| 63 | }); | ||
| 64 | |||
| 65 | CentralPanel::default() | ||
| 66 | .frame( | ||
| 67 | Frame::new() | ||
| 68 | .fill(theme::BG) | ||
| 69 | .inner_margin(Margin::same(theme::SPACE_5 as i8)), | ||
| 70 | ) | ||
| 71 | .show(ui, |ui| table(ui, &mut self.demo)); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | // --------------------------------------------------------------------------- | ||
| 76 | // Header | ||
| 77 | // --------------------------------------------------------------------------- | ||
| 78 | |||
| 79 | fn header(ui: &mut Ui, demo: &Demo) { | ||
| 80 | ui.with_layout(Layout::left_to_right(Align::Center), |ui| { | ||
| 81 | let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); | ||
| 82 | ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); | ||
| 83 | ui.add_space(theme::SPACE_2); | ||
| 84 | ui.label( | ||
| 85 | RichText::new("ZAGAN") | ||
| 86 | .font(theme::mono(18.0)) | ||
| 87 | .color(theme::TEXT), | ||
| 88 | ); | ||
| 89 | ui.label( | ||
| 90 | RichText::new("BET") | ||
| 91 | .font(theme::mono(18.0)) | ||
| 92 | .color(theme::WIN), | ||
| 93 | ); | ||
| 94 | ui.add_space(theme::SPACE_3); | ||
| 95 | pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); | ||
| 96 | ui.add_space(theme::SPACE_1); | ||
| 97 | pill(ui, "PREVIEW", theme::GOLD); | ||
| 98 | ui.add_space(theme::SPACE_5); | ||
| 99 | connection_pill(ui, demo); | ||
| 100 | |||
| 101 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 102 | stat_block( | ||
| 103 | ui, | ||
| 104 | 150.0, | ||
| 105 | "Balance", | ||
| 106 | &theme::money(demo.balance), | ||
| 107 | theme::mono(20.0), | ||
| 108 | theme::TEXT, | ||
| 109 | None, | ||
| 110 | ); | ||
| 111 | ui.add_space(theme::SPACE_5); | ||
| 112 | stat_block( | ||
| 113 | ui, | ||
| 114 | 190.0, | ||
| 115 | "Account", | ||
| 116 | demo.user, | ||
| 117 | theme::sans(theme::FONT_BODY), | ||
| 118 | theme::TEXT, | ||
| 119 | Some(demo.account), | ||
| 120 | ); | ||
| 121 | }); | ||
| 122 | }); | ||
| 123 | } | ||
| 124 | |||
| 125 | fn pill(ui: &mut Ui, text: &str, color: Color32) { | ||
| 126 | Frame::new() | ||
| 127 | .fill(theme::SURFACE_RAISED) | ||
| 128 | .corner_radius(theme::RADIUS_SM) | ||
| 129 | .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3)) | ||
| 130 | .show(ui, |ui| { | ||
| 131 | ui.label(RichText::new(text).font(theme::mono(10.0)).color(color)); | ||
| 132 | }); | ||
| 133 | } | ||
| 134 | |||
| 135 | fn connection_pill(ui: &mut Ui, demo: &Demo) { | ||
| 136 | Frame::new() | ||
| 137 | .fill(theme::SURFACE_RAISED) | ||
| 138 | .corner_radius(theme::RADIUS_SM) | ||
| 139 | .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5)) | ||
| 140 | .show(ui, |ui| { | ||
| 141 | ui.horizontal(|ui| { | ||
| 142 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover()); | ||
| 143 | ui.painter().circle_filled(dot.center(), 3.0, theme::WIN); | ||
| 144 | ui.label( | ||
| 145 | RichText::new("ONLINE") | ||
| 146 | .font(theme::mono(10.0)) | ||
| 147 | .color(theme::WIN), | ||
| 148 | ); | ||
| 149 | ui.label( | ||
| 150 | RichText::new(demo.host) | ||
| 151 | .font(theme::mono(12.0)) | ||
| 152 | .color(theme::TEXT_DIM), | ||
| 153 | ); | ||
| 154 | ui.label( | ||
| 155 | RichText::new(format!("{} ms", demo.ping_ms)) | ||
| 156 | .font(theme::mono(12.0)) | ||
| 157 | .color(theme::TEXT_FAINT), | ||
| 158 | ); | ||
| 159 | }); | ||
| 160 | }); | ||
| 161 | } | ||
| 162 | |||
| 163 | /// A label over a value, right-aligned in a fixed slot so the header's right | ||
| 164 | /// edge stays put as the numbers change. | ||
| 165 | fn stat_block( | ||
| 166 | ui: &mut Ui, | ||
| 167 | width: f32, | ||
| 168 | label: &str, | ||
| 169 | value: &str, | ||
| 170 | value_font: egui::FontId, | ||
| 171 | value_color: Color32, | ||
| 172 | detail: Option<&str>, | ||
| 173 | ) { | ||
| 174 | ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| { | ||
| 175 | ui.spacing_mut().item_spacing.y = 0.0; | ||
| 176 | ui.label(theme::label(label)); | ||
| 177 | ui.label(RichText::new(value).font(value_font).color(value_color)); | ||
| 178 | if let Some(detail) = detail { | ||
| 179 | ui.label( | ||
| 180 | RichText::new(detail) | ||
| 181 | .font(theme::mono(10.0)) | ||
| 182 | .color(theme::TEXT_FAINT), | ||
| 183 | ); | ||
| 184 | } | ||
| 185 | }); | ||
| 186 | } | ||
| 187 | |||
| 188 | // --------------------------------------------------------------------------- | ||
| 189 | // Sidebar | ||
| 190 | // --------------------------------------------------------------------------- | ||
| 191 | |||
| 192 | fn sidebar(ui: &mut Ui, demo: &mut Demo) { | ||
| 193 | ui.label( | ||
| 194 | RichText::new("Roulette") | ||
| 195 | .font(theme::sans(22.0)) | ||
| 196 | .color(theme::TEXT), | ||
| 197 | ); | ||
| 198 | ui.label( | ||
| 199 | RichText::new("European · single zero") | ||
| 200 | .font(theme::sans(12.0)) | ||
| 201 | .color(theme::TEXT_DIM), | ||
| 202 | ); | ||
| 203 | |||
| 204 | ui.add_space(theme::SPACE_4); | ||
| 205 | let view = demo.view(); | ||
| 206 | let wheel_size = ui.available_width().min(210.0); | ||
| 207 | ui.horizontal(|ui| { | ||
| 208 | let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0); | ||
| 209 | ui.add_space(pad); | ||
| 210 | roulette::wheel(ui, wheel_size, &view, demo.rotation); | ||
| 211 | }); | ||
| 212 | ui.add_space(theme::SPACE_1); | ||
| 213 | result_line(ui, demo); | ||
| 214 | |||
| 215 | ui.add_space(theme::SPACE_3); | ||
| 216 | ui.separator(); | ||
| 217 | ui.add_space(theme::SPACE_3); | ||
| 218 | |||
| 219 | ui.label(theme::label("Chip")); | ||
| 220 | ui.add_space(theme::SPACE_2); | ||
| 221 | ui.horizontal(|ui| { | ||
| 222 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 223 | for amount in CHIP_SIZES { | ||
| 224 | if chip_button(ui, amount, demo.chip == amount).clicked() { | ||
| 225 | demo.chip = amount; | ||
| 226 | } | ||
| 227 | } | ||
| 228 | }); | ||
| 229 | |||
| 230 | ui.add_space(theme::SPACE_3); | ||
| 231 | ui.label(theme::label("Bets")); | ||
| 232 | ui.add_space(theme::SPACE_1); | ||
| 233 | if demo.bets.chips.is_empty() { | ||
| 234 | ui.label( | ||
| 235 | RichText::new("No chips on the felt") | ||
| 236 | .font(theme::sans(12.0)) | ||
| 237 | .color(theme::TEXT_FAINT), | ||
| 238 | ); | ||
| 239 | } else { | ||
| 240 | for chip in &demo.bets.chips { | ||
| 241 | bet_row(ui, &roulette::target_name(&chip.target), chip.amount); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | |||
| 245 | ui.add_space(theme::SPACE_3); | ||
| 246 | ui.separator(); | ||
| 247 | ui.add_space(theme::SPACE_2); | ||
| 248 | bet_row(ui, "Total", demo.bets.total()); | ||
| 249 | |||
| 250 | ui.add_space(theme::SPACE_3); | ||
| 251 | let spinning = demo.phase == RoulettePhase::Spinning; | ||
| 252 | let spin = Button::new( | ||
| 253 | RichText::new("SPIN") | ||
| 254 | .font(theme::mono(theme::FONT_MONO)) | ||
| 255 | .color(theme::BG), | ||
| 256 | ) | ||
| 257 | .fill(if spinning { | ||
| 258 | theme::WIN.gamma_multiply(0.45) | ||
| 259 | } else { | ||
| 260 | theme::WIN | ||
| 261 | }) | ||
| 262 | .corner_radius(theme::RADIUS_MD) | ||
| 263 | .min_size(vec2(ui.available_width(), 44.0)); | ||
| 264 | if ui.add_enabled(!spinning, spin).clicked() { | ||
| 265 | demo.start_spin(ui.ctx()); | ||
| 266 | } | ||
| 267 | |||
| 268 | ui.add_space(theme::SPACE_2); | ||
| 269 | let clear = Button::new( | ||
| 270 | RichText::new("Clear bets") | ||
| 271 | .font(theme::sans(12.0)) | ||
| 272 | .color(theme::TEXT_DIM), | ||
| 273 | ) | ||
| 274 | .frame(false); | ||
| 275 | if ui | ||
| 276 | .add_enabled(!demo.bets.is_empty() && !spinning, clear) | ||
| 277 | .clicked() | ||
| 278 | { | ||
| 279 | demo.bets = Bets::default(); | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | fn result_line(ui: &mut Ui, demo: &Demo) { | ||
| 284 | ui.horizontal(|ui| { | ||
| 285 | match (demo.phase, demo.number) { | ||
| 286 | (RoulettePhase::Result, Some(number)) => { | ||
| 287 | ui.label(theme::label("Result")); | ||
| 288 | ui.label( | ||
| 289 | RichText::new(number.to_string()) | ||
| 290 | .font(theme::mono(15.0)) | ||
| 291 | .color(color_of(number)), | ||
| 292 | ); | ||
| 293 | ui.label( | ||
| 294 | RichText::new(wheel_color(number).to_uppercase()) | ||
| 295 | .font(theme::mono(12.0)) | ||
| 296 | .color(theme::TEXT_DIM), | ||
| 297 | ); | ||
| 298 | } | ||
| 299 | (RoulettePhase::Spinning, _) => { | ||
| 300 | ui.label( | ||
| 301 | RichText::new("SPINNING…") | ||
| 302 | .font(theme::mono(12.0)) | ||
| 303 | .color(theme::GOLD), | ||
| 304 | ); | ||
| 305 | } | ||
| 306 | _ => { | ||
| 307 | ui.label( | ||
| 308 | RichText::new("Place your bets") | ||
| 309 | .font(theme::sans(12.0)) | ||
| 310 | .color(theme::TEXT_DIM), | ||
| 311 | ); | ||
| 312 | } | ||
| 313 | } | ||
| 314 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 315 | ui.label( | ||
| 316 | RichText::new(format!("{} chips", demo.bets.chips.len())) | ||
| 317 | .font(theme::mono(10.0)) | ||
| 318 | .color(theme::TEXT_FAINT), | ||
| 319 | ); | ||
| 320 | }); | ||
| 321 | }); | ||
| 322 | } | ||
| 323 | |||
| 324 | fn bet_row(ui: &mut Ui, name: &str, amount: u64) { | ||
| 325 | ui.horizontal(|ui| { | ||
| 326 | ui.label( | ||
| 327 | RichText::new(name) | ||
| 328 | .font(theme::sans(13.0)) | ||
| 329 | .color(theme::TEXT_DIM), | ||
| 330 | ); | ||
| 331 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 332 | ui.label( | ||
| 333 | RichText::new(theme::money(amount as i64)) | ||
| 334 | .font(theme::mono(13.0)) | ||
| 335 | .color(theme::GOLD), | ||
| 336 | ); | ||
| 337 | }); | ||
| 338 | }); | ||
| 339 | } | ||
| 340 | |||
| 341 | fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response { | ||
| 342 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click()); | ||
| 343 | let center = rect.center(); | ||
| 344 | let radius = 20.0; | ||
| 345 | let painter = ui.painter(); | ||
| 346 | if selected || response.hovered() { | ||
| 347 | painter.circle_stroke( | ||
| 348 | center, | ||
| 349 | radius + 3.0, | ||
| 350 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | ||
| 351 | ); | ||
| 352 | } | ||
| 353 | roulette::paint_chip(painter, center, radius, amount); | ||
| 354 | response | ||
| 355 | } | ||
| 356 | |||
| 357 | fn color_of(number: u8) -> Color32 { | ||
| 358 | match wheel_color(number) { | ||
| 359 | "red" => theme::ROULETTE_RED, | ||
| 360 | "green" => theme::WIN, | ||
| 361 | _ => theme::TEXT, | ||
| 362 | } | ||
| 363 | } | ||
| 364 | |||
| 365 | // --------------------------------------------------------------------------- | ||
| 366 | // Table | ||
| 367 | // --------------------------------------------------------------------------- | ||
| 368 | |||
| 369 | fn table(ui: &mut Ui, demo: &mut Demo) { | ||
| 370 | ui.horizontal(|ui| { | ||
| 371 | ui.label( | ||
| 372 | RichText::new("Table 01") | ||
| 373 | .font(theme::sans(15.0)) | ||
| 374 | .color(theme::TEXT), | ||
| 375 | ); | ||
| 376 | ui.label( | ||
| 377 | RichText::new("European · single zero") | ||
| 378 | .font(theme::sans(12.0)) | ||
| 379 | .color(theme::TEXT_DIM), | ||
| 380 | ); | ||
| 381 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 382 | ui.label( | ||
| 383 | RichText::new("Limits 10 – 500 Kr") | ||
| 384 | .font(theme::mono(12.0)) | ||
| 385 | .color(theme::TEXT_FAINT), | ||
| 386 | ); | ||
| 387 | }); | ||
| 388 | }); | ||
| 389 | ui.add_space(theme::SPACE_4); | ||
| 390 | |||
| 391 | let view = demo.view(); | ||
| 392 | let strip_height = 104.0; | ||
| 393 | let available = ui.available_size(); | ||
| 394 | let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0))); | ||
| 395 | let side_pad = ((available.x - size.x) * 0.5).max(0.0); | ||
| 396 | let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0); | ||
| 397 | let chip = demo.chip; | ||
| 398 | ui.add_space(top_pad); | ||
| 399 | ui.horizontal(|ui| { | ||
| 400 | ui.add_space(side_pad); | ||
| 401 | if let Some(target) = roulette::felt(ui, size, &view) { | ||
| 402 | demo.bets.add(target, chip); | ||
| 403 | } | ||
| 404 | }); | ||
| 405 | |||
| 406 | ui.add_space(theme::SPACE_4); | ||
| 407 | ui.horizontal(|ui| { | ||
| 408 | ui.label(theme::label("Last")); | ||
| 409 | if demo.last.is_empty() { | ||
| 410 | ui.label( | ||
| 411 | RichText::new("No spins yet") | ||
| 412 | .font(theme::sans(12.0)) | ||
| 413 | .color(theme::TEXT_FAINT), | ||
| 414 | ); | ||
| 415 | } | ||
| 416 | for number in &demo.last { | ||
| 417 | let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover()); | ||
| 418 | roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number); | ||
| 419 | ui.add_space(theme::SPACE_1); | ||
| 420 | } | ||
| 421 | }); | ||
| 422 | ui.add_space(theme::SPACE_2); | ||
| 423 | ui.label( | ||
| 424 | RichText::new( | ||
| 425 | "Click a pocket to place a chip · arrow keys move the cursor · space spins", | ||
| 426 | ) | ||
| 427 | .font(theme::sans(12.0)) | ||
| 428 | .color(theme::TEXT_FAINT), | ||
| 429 | ); | ||
| 430 | } | ||
| 431 | |||
| 432 | // --------------------------------------------------------------------------- | ||
| 433 | // Demo state | ||
| 434 | // --------------------------------------------------------------------------- | ||
| 435 | |||
| 436 | struct Spin { | ||
| 437 | started: f64, | ||
| 438 | from: f32, | ||
| 439 | delta: f32, | ||
| 440 | target: u8, | ||
| 441 | } | ||
| 442 | |||
| 443 | struct Demo { | ||
| 444 | bets: Bets, | ||
| 445 | cursor: Cursor, | ||
| 446 | chip: u64, | ||
| 447 | phase: RoulettePhase, | ||
| 448 | number: Option<u8>, | ||
| 449 | rotation: f32, | ||
| 450 | spin: Option<Spin>, | ||
| 451 | spins: usize, | ||
| 452 | last: Vec<u8>, | ||
| 453 | balance: i64, | ||
| 454 | host: &'static str, | ||
| 455 | ping_ms: u32, | ||
| 456 | user: &'static str, | ||
| 457 | account: &'static str, | ||
| 458 | } | ||
| 459 | |||
| 460 | impl Demo { | ||
| 461 | fn new() -> Self { | ||
| 462 | let mut bets = Bets::default(); | ||
| 463 | bets.add(BetTarget::Straight { number: 17 }, 50); | ||
| 464 | bets.add( | ||
| 465 | BetTarget::Corner { | ||
| 466 | numbers: [17, 18, 20, 21], | ||
| 467 | }, | ||
| 468 | 25, | ||
| 469 | ); | ||
| 470 | bets.add(BetTarget::Outside { kind: Outside::Red }, 25); | ||
| 471 | |||
| 472 | Self { | ||
| 473 | bets, | ||
| 474 | // A split with no chip on it, so the fine lattice cursor shows. | ||
| 475 | cursor: Cursor::Lattice { x: 18, y: 3 }, | ||
| 476 | chip: 25, | ||
| 477 | phase: RoulettePhase::Placing, | ||
| 478 | number: None, | ||
| 479 | rotation: 0.0, | ||
| 480 | spin: None, | ||
| 481 | spins: 0, | ||
| 482 | last: Vec::new(), | ||
| 483 | balance: 1000, | ||
| 484 | host: "127.0.0.1:11525", | ||
| 485 | ping_ms: 41, | ||
| 486 | user: "alice", | ||
| 487 | account: "#4161673306", | ||
| 488 | } | ||
| 489 | } | ||
| 490 | |||
| 491 | fn view(&self) -> RouletteView { | ||
| 492 | RouletteView { | ||
| 493 | bet_amount: 10, | ||
| 494 | balance: Some(self.balance), | ||
| 495 | chip: self.chip, | ||
| 496 | bets: self.bets.clone(), | ||
| 497 | cursor: self.cursor, | ||
| 498 | phase: self.phase, | ||
| 499 | number: self.number, | ||
| 500 | color: self.number.map(|number| wheel_color(number).to_string()), | ||
| 501 | winnings: None, | ||
| 502 | error: None, | ||
| 503 | } | ||
| 504 | } | ||
| 505 | |||
| 506 | fn tick(&mut self, ctx: &Context) { | ||
| 507 | let now = ctx.input(|input| input.time); | ||
| 508 | let mut finished = None; | ||
| 509 | if let Some(spin) = &self.spin { | ||
| 510 | let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0); | ||
| 511 | let eased = 1.0 - (1.0 - t).powi(3); | ||
| 512 | self.rotation = spin.from + spin.delta * eased; | ||
| 513 | if t >= 1.0 { | ||
| 514 | finished = Some(spin.target); | ||
| 515 | } | ||
| 516 | ctx.request_repaint(); | ||
| 517 | } | ||
| 518 | if let Some(number) = finished { | ||
| 519 | self.spin = None; | ||
| 520 | self.phase = RoulettePhase::Result; | ||
| 521 | self.number = Some(number); | ||
| 522 | self.last.insert(0, number); | ||
| 523 | self.last.truncate(12); | ||
| 524 | } | ||
| 525 | } | ||
| 526 | |||
| 527 | fn keys(&mut self, ctx: &Context) { | ||
| 528 | if self.phase == RoulettePhase::Spinning { | ||
| 529 | return; | ||
| 530 | } | ||
| 531 | let (left, right, up, down) = ctx.input(|input| { | ||
| 532 | ( | ||
| 533 | input.key_pressed(Key::ArrowLeft), | ||
| 534 | input.key_pressed(Key::ArrowRight), | ||
| 535 | input.key_pressed(Key::ArrowUp), | ||
| 536 | input.key_pressed(Key::ArrowDown), | ||
| 537 | ) | ||
| 538 | }); | ||
| 539 | for (pressed, direction) in [ | ||
| 540 | (left, Direction::Left), | ||
| 541 | (right, Direction::Right), | ||
| 542 | (up, Direction::Up), | ||
| 543 | (down, Direction::Down), | ||
| 544 | ] { | ||
| 545 | if pressed { | ||
| 546 | self.cursor = self.cursor.moved(direction); | ||
| 547 | } | ||
| 548 | } | ||
| 549 | if ctx.memory(|memory| memory.focused().is_none()) { | ||
| 550 | if ctx.input(|input| input.key_pressed(Key::Enter)) { | ||
| 551 | if let Some(target) = self.cursor.target() { | ||
| 552 | let chip = self.chip; | ||
| 553 | self.bets.add(target, chip); | ||
| 554 | } | ||
| 555 | } | ||
| 556 | if ctx.input(|input| input.key_pressed(Key::Space)) { | ||
| 557 | self.start_spin(ctx); | ||
| 558 | } | ||
| 559 | } | ||
| 560 | } | ||
| 561 | |||
| 562 | fn start_spin(&mut self, ctx: &Context) { | ||
| 563 | if self.phase == RoulettePhase::Spinning { | ||
| 564 | return; | ||
| 565 | } | ||
| 566 | let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()]; | ||
| 567 | self.spins += 1; | ||
| 568 | |||
| 569 | let segment = TAU / WHEEL.len() as f32; | ||
| 570 | let index = WHEEL | ||
| 571 | .iter() | ||
| 572 | .position(|number| *number == target) | ||
| 573 | .unwrap_or(0) as f32; | ||
| 574 | let desired = -index * segment; | ||
| 575 | let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU; | ||
| 576 | |||
| 577 | self.spin = Some(Spin { | ||
| 578 | started: ctx.input(|input| input.time), | ||
| 579 | from: self.rotation, | ||
| 580 | delta, | ||
| 581 | target, | ||
| 582 | }); | ||
| 583 | self.phase = RoulettePhase::Spinning; | ||
| 584 | self.number = None; | ||
| 585 | ctx.request_repaint(); | ||
| 586 | } | ||
| 587 | } | ||
diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..e0f5bc9 --- /dev/null +++ b/gui/src/lib.rs | |||
| @@ -0,0 +1,9 @@ | |||
| 1 | //! Desktop frontend for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! The window is a thin shell over the shared `ClientCore` (Phase 4). This | ||
| 4 | //! crate holds only presentation: the theme, the roulette table, and the app | ||
| 5 | //! layout. It never enters the TUI or server build graph. | ||
| 6 | |||
| 7 | pub mod app; | ||
| 8 | pub mod roulette; | ||
| 9 | pub mod theme; | ||
diff --git a/gui/src/main.rs b/gui/src/main.rs new file mode 100644 index 0000000..cf1c67d --- /dev/null +++ b/gui/src/main.rs | |||
| @@ -0,0 +1,25 @@ | |||
| 1 | //! Desktop frontend for zagan-bet. | ||
| 2 | //! | ||
| 3 | //! Phase 3 of `docs/GUI-PLAN.md`: the style spike. It owns the window and the | ||
| 4 | //! roulette reference screen; the shared core and transport are wired in | ||
| 5 | //! Phase 4. | ||
| 6 | |||
| 7 | use zagan_bet_gui::app::ZaganApp; | ||
| 8 | |||
| 9 | fn main() -> eframe::Result { | ||
| 10 | let options = eframe::NativeOptions { | ||
| 11 | viewport: eframe::egui::ViewportBuilder::default() | ||
| 12 | .with_title("Zagan-Bet") | ||
| 13 | .with_app_id("zagan-bet") | ||
| 14 | .with_inner_size([1440.0, 900.0]) | ||
| 15 | .with_min_inner_size([1100.0, 720.0]), | ||
| 16 | renderer: eframe::Renderer::Glow, | ||
| 17 | ..Default::default() | ||
| 18 | }; | ||
| 19 | |||
| 20 | eframe::run_native( | ||
| 21 | "Zagan-Bet", | ||
| 22 | options, | ||
| 23 | Box::new(|cc| Ok(Box::new(ZaganApp::new(cc)))), | ||
| 24 | ) | ||
| 25 | } | ||
diff --git a/gui/src/roulette.rs b/gui/src/roulette.rs new file mode 100644 index 0000000..22c46d6 --- /dev/null +++ b/gui/src/roulette.rs | |||
| @@ -0,0 +1,522 @@ | |||
| 1 | //! The roulette table and wheel, painted from `RouletteView`. | ||
| 2 | //! | ||
| 3 | //! Geometry is the GUI's business: the core only says which number or region a | ||
| 4 | //! chip or the cursor is on. Everything here is derived from that, so the two | ||
| 5 | //! frontends can lay the same table out differently. | ||
| 6 | |||
| 7 | use std::f32::consts::{FRAC_PI_2, TAU}; | ||
| 8 | |||
| 9 | use eframe::egui::{ | ||
| 10 | Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2, | ||
| 11 | pos2, vec2, | ||
| 12 | }; | ||
| 13 | use zagan_bet::client::games::roulette::Cursor; | ||
| 14 | use zagan_bet::client::view::{RoulettePhase, RouletteView}; | ||
| 15 | use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red}; | ||
| 16 | |||
| 17 | use crate::theme; | ||
| 18 | |||
| 19 | const COLUMNS: usize = 12; | ||
| 20 | const ROWS: usize = 3; | ||
| 21 | const OUTSIDE_ORDER: [Outside; 6] = [ | ||
| 22 | Outside::Low, | ||
| 23 | Outside::Even, | ||
| 24 | Outside::Red, | ||
| 25 | Outside::Black, | ||
| 26 | Outside::Odd, | ||
| 27 | Outside::High, | ||
| 28 | ]; | ||
| 29 | |||
| 30 | const DOZEN_ROW: f32 = 0.72; | ||
| 31 | const COLUMN_ROW: f32 = 0.55; | ||
| 32 | const OUTSIDE_ROW: f32 = 0.62; | ||
| 33 | const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW; | ||
| 34 | const BOARD_PAD: f32 = 6.0; | ||
| 35 | |||
| 36 | /// Largest table that fits in `available`, including its felt border. | ||
| 37 | pub fn size_for(available: Vec2) -> Vec2 { | ||
| 38 | let inner = (available - Vec2::splat(BOARD_PAD * 2.0)).max(Vec2::ZERO); | ||
| 39 | let cell = (inner.x / (COLUMNS as f32 + 1.0)) | ||
| 40 | .min(inner.y / HEIGHT_UNITS) | ||
| 41 | .max(1.0); | ||
| 42 | vec2((COLUMNS as f32 + 1.0) * cell, HEIGHT_UNITS * cell) + Vec2::splat(BOARD_PAD * 2.0) | ||
| 43 | } | ||
| 44 | |||
| 45 | /// The table painted at `size`; returns the target clicked this frame. | ||
| 46 | pub fn felt(ui: &mut Ui, size: Vec2, view: &RouletteView) -> Option<BetTarget> { | ||
| 47 | let (rect, response) = ui.allocate_exact_size(size, Sense::click()); | ||
| 48 | let felt = Felt::new(rect); | ||
| 49 | let hovered = response.hover_pos().and_then(|pos| felt.hit(pos)); | ||
| 50 | let painter = ui.painter(); | ||
| 51 | |||
| 52 | painter.rect_filled(rect, theme::RADIUS_LG, theme::FELT_DARK); | ||
| 53 | |||
| 54 | for number in 0..=36u8 { | ||
| 55 | let highlighted = hovered == Some(BetTarget::Straight { number }); | ||
| 56 | felt.paint_number(painter, number, highlighted); | ||
| 57 | } | ||
| 58 | for index in 0..3 { | ||
| 59 | felt.paint_dozen( | ||
| 60 | painter, | ||
| 61 | index, | ||
| 62 | hovered == Some(BetTarget::Dozen { index: index as u8 }), | ||
| 63 | ); | ||
| 64 | } | ||
| 65 | for index in 0..3 { | ||
| 66 | felt.paint_column( | ||
| 67 | painter, | ||
| 68 | index, | ||
| 69 | hovered == Some(BetTarget::Column { index: index as u8 }), | ||
| 70 | ); | ||
| 71 | } | ||
| 72 | for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { | ||
| 73 | felt.paint_outside( | ||
| 74 | painter, | ||
| 75 | index, | ||
| 76 | *kind, | ||
| 77 | hovered == Some(BetTarget::Outside { kind: *kind }), | ||
| 78 | ); | ||
| 79 | } | ||
| 80 | |||
| 81 | if view.phase == RoulettePhase::Result { | ||
| 82 | if let Some(number) = view.number { | ||
| 83 | painter.rect_stroke( | ||
| 84 | felt.number_rect(number).shrink(2.0), | ||
| 85 | theme::RADIUS_SM, | ||
| 86 | Stroke::new(2.0, theme::GOLD), | ||
| 87 | StrokeKind::Inside, | ||
| 88 | ); | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | felt.paint_cursor(painter, &view.cursor); | ||
| 93 | |||
| 94 | for chip in &view.bets.chips { | ||
| 95 | if let Some(center) = felt.anchor(&chip.target) { | ||
| 96 | paint_chip(painter, center, felt.cell * 0.26, chip.amount); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | if response.clicked() { hovered } else { None } | ||
| 101 | } | ||
| 102 | |||
| 103 | struct Felt { | ||
| 104 | cell: f32, | ||
| 105 | origin: Pos2, | ||
| 106 | } | ||
| 107 | |||
| 108 | impl Felt { | ||
| 109 | fn new(board: Rect) -> Self { | ||
| 110 | let inner = board.shrink(BOARD_PAD); | ||
| 111 | Self { | ||
| 112 | cell: inner.width() / (COLUMNS as f32 + 1.0), | ||
| 113 | origin: inner.min, | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | fn number_rect(&self, number: u8) -> Rect { | ||
| 118 | debug_assert!(number <= 36); | ||
| 119 | if number == 0 { | ||
| 120 | return Rect::from_min_size(self.origin, vec2(self.cell, self.cell * ROWS as f32)); | ||
| 121 | } | ||
| 122 | let column = ((number - 1) / 3) as f32; | ||
| 123 | let row = (2 - (number - 1) % 3) as f32; | ||
| 124 | Rect::from_min_size( | ||
| 125 | self.origin + vec2(self.cell * (1.0 + column), self.cell * row), | ||
| 126 | Vec2::splat(self.cell), | ||
| 127 | ) | ||
| 128 | } | ||
| 129 | |||
| 130 | fn dozen_rect(&self, index: usize) -> Rect { | ||
| 131 | let y = self.origin.y + self.cell * ROWS as f32; | ||
| 132 | Rect::from_min_size( | ||
| 133 | pos2(self.origin.x + self.cell * (1.0 + 4.0 * index as f32), y), | ||
| 134 | vec2(self.cell * 4.0, self.cell * DOZEN_ROW), | ||
| 135 | ) | ||
| 136 | } | ||
| 137 | |||
| 138 | fn column_rect(&self, index: usize) -> Rect { | ||
| 139 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW); | ||
| 140 | Rect::from_min_size( | ||
| 141 | pos2(self.origin.x + self.cell * (1.0 + index as f32), y), | ||
| 142 | vec2(self.cell, self.cell * COLUMN_ROW), | ||
| 143 | ) | ||
| 144 | } | ||
| 145 | |||
| 146 | fn outside_rect(&self, index: usize) -> Rect { | ||
| 147 | let y = self.origin.y + self.cell * (ROWS as f32 + DOZEN_ROW + COLUMN_ROW); | ||
| 148 | Rect::from_min_size( | ||
| 149 | pos2(self.origin.x + self.cell * (1.0 + 2.0 * index as f32), y), | ||
| 150 | vec2(self.cell * 2.0, self.cell * OUTSIDE_ROW), | ||
| 151 | ) | ||
| 152 | } | ||
| 153 | |||
| 154 | /// Where a chip sits for this target. | ||
| 155 | fn anchor(&self, target: &BetTarget) -> Option<Pos2> { | ||
| 156 | Some(match target { | ||
| 157 | BetTarget::Straight { number } => self.number_rect(*number).center(), | ||
| 158 | BetTarget::Split { a, b } => { | ||
| 159 | let a = self.number_rect(*a).center(); | ||
| 160 | let b = self.number_rect(*b).center(); | ||
| 161 | pos2((a.x + b.x) * 0.5, (a.y + b.y) * 0.5) | ||
| 162 | } | ||
| 163 | BetTarget::Corner { numbers } => { | ||
| 164 | let mut sum = Vec2::ZERO; | ||
| 165 | for number in numbers { | ||
| 166 | sum += self.number_rect(*number).center().to_vec2(); | ||
| 167 | } | ||
| 168 | (sum / numbers.len() as f32).to_pos2() | ||
| 169 | } | ||
| 170 | BetTarget::Dozen { index } => self.dozen_rect(*index as usize).center(), | ||
| 171 | BetTarget::Column { index } => self.column_rect(*index as usize).center(), | ||
| 172 | BetTarget::Outside { kind } => { | ||
| 173 | let index = OUTSIDE_ORDER | ||
| 174 | .iter() | ||
| 175 | .position(|candidate| candidate == kind)?; | ||
| 176 | self.outside_rect(index).center() | ||
| 177 | } | ||
| 178 | }) | ||
| 179 | } | ||
| 180 | |||
| 181 | /// The fine-grid point the cursor occupies over the numbers. | ||
| 182 | fn lattice(&self, x: usize, y: usize) -> Pos2 { | ||
| 183 | pos2( | ||
| 184 | self.origin.x + self.cell * (1.0 + x as f32 * 0.5), | ||
| 185 | self.origin.y + self.cell * (y as f32 * 0.5), | ||
| 186 | ) | ||
| 187 | } | ||
| 188 | |||
| 189 | fn hit(&self, pos: Pos2) -> Option<BetTarget> { | ||
| 190 | for number in 0..=36u8 { | ||
| 191 | if self.number_rect(number).contains(pos) { | ||
| 192 | return Some(BetTarget::Straight { number }); | ||
| 193 | } | ||
| 194 | } | ||
| 195 | for index in 0..3usize { | ||
| 196 | if self.dozen_rect(index).contains(pos) { | ||
| 197 | return Some(BetTarget::Dozen { index: index as u8 }); | ||
| 198 | } | ||
| 199 | if self.column_rect(index).contains(pos) { | ||
| 200 | return Some(BetTarget::Column { index: index as u8 }); | ||
| 201 | } | ||
| 202 | } | ||
| 203 | for (index, kind) in OUTSIDE_ORDER.iter().enumerate() { | ||
| 204 | if self.outside_rect(index).contains(pos) { | ||
| 205 | return Some(BetTarget::Outside { kind: *kind }); | ||
| 206 | } | ||
| 207 | } | ||
| 208 | None | ||
| 209 | } | ||
| 210 | |||
| 211 | fn paint_number(&self, painter: &Painter, number: u8, hovered: bool) { | ||
| 212 | let rect = self.number_rect(number); | ||
| 213 | let fill = if number == 0 { | ||
| 214 | theme::ROULETTE_ZERO | ||
| 215 | } else if is_red(number) { | ||
| 216 | theme::ROULETTE_RED | ||
| 217 | } else { | ||
| 218 | theme::ROULETTE_BLACK | ||
| 219 | }; | ||
| 220 | painter.rect_filled(rect, CornerRadius::ZERO, fill); | ||
| 221 | painter.rect_stroke( | ||
| 222 | rect, | ||
| 223 | CornerRadius::ZERO, | ||
| 224 | Stroke::new(1.0, theme::FELT_LINE), | ||
| 225 | StrokeKind::Inside, | ||
| 226 | ); | ||
| 227 | if hovered { | ||
| 228 | painter.rect_filled( | ||
| 229 | rect, | ||
| 230 | CornerRadius::ZERO, | ||
| 231 | Color32::WHITE.gamma_multiply(0.10), | ||
| 232 | ); | ||
| 233 | } | ||
| 234 | painter.text( | ||
| 235 | rect.center(), | ||
| 236 | Align2::CENTER_CENTER, | ||
| 237 | number, | ||
| 238 | theme::mono(self.number_font()), | ||
| 239 | theme::FELT_TEXT, | ||
| 240 | ); | ||
| 241 | } | ||
| 242 | |||
| 243 | fn paint_dozen(&self, painter: &Painter, index: usize, hovered: bool) { | ||
| 244 | let rect = self.dozen_rect(index); | ||
| 245 | self.paint_region(painter, rect, hovered); | ||
| 246 | let label = match index { | ||
| 247 | 0 => "1st 12", | ||
| 248 | 1 => "2nd 12", | ||
| 249 | _ => "3rd 12", | ||
| 250 | }; | ||
| 251 | painter.text( | ||
| 252 | rect.center(), | ||
| 253 | Align2::CENTER_CENTER, | ||
| 254 | label, | ||
| 255 | theme::sans(self.label_font()), | ||
| 256 | theme::FELT_TEXT, | ||
| 257 | ); | ||
| 258 | } | ||
| 259 | |||
| 260 | fn paint_column(&self, painter: &Painter, index: usize, hovered: bool) { | ||
| 261 | let rect = self.column_rect(index); | ||
| 262 | self.paint_region(painter, rect, hovered); | ||
| 263 | painter.text( | ||
| 264 | rect.center(), | ||
| 265 | Align2::CENTER_CENTER, | ||
| 266 | "2:1", | ||
| 267 | theme::sans(self.label_font()), | ||
| 268 | theme::FELT_TEXT, | ||
| 269 | ); | ||
| 270 | } | ||
| 271 | |||
| 272 | fn paint_outside(&self, painter: &Painter, index: usize, kind: Outside, hovered: bool) { | ||
| 273 | let rect = self.outside_rect(index); | ||
| 274 | self.paint_region(painter, rect, hovered); | ||
| 275 | let color = match kind { | ||
| 276 | Outside::Red => theme::ROULETTE_RED, | ||
| 277 | Outside::Black => theme::TEXT_DIM, | ||
| 278 | _ => theme::FELT_TEXT, | ||
| 279 | }; | ||
| 280 | painter.text( | ||
| 281 | rect.center(), | ||
| 282 | Align2::CENTER_CENTER, | ||
| 283 | kind.label(), | ||
| 284 | theme::sans(self.label_font()), | ||
| 285 | color, | ||
| 286 | ); | ||
| 287 | } | ||
| 288 | |||
| 289 | fn paint_region(&self, painter: &Painter, rect: Rect, hovered: bool) { | ||
| 290 | painter.rect_filled(rect, CornerRadius::ZERO, theme::FELT); | ||
| 291 | painter.rect_stroke( | ||
| 292 | rect, | ||
| 293 | CornerRadius::ZERO, | ||
| 294 | Stroke::new(1.0, theme::FELT_LINE), | ||
| 295 | StrokeKind::Inside, | ||
| 296 | ); | ||
| 297 | if hovered { | ||
| 298 | painter.rect_filled( | ||
| 299 | rect, | ||
| 300 | CornerRadius::ZERO, | ||
| 301 | Color32::WHITE.gamma_multiply(0.10), | ||
| 302 | ); | ||
| 303 | } | ||
| 304 | } | ||
| 305 | |||
| 306 | fn paint_cursor(&self, painter: &Painter, cursor: &Cursor) { | ||
| 307 | let stroke = Stroke::new(2.0, theme::ACCENT); | ||
| 308 | match cursor { | ||
| 309 | Cursor::Zero => outline(painter, self.number_rect(0), stroke), | ||
| 310 | Cursor::Lattice { x, y } => { | ||
| 311 | for number in cursor.numbers() { | ||
| 312 | painter.rect_filled( | ||
| 313 | self.number_rect(number), | ||
| 314 | CornerRadius::ZERO, | ||
| 315 | theme::ACCENT.gamma_multiply(0.14), | ||
| 316 | ); | ||
| 317 | } | ||
| 318 | let point = self.lattice(*x, *y); | ||
| 319 | painter.circle_filled(point, self.cell * 0.05, theme::ACCENT); | ||
| 320 | painter.circle_stroke(point, self.cell * 0.14, stroke); | ||
| 321 | } | ||
| 322 | Cursor::Dozen(index) => outline(painter, self.dozen_rect(*index), stroke), | ||
| 323 | Cursor::Column(index) => outline(painter, self.column_rect(*index), stroke), | ||
| 324 | Cursor::Outside(index) => outline(painter, self.outside_rect(*index), stroke), | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | fn number_font(&self) -> f32 { | ||
| 329 | (self.cell * 0.40).clamp(12.0, 22.0) | ||
| 330 | } | ||
| 331 | |||
| 332 | fn label_font(&self) -> f32 { | ||
| 333 | (self.cell * 0.24).clamp(10.0, 15.0) | ||
| 334 | } | ||
| 335 | } | ||
| 336 | |||
| 337 | fn outline(painter: &Painter, rect: Rect, stroke: Stroke) { | ||
| 338 | painter.rect_stroke( | ||
| 339 | rect.shrink(1.0), | ||
| 340 | theme::RADIUS_SM, | ||
| 341 | stroke, | ||
| 342 | StrokeKind::Inside, | ||
| 343 | ); | ||
| 344 | } | ||
| 345 | |||
| 346 | /// A single chip, also used by the sidebar's chip tray. | ||
| 347 | pub fn paint_chip(painter: &Painter, center: Pos2, radius: f32, amount: u64) { | ||
| 348 | let fill = chip_color(amount); | ||
| 349 | painter.circle_filled(center, radius, fill); | ||
| 350 | painter.circle_stroke( | ||
| 351 | center, | ||
| 352 | radius - radius * 0.18, | ||
| 353 | Stroke::new(radius * 0.10, Color32::WHITE.gamma_multiply(0.55)), | ||
| 354 | ); | ||
| 355 | let text_color = if amount <= 10 { | ||
| 356 | theme::BG | ||
| 357 | } else { | ||
| 358 | Color32::WHITE | ||
| 359 | }; | ||
| 360 | painter.text( | ||
| 361 | center, | ||
| 362 | Align2::CENTER_CENTER, | ||
| 363 | amount, | ||
| 364 | theme::mono(radius * 0.85), | ||
| 365 | text_color, | ||
| 366 | ); | ||
| 367 | } | ||
| 368 | |||
| 369 | /// Colour per denomination, as a chip tray would be stocked. | ||
| 370 | pub fn chip_color(amount: u64) -> Color32 { | ||
| 371 | match amount { | ||
| 372 | 0..=10 => Color32::from_rgb(0xd9, 0xd3, 0xc7), | ||
| 373 | 11..=25 => Color32::from_rgb(0x3f, 0x7f, 0xd1), | ||
| 374 | 26..=50 => Color32::from_rgb(0xc9, 0x4b, 0x4b), | ||
| 375 | _ => Color32::from_rgb(0x3a, 0x9d, 0x5d), | ||
| 376 | } | ||
| 377 | } | ||
| 378 | |||
| 379 | fn segment_color(number: u8) -> Color32 { | ||
| 380 | match wheel_color(number) { | ||
| 381 | "red" => theme::ROULETTE_RED, | ||
| 382 | "green" => theme::ROULETTE_ZERO, | ||
| 383 | _ => Color32::from_rgb(0x2b, 0x35, 0x3f), | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | /// Number currently under the pointer for this wheel rotation. | ||
| 388 | pub fn number_under(rotation: f32) -> u8 { | ||
| 389 | let segment = TAU / WHEEL.len() as f32; | ||
| 390 | let index = (-rotation / segment).round() as i64; | ||
| 391 | WHEEL[index.rem_euclid(WHEEL.len() as i64) as usize] | ||
| 392 | } | ||
| 393 | |||
| 394 | /// The wheel dial: 37 coloured pockets, a fixed pointer, and the number under | ||
| 395 | /// it. `rotation` is in radians. | ||
| 396 | pub fn wheel(ui: &mut Ui, diameter: f32, view: &RouletteView, rotation: f32) { | ||
| 397 | let (rect, _) = ui.allocate_exact_size(Vec2::splat(diameter), Sense::hover()); | ||
| 398 | let painter = ui.painter(); | ||
| 399 | let center = rect.center(); | ||
| 400 | let radius = diameter * 0.5; | ||
| 401 | |||
| 402 | painter.circle_filled(center, radius, theme::SURFACE_RAISED); | ||
| 403 | painter.circle_stroke(center, radius - 1.0, Stroke::new(1.0, theme::LINE)); | ||
| 404 | |||
| 405 | let segment = TAU / WHEEL.len() as f32; | ||
| 406 | let ring = radius * 0.80; | ||
| 407 | let dot = (radius * 0.072).max(2.0); | ||
| 408 | painter.circle_stroke( | ||
| 409 | center, | ||
| 410 | ring, | ||
| 411 | Stroke::new(dot * 2.0, Color32::from_rgb(0x1f, 0x28, 0x31)), | ||
| 412 | ); | ||
| 413 | for (index, number) in WHEEL.iter().enumerate() { | ||
| 414 | let angle = -FRAC_PI_2 + rotation + index as f32 * segment; | ||
| 415 | let pocket = center + vec2(angle.cos(), angle.sin()) * ring; | ||
| 416 | let winning = view.phase == RoulettePhase::Result && view.number == Some(*number); | ||
| 417 | let stroke = if winning { | ||
| 418 | Stroke::new(2.0, theme::GOLD) | ||
| 419 | } else { | ||
| 420 | Stroke::new(1.0, Color32::from_rgb(0x3a, 0x46, 0x52)) | ||
| 421 | }; | ||
| 422 | painter.circle( | ||
| 423 | pocket, | ||
| 424 | if winning { dot * 1.25 } else { dot }, | ||
| 425 | segment_color(*number), | ||
| 426 | stroke, | ||
| 427 | ); | ||
| 428 | } | ||
| 429 | |||
| 430 | // A fixed pointer at the top, marking the winning pocket. | ||
| 431 | let tip = center + vec2(0.0, -radius + dot * 0.30); | ||
| 432 | painter.add(Shape::convex_polygon( | ||
| 433 | vec![ | ||
| 434 | tip, | ||
| 435 | tip + vec2(-dot * 0.9, -dot * 1.4), | ||
| 436 | tip + vec2(dot * 0.9, -dot * 1.4), | ||
| 437 | ], | ||
| 438 | theme::TEXT, | ||
| 439 | Stroke::NONE, | ||
| 440 | )); | ||
| 441 | |||
| 442 | let inner = radius * 0.52; | ||
| 443 | painter.circle_filled(center, inner, theme::SURFACE); | ||
| 444 | painter.circle_stroke(center, inner, Stroke::new(1.0, theme::LINE)); | ||
| 445 | |||
| 446 | let displayed = match view.phase { | ||
| 447 | RoulettePhase::Result => view.number, | ||
| 448 | RoulettePhase::Spinning => Some(number_under(rotation)), | ||
| 449 | RoulettePhase::Placing => None, | ||
| 450 | }; | ||
| 451 | match displayed { | ||
| 452 | Some(number) => { | ||
| 453 | let color = match wheel_color(number) { | ||
| 454 | "red" => theme::ROULETTE_RED, | ||
| 455 | "green" => theme::WIN, | ||
| 456 | _ => theme::TEXT, | ||
| 457 | }; | ||
| 458 | painter.text( | ||
| 459 | center, | ||
| 460 | Align2::CENTER_CENTER, | ||
| 461 | number, | ||
| 462 | theme::mono(inner * 0.85), | ||
| 463 | color, | ||
| 464 | ); | ||
| 465 | } | ||
| 466 | None => { | ||
| 467 | painter.text( | ||
| 468 | center, | ||
| 469 | Align2::CENTER_CENTER, | ||
| 470 | "—", | ||
| 471 | theme::mono(inner * 0.55), | ||
| 472 | theme::TEXT_FAINT, | ||
| 473 | ); | ||
| 474 | } | ||
| 475 | } | ||
| 476 | } | ||
| 477 | |||
| 478 | /// "Straight 17", "Corner 17/18/20/21", "Red". | ||
| 479 | pub fn target_name(target: &BetTarget) -> String { | ||
| 480 | match target { | ||
| 481 | BetTarget::Straight { number } => format!("Straight {number}"), | ||
| 482 | BetTarget::Split { a, b } => format!("Split {a}/{b}"), | ||
| 483 | BetTarget::Corner { numbers } => format!( | ||
| 484 | "Corner {}/{}/{}/{}", | ||
| 485 | numbers[0], numbers[1], numbers[2], numbers[3] | ||
| 486 | ), | ||
| 487 | BetTarget::Dozen { index } => match index { | ||
| 488 | 0 => "1st 12".to_string(), | ||
| 489 | 1 => "2nd 12".to_string(), | ||
| 490 | _ => "3rd 12".to_string(), | ||
| 491 | }, | ||
| 492 | BetTarget::Column { index } => format!("Column {}", index + 1), | ||
| 493 | BetTarget::Outside { kind } => title_case(kind.label()), | ||
| 494 | } | ||
| 495 | } | ||
| 496 | |||
| 497 | fn title_case(text: &str) -> String { | ||
| 498 | let mut characters = text.chars(); | ||
| 499 | match characters.next() { | ||
| 500 | Some(first) => { | ||
| 501 | first.to_uppercase().collect::<String>() + &characters.as_str().to_lowercase() | ||
| 502 | } | ||
| 503 | None => String::new(), | ||
| 504 | } | ||
| 505 | } | ||
| 506 | |||
| 507 | /// Small coloured dot for a result strip. | ||
| 508 | pub fn paint_result_dot(painter: &Painter, center: Pos2, radius: f32, number: u8) { | ||
| 509 | let color = match wheel_color(number) { | ||
| 510 | "red" => theme::ROULETTE_RED, | ||
| 511 | "green" => theme::ROULETTE_ZERO, | ||
| 512 | _ => theme::ROULETTE_BLACK, | ||
| 513 | }; | ||
| 514 | painter.circle_filled(center, radius, color); | ||
| 515 | painter.text( | ||
| 516 | center, | ||
| 517 | Align2::CENTER_CENTER, | ||
| 518 | number, | ||
| 519 | theme::mono(radius * 0.95), | ||
| 520 | theme::FELT_TEXT, | ||
| 521 | ); | ||
| 522 | } | ||
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 | } | ||
diff --git a/gui/tests/layout.rs b/gui/tests/layout.rs new file mode 100644 index 0000000..e0c0475 --- /dev/null +++ b/gui/tests/layout.rs | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | //! Pure presentation maths. Anything that touches a `Ui` is covered by the | ||
| 2 | //! manual screenshot pass; these are the calculations a pixel test cannot | ||
| 3 | //! express. | ||
| 4 | |||
| 5 | use std::f32::consts::TAU; | ||
| 6 | |||
| 7 | use zagan_bet::roulette::{BetTarget, Outside}; | ||
| 8 | use zagan_bet_gui::roulette::{number_under, size_for, target_name}; | ||
| 9 | use zagan_bet_gui::theme::{group_digits, money}; | ||
| 10 | |||
| 11 | #[test] | ||
| 12 | fn money_is_grouped_in_threes() { | ||
| 13 | assert_eq!(group_digits(0), "0"); | ||
| 14 | assert_eq!(group_digits(999), "999"); | ||
| 15 | assert_eq!(group_digits(1000), "1 000"); | ||
| 16 | assert_eq!(group_digits(1234567), "1 234 567"); | ||
| 17 | assert_eq!(group_digits(-1500), "-1 500"); | ||
| 18 | assert_eq!(money(1000), "1 000 Kr"); | ||
| 19 | } | ||
| 20 | |||
| 21 | #[test] | ||
| 22 | fn the_wheel_pointer_reads_the_number_at_the_top() { | ||
| 23 | let segment = TAU / 37.0; | ||
| 24 | assert_eq!(number_under(0.0), 0); | ||
| 25 | |||
| 26 | // Rotating by -i segments puts wheel entry i under the pointer. | ||
| 27 | for number in [0u8, 32, 17, 26] { | ||
| 28 | let index = zagan_bet::roulette::WHEEL | ||
| 29 | .iter() | ||
| 30 | .position(|candidate| *candidate == number) | ||
| 31 | .expect("number is on the wheel"); | ||
| 32 | let rotation = -(index as f32) * segment; | ||
| 33 | assert_eq!(number_under(rotation), number); | ||
| 34 | } | ||
| 35 | |||
| 36 | // One full turn changes nothing. | ||
| 37 | assert_eq!(number_under(-TAU), 0); | ||
| 38 | } | ||
| 39 | |||
| 40 | #[test] | ||
| 41 | fn table_size_keeps_the_felt_aspect_and_fits() { | ||
| 42 | let available = eframe::egui::vec2(1300.0, 900.0); | ||
| 43 | let size = size_for(available); | ||
| 44 | assert!(size.x <= available.x + 0.01, "{size:?} overflows"); | ||
| 45 | assert!(size.y <= available.y + 0.01, "{size:?} overflows"); | ||
| 46 | |||
| 47 | // 13 cells wide and 4.89 cells tall, plus the felt border on every side. | ||
| 48 | let cell = (size.x - 12.0) / 13.0; | ||
| 49 | assert!((cell - (size.y - 12.0) / 4.89).abs() < 0.01); | ||
| 50 | } | ||
| 51 | |||
| 52 | #[test] | ||
| 53 | fn targets_read_as_bet_slips() { | ||
| 54 | assert_eq!( | ||
| 55 | target_name(&BetTarget::Straight { number: 17 }), | ||
| 56 | "Straight 17" | ||
| 57 | ); | ||
| 58 | assert_eq!( | ||
| 59 | target_name(&BetTarget::Split { a: 26, b: 29 }), | ||
| 60 | "Split 26/29" | ||
| 61 | ); | ||
| 62 | assert_eq!( | ||
| 63 | target_name(&BetTarget::Corner { | ||
| 64 | numbers: [17, 18, 20, 21] | ||
| 65 | }), | ||
| 66 | "Corner 17/18/20/21" | ||
| 67 | ); | ||
| 68 | assert_eq!(target_name(&BetTarget::Dozen { index: 1 }), "2nd 12"); | ||
| 69 | assert_eq!(target_name(&BetTarget::Column { index: 2 }), "Column 3"); | ||
| 70 | assert_eq!( | ||
| 71 | target_name(&BetTarget::Outside { | ||
| 72 | kind: Outside::High | ||
| 73 | }), | ||
| 74 | "19-36" | ||
| 75 | ); | ||
| 76 | assert_eq!( | ||
| 77 | target_name(&BetTarget::Outside { | ||
| 78 | kind: Outside::Black | ||
| 79 | }), | ||
| 80 | "Black" | ||
| 81 | ); | ||
| 82 | } | ||
