summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
commit10ff558853f549d3f3c7c8432488bfad9da6973a (patch)
tree271b05fead384e2d91e5c8332fea9d00b540f0cc
parentv4.0.7: finish the Phase 1 boundary with a command shell (diff)
downloadzagan-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.
-rw-r--r--AGENTS.md13
-rw-r--r--Cargo.toml15
-rw-r--r--docs/GUI-PLAN.md43
-rw-r--r--docs/TESTING.md46
-rw-r--r--docs/screenshots/gui-roulette-1080p.pngbin0 -> 126927 bytes
-rw-r--r--docs/screenshots/gui-roulette-result-1080p.pngbin0 -> 127523 bytes
-rw-r--r--gui/Cargo.toml21
-rw-r--r--gui/assets/fonts/Hack-Regular.ttfbin0 -> 309408 bytes
-rw-r--r--gui/assets/fonts/LICENSE-Hack.md45
-rw-r--r--gui/assets/fonts/OFL-OpenSans.txt88
-rw-r--r--gui/assets/fonts/OpenSans-Regular.ttfbin0 -> 147528 bytes
-rw-r--r--gui/src/app.rs587
-rw-r--r--gui/src/lib.rs9
-rw-r--r--gui/src/main.rs25
-rw-r--r--gui/src/roulette.rs522
-rw-r--r--gui/src/theme.rs211
-rw-r--r--gui/tests/layout.rs82
-rw-r--r--tests/version.rs27
18 files changed, 1722 insertions, 12 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 3908c2d..c3b555d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -59,11 +59,20 @@
59 receives `Command` or a finished line through `submit_line`, never a key. 59 receives `Command` or a finished line through `submit_line`, never a key.
60- egui lives in the `gui/` workspace member. Its dependencies must never 60- egui lives in the `gui/` workspace member. Its dependencies must never
61 enter the TUI or server build graph: no GUI crate in the root package's 61 enter the TUI or server build graph: no GUI crate in the root package's
62 `[dependencies]`. 62 `[dependencies]`. The GUI may depend on the core, never the reverse.
63- Build the GUI with `cargo build -p zagan-bet-gui --profile gui-release`;
64 the profile lives at the workspace root because a member's is ignored.
65- Colours, spacing and font sizes come from `gui/src/theme.rs`; screens must
66 not hard-code them. The embedded fonts are Open Sans (OFL) and Hack (MIT);
67 ship their licence files with any change.
68- GUI integration tests live in `gui/tests/`, because the root package cannot
69 depend on the GUI.
63- First contact shows the fingerprint and waits for a decision before sending 70- First contact shows the fingerprint and waits for a decision before sending
64 any credentials. A changed key is a hard stop; forgetting it is explicit. 71 any credentials. A changed key is a hard stop; forgetting it is explicit.
65- The GUI is themed deliberately. Default egui styling is not an acceptable 72- The GUI is themed deliberately. Default egui styling is not an acceptable
66 look; keep a style module and a reference screen. 73 look; keep a style module and a reference screen. The palette is derived
74 from the terminal's ANSI colours, and felt green is only ever the roulette
75 table.
67 76
68## Client TUI 77## Client TUI
69- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`, 78- `render_lines()` and `render()` must agree. If `render()` uses `\r\n`,
diff --git a/Cargo.toml b/Cargo.toml
index 9e650f2..ed9d4f3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,9 @@
1[workspace]
2members = ["gui"]
3
1[package] 4[package]
2name = "zagan-bet" 5name = "zagan-bet"
3version = "4.0.7" 6version = "4.1.0"
4edition = "2024" 7edition = "2024"
5license = "GPL-3.0-or-later" 8license = "GPL-3.0-or-later"
6 9
@@ -33,3 +36,13 @@ serde_json = "1.0"
33uuid = { version = "1.0", features = ["v4"] } 36uuid = { version = "1.0", features = ["v4"] }
34native-tls = { version = "0.2", optional = true } 37native-tls = { version = "0.2", optional = true }
35rustls = { version = "0.21", features = ["dangerous_configuration"] } 38rustls = { version = "0.21", features = ["dangerous_configuration"] }
39
40# The GUI ships as one self-contained binary; the TUI and server keep the
41# default release profile. Build with:
42# cargo build -p zagan-bet-gui --profile gui-release
43[profile.gui-release]
44inherits = "release"
45opt-level = "z"
46lto = true
47codegen-units = 1
48strip = true
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md
index 2789b81..8c3db0a 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -69,16 +69,43 @@ crash board from its local clock.
69 69
70## Phase 3 — `gui/` skeleton and the style spike 70## Phase 3 — `gui/` skeleton and the style spike
71 71
72- Add the `gui/` workspace member (eframe), no game code yet. 72**Code complete in v4.1.0; awaiting the screenshot sign-off.**
73- Theme module with the agreed tokens, two fonts, spacing scale. 73
74- One reference screen: the roulette felt with chips, cursor, and a spinning 74- `gui/` is a workspace member (`zagan-bet-gui`). It depends on the core with
75 number, plus the persistent header (connection, balance, account). 75 `default-features = false`, so neither egui nor native-tls enters the TUI or
76- Verify the Windows cross-compile with a hello-world before going further. 76 server build graph. The build uses the workspace's `gui-release` profile
77 77 (opt-level `z`, LTO, stripped).
78**Gate:** your approval of the screenshot at 1080p; binary ≤ 40 MB, idle 78- `gui/src/theme.rs` holds the tokens: the terminal-derived dark palette, felt
79≤ 150 MB, cold start ≤ 1 s, 60 fps on integrated graphics. 79 green kept for the table, a 4/8/12/16/24 spacing scale, radii, and the type
80 scale. Two embedded fonts: Open Sans (OFL) for text, Hack (MIT) for numbers,
81 chips and labels. Licences sit next to the files in `gui/assets/fonts/`.
82- `gui/src/roulette.rs` draws the felt, chips, the fine lattice cursor and the
83 wheel from a `RouletteView`; it owns the geometry, not the rules.
84- `gui/src/app.rs` composes the persistent header (connection, account,
85 balance) with the table and the betting sidebar. The demo state animates a
86 2.4 s wheel spin and highlights the winning pocket and cell.
87- `gui/tests/layout.rs` covers the pure maths (money grouping, wheel angle,
88 table aspect, bet names). UI wiring tests arrive with `egui_kittest` in
89 Phase 4.
90
91Measured on Linux at 1080p with llvmpipe software GL:
92
93| Check | Result |
94| --- | --- |
95| Linux binary | 6.0 MB |
96| Windows x86_64 binary | 4.7 MB |
97| Cold start to window | 52 ms |
98| Idle RSS | 137 MB |
99| Idle CPU | 0% |
100
101The approved reference lives at `docs/screenshots/gui-roulette-1080p.png`, with
102the result state at `docs/screenshots/gui-roulette-result-1080p.png`.
103
104**Gate:** the screenshot needs your approval. Frame rate on integrated graphics
105still needs a run on real hardware.
80 106
81## Phase 4 — GUI wiring 107## Phase 4 — GUI wiring
108## Phase 4 — GUI wiring
82 109
83- Network thread with channels, request deadlines in the shared adapter. 110- Network thread with channels, request deadlines in the shared adapter.
84- Trust modal, version-mismatch screen, login/register, lobby, history panel. 111- Trust modal, version-mismatch screen, login/register, lobby, history panel.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 23e3a19..f42b84a 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -3,7 +3,7 @@
3## Automated suite 3## Automated suite
4 4
5```sh 5```sh
6cargo test 6cargo test --workspace
7``` 7```
8 8
9The suite is deterministic and headless: no network, no PTY, no sleeps, no 9The suite is deterministic and headless: no network, no PTY, no sleeps, no
@@ -46,10 +46,14 @@ or a vendored OpenSSL.
46| `tests/slots.rs` | Server slots payout rules and overflow handling. | 46| `tests/slots.rs` | Server slots payout rules and overflow handling. |
47| `tests/storage.rs` | Client/server SQLite storage helpers. | 47| `tests/storage.rs` | Client/server SQLite storage helpers. |
48| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. | 48| `tests/tofu.rs` | SPKI fingerprint and TOFU decisions. |
49| `gui/tests/layout.rs` | GUI presentation maths: money grouping, wheel angle, table aspect, bet names. |
49 50
50`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them 51`tests/tofu.rs` and `tests/server_setup.rs` shell out to `openssl`, so run them
51on a machine where the `openssl` CLI is available. 52on a machine where the `openssl` CLI is available.
52 53
54The GUI has its own integration tests because the root package cannot depend on
55the GUI (`gui/tests/`); `cargo test --workspace` runs both halves.
56
53## Development resets 57## Development resets
54 58
55The server creates `server.db` in its working directory. The client keeps 59The server creates `server.db` in its working directory. The client keeps
@@ -67,6 +71,21 @@ rm -rf ~/.local/share/zagan-bet
67The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` 71The audit schema is not auto-migrated from the pre-2.0 slots-only `plays`
68table. Starting against an old database will fail until it is removed. 72table. Starting against an old database will fail until it is removed.
69 73
74## Building the GUI
75
76The GUI is a workspace member, so it never enters the TUI or server build graph:
77
78```sh
79cargo build -p zagan-bet-gui # debug
80cargo build -p zagan-bet-gui --profile gui-release # release
81cargo build -p zagan-bet-gui --target x86_64-pc-windows-gnu \
82 --profile gui-release # Windows
83```
84
85`gui-release` is declared at the workspace root; a profile inside a member's
86manifest would be ignored. Fonts are embedded, so there is no runtime asset
87directory to ship.
88
70## Manual TUI checklist 89## Manual TUI checklist
71 90
72There is no CI. Run this on Linux and on a Windows VM before a release. 91There is no CI. Run this on Linux and on a Windows VM before a release.
@@ -97,6 +116,31 @@ Use an 80-column terminal because several output lines are width-sensitive.
97 under a single fingerprint, because the certificate key is the same. 116 under a single fingerprint, because the certificate key is the same.
9811. Windows: repeat 2-10 and confirm keys are not duplicated or printed twice. 11711. Windows: repeat 2-10 and confirm keys are not duplicated or printed twice.
99 118
119## Manual GUI checklist
120
121The GUI is a style spike until Phase 4: the table and wheel are painted from a
122demo `RouletteView`, and no network connection is made. Run it on Linux and on
123Windows 10 before approving the look.
124
1251. `cargo run -p zagan-bet-gui` at 1440x900, then at 1920x1080. The header
126 keeps its right margin at both sizes and shows ONLINE, host, ping, account
127 and balance; the table is centred and the sidebar is full height.
1282. The felt shows 0, the 3x12 numbers, dozens, columns and the six outside
129 cells. Chips sit on their targets: a straight in the cell, a corner on the
130 lattice crossing, an outside bet in its box.
1313. Click a pocket: a chip of the selected denomination appears and the slip
132 gains a row. Chips are 10/25/50/100; the selected one has a cyan ring.
1334. Arrow keys move the cursor. Over the numbers the cyan marker sits on the
134 cell or lattice point and the covered cells tint; leaving the table walks
135 dozens, columns and outside.
1365. Space or SPIN animates the wheel for about 2.4 s and stops with the winning
137 pocket under the pointer. The winning cell gets a gold outline, RESULT names
138 the number and colour, and LAST gains a dot. SPIN is disabled during the
139 spin.
1406. Clear bets empties the slip and the total. Resize to 1100x720: the sidebar
141 scrolls, nothing overlaps the header, and the table keeps its aspect ratio.
1427. Compare the 1080p window against the approved reference screenshot.
143
100## Manual roulette checklist 144## Manual roulette checklist
101 145
1021. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, 1461. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns,
diff --git a/docs/screenshots/gui-roulette-1080p.png b/docs/screenshots/gui-roulette-1080p.png
new file mode 100644
index 0000000..b6c9b17
--- /dev/null
+++ b/docs/screenshots/gui-roulette-1080p.png
Binary files differ
diff --git a/docs/screenshots/gui-roulette-result-1080p.png b/docs/screenshots/gui-roulette-result-1080p.png
new file mode 100644
index 0000000..0f37133
--- /dev/null
+++ b/docs/screenshots/gui-roulette-result-1080p.png
Binary files differ
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]
2name = "zagan-bet-gui"
3version = "4.1.0"
4edition = "2024"
5license = "GPL-3.0-or-later"
6description = "Desktop frontend for zagan-bet"
7
8[[bin]]
9name = "zagan-bet-gui"
10path = "src/main.rs"
11
12[dependencies]
13# No default features: the GUI never links the server's native-tls.
14zagan-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.
17eframe = { 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 @@
1The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
2
3The work in the DejaVu project was committed to the public domain.
4
5Bitstream 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
9Copyright (c) 2018 Source Foundry Authors
10
11Permission is hereby granted, free of charge, to any person obtaining a copy
12of this software and associated documentation files (the "Software"), to deal
13in the Software without restriction, including without limitation the rights
14to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15copies of the Software, and to permit persons to whom the Software is
16furnished to do so, subject to the following conditions:
17
18The above copyright notice and this permission notice shall be included in all
19copies or substantial portions of the Software.
20
21THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27SOFTWARE.
28
29### BITSTREAM VERA LICENSE
30
31Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
32
33Permission 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
35The 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
37The 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
39This 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
41The 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
43THE 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
45Except 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 @@
1Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
2
3-----------------------------------------------------------
4SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
5-----------------------------------------------------------
6
7PREAMBLE
8The goals of the Open Font License (OFL) are to stimulate worldwide
9development of collaborative font projects, to support the font
10creation efforts of academic and linguistic communities, and to
11provide a free and open framework in which fonts may be shared and
12improved in partnership with others.
13
14The OFL allows the licensed fonts to be used, studied, modified and
15redistributed freely as long as they are not sold by themselves. The
16fonts, including any derivative works, can be bundled, embedded,
17redistributed and/or sold with any software provided that any reserved
18names are not used by derivative works. The fonts and derivatives,
19however, cannot be released under any other type of license. The
20requirement for fonts to remain under this license does not apply to
21any document created using the fonts or their derivatives.
22
23DEFINITIONS
24"Font Software" refers to the set of files released by the Copyright
25Holder(s) under this license and clearly marked as such. This may
26include source files, build scripts and documentation.
27
28"Reserved Font Name" refers to any names specified as such after the
29copyright statement(s).
30
31"Original Version" refers to the collection of Font Software
32components as distributed by the Copyright Holder(s).
33
34"Modified Version" refers to any derivative made by adding to,
35deleting, or substituting -- in part or in whole -- any of the
36components of the Original Version, by changing formats or by porting
37the Font Software to a new environment.
38
39"Author" refers to any designer, engineer, programmer, technical
40writer or other person who contributed to the Font Software.
41
42PERMISSION & CONDITIONS
43Permission is hereby granted, free of charge, to any person obtaining
44a copy of the Font Software, to use, study, copy, merge, embed,
45modify, redistribute, and sell modified and unmodified copies of the
46Font Software, subject to the following conditions:
47
481) Neither the Font Software nor any of its individual components, in
49Original or Modified Versions, may be sold by itself.
50
512) Original or Modified Versions of the Font Software may be bundled,
52redistributed and/or sold with any software, provided that each copy
53contains the above copyright notice and this license. These can be
54included either as stand-alone text files, human-readable headers or
55in the appropriate machine-readable metadata fields within text or
56binary files as long as those fields can be easily viewed by the user.
57
583) No Modified Version of the Font Software may use the Reserved Font
59Name(s) unless explicit written permission is granted by the
60corresponding Copyright Holder. This restriction only applies to the
61primary font name as presented to the users.
62
634) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
64Software shall not be used to promote, endorse or advertise any
65Modified Version, except to acknowledge the contribution(s) of the
66Copyright Holder(s) and the Author(s) or with their explicit written
67permission.
68
695) The Font Software, modified or unmodified, in part or in whole,
70must be distributed entirely under this license, and must not be
71distributed under any other license. The requirement for fonts to
72remain under this license does not apply to any document created using
73the Font Software.
74
75TERMINATION
76This license becomes null and void if any of the above conditions are
77not met.
78
79DISCLAIMER
80THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
81EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
82MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
83OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
84COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
85INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
86DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
87FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
88OTHER 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
7use std::f32::consts::TAU;
8
9use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2,
12};
13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION;
17
18use crate::{roulette, theme};
19
20const HEADER_HEIGHT: f32 = 64.0;
21const SIDEBAR_WIDTH: f32 = 336.0;
22const SPIN_SECONDS: f64 = 2.4;
23/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black.
24const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11];
25const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100];
26
27pub struct ZaganApp {
28 demo: Demo,
29}
30
31impl ZaganApp {
32 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
33 theme::install(&cc.egui_ctx);
34 Self { demo: Demo::new() }
35 }
36}
37
38impl 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
79fn 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
125fn 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
135fn 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.
165fn 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
192fn 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
283fn 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
324fn 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
341fn 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
357fn 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
369fn 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
436struct Spin {
437 started: f64,
438 from: f32,
439 delta: f32,
440 target: u8,
441}
442
443struct 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
460impl 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
7pub mod app;
8pub mod roulette;
9pub 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
7use zagan_bet_gui::app::ZaganApp;
8
9fn 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
7use std::f32::consts::{FRAC_PI_2, TAU};
8
9use eframe::egui::{
10 Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, Vec2,
11 pos2, vec2,
12};
13use zagan_bet::client::games::roulette::Cursor;
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Outside, WHEEL, color as wheel_color, is_red};
16
17use crate::theme;
18
19const COLUMNS: usize = 12;
20const ROWS: usize = 3;
21const OUTSIDE_ORDER: [Outside; 6] = [
22 Outside::Low,
23 Outside::Even,
24 Outside::Red,
25 Outside::Black,
26 Outside::Odd,
27 Outside::High,
28];
29
30const DOZEN_ROW: f32 = 0.72;
31const COLUMN_ROW: f32 = 0.55;
32const OUTSIDE_ROW: f32 = 0.62;
33const HEIGHT_UNITS: f32 = ROWS as f32 + DOZEN_ROW + COLUMN_ROW + OUTSIDE_ROW;
34const BOARD_PAD: f32 = 6.0;
35
36/// Largest table that fits in `available`, including its felt border.
37pub 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.
46pub 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
103struct Felt {
104 cell: f32,
105 origin: Pos2,
106}
107
108impl 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
337fn 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.
347pub 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.
370pub 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
379fn 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.
388pub 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.
396pub 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".
479pub 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
497fn 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.
508pub 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
10use std::sync::Arc;
11
12use eframe::egui::style::WidgetVisuals;
13use 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.
23pub const BG: Color32 = Color32::from_rgb(0x0a, 0x0e, 0x12);
24/// Cards, header, sidebar.
25pub const SURFACE: Color32 = Color32::from_rgb(0x11, 0x17, 0x1d);
26/// Inputs, pills, raised rows.
27pub const SURFACE_RAISED: Color32 = Color32::from_rgb(0x17, 0x1f, 0x27);
28/// Hover state.
29pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x1d, 0x27, 0x31);
30/// Hairlines and table borders.
31pub const LINE: Color32 = Color32::from_rgb(0x24, 0x2e, 0x38);
32/// Primary text.
33pub const TEXT: Color32 = Color32::from_rgb(0xd9, 0xe2, 0xea);
34/// Labels and secondary text.
35pub const TEXT_DIM: Color32 = Color32::from_rgb(0x8a, 0x98, 0xa6);
36/// Disabled text, captions.
37pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x5b, 0x67, 0x73);
38/// Focus, cursor, selection.
39pub const ACCENT: Color32 = Color32::from_rgb(0x4f, 0xb8, 0xc9);
40/// Wins, "online", the spin button.
41pub const WIN: Color32 = Color32::from_rgb(0x4e, 0xc2, 0x7b);
42/// Losses and errors.
43pub const LOSS: Color32 = Color32::from_rgb(0xe0, 0x5a, 0x5a);
44/// Chips and pending bets.
45pub const GOLD: Color32 = Color32::from_rgb(0xe0, 0xb6, 0x4a);
46
47/// Roulette felt. Never used outside the table.
48pub const FELT: Color32 = Color32::from_rgb(0x0d, 0x5c, 0x39);
49pub const FELT_DARK: Color32 = Color32::from_rgb(0x09, 0x47, 0x2c);
50pub const FELT_LINE: Color32 = Color32::from_rgb(0x1f, 0x7d, 0x52);
51pub const FELT_TEXT: Color32 = Color32::from_rgb(0xec, 0xf7, 0xf0);
52pub const ROULETTE_RED: Color32 = Color32::from_rgb(0xb0, 0x31, 0x3a);
53pub const ROULETTE_BLACK: Color32 = Color32::from_rgb(0x15, 0x1b, 0x21);
54pub const ROULETTE_ZERO: Color32 = Color32::from_rgb(0x10, 0x6b, 0x45);
55
56// ---------------------------------------------------------------------------
57// Spacing, radius, type scale
58// ---------------------------------------------------------------------------
59
60pub const SPACE_1: f32 = 4.0;
61pub const SPACE_2: f32 = 8.0;
62pub const SPACE_3: f32 = 12.0;
63pub const SPACE_4: f32 = 16.0;
64pub const SPACE_5: f32 = 24.0;
65
66pub const RADIUS_SM: CornerRadius = CornerRadius::same(4);
67pub const RADIUS_MD: CornerRadius = CornerRadius::same(8);
68pub const RADIUS_LG: CornerRadius = CornerRadius::same(12);
69
70pub const FONT_BODY: f32 = 14.0;
71pub const FONT_SMALL: f32 = 12.0;
72pub const FONT_LABEL: f32 = 11.0;
73pub const FONT_TITLE: f32 = 24.0;
74pub 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`.
81pub const FONT_SANS_BYTES: &[u8] = include_bytes!("../assets/fonts/OpenSans-Regular.ttf");
82/// Hack, MIT. See `assets/fonts/LICENSE-Hack.md`.
83pub const FONT_MONO_BYTES: &[u8] = include_bytes!("../assets/fonts/Hack-Regular.ttf");
84
85pub fn sans(size: f32) -> FontId {
86 FontId::new(size, FontFamily::Proportional)
87}
88
89pub fn mono(size: f32) -> FontId {
90 FontId::new(size, FontFamily::Monospace)
91}
92
93/// A small uppercase label, the terminal's dim section headers.
94pub 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".
101pub 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"`.
119pub 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.
129pub 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
169fn 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
185fn 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
5use std::f32::consts::TAU;
6
7use zagan_bet::roulette::{BetTarget, Outside};
8use zagan_bet_gui::roulette::{number_under, size_for, target_name};
9use zagan_bet_gui::theme::{group_digits, money};
10
11#[test]
12fn 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]
22fn 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]
41fn 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]
53fn 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}
diff --git a/tests/version.rs b/tests/version.rs
index ef58601..af244db 100644
--- a/tests/version.rs
+++ b/tests/version.rs
@@ -69,3 +69,30 @@ fn the_hello_reply_round_trips() {
69 assert!(parse_hello("PONG").is_none()); 69 assert!(parse_hello("PONG").is_none());
70 assert!(parse_hello("HELLO").is_none()); 70 assert!(parse_hello("HELLO").is_none());
71} 71}
72
73/// The GUI is a client, so it must ship in lockstep with the TUI and server.
74/// Keep `gui/Cargo.toml` at the same version as the root package.
75#[test]
76fn the_gui_member_uses_the_same_version() {
77 let gui_manifest = include_str!("../gui/Cargo.toml");
78 let gui_version = gui_manifest
79 .lines()
80 .find_map(|line| line.trim().strip_prefix("version = "))
81 .expect("gui/Cargo.toml declares a version")
82 .trim_matches('"');
83 assert_eq!(gui_version, VERSION);
84}
85
86/// egui's dependency tree must never enter the TUI or server build graph.
87/// The GUI is a workspace member that depends on this package, never the
88/// other way round.
89#[test]
90fn the_root_package_does_not_depend_on_the_gui() {
91 let manifest = include_str!("../Cargo.toml");
92 for forbidden in ["eframe", "egui"] {
93 assert!(
94 !manifest.contains(forbidden),
95 "the root package must not depend on {forbidden}"
96 );
97 }
98}