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 | |
| 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.
| -rw-r--r-- | AGENTS.md | 13 | ||||
| -rw-r--r-- | Cargo.toml | 15 | ||||
| -rw-r--r-- | docs/GUI-PLAN.md | 43 | ||||
| -rw-r--r-- | docs/TESTING.md | 46 | ||||
| -rw-r--r-- | docs/screenshots/gui-roulette-1080p.png | bin | 0 -> 126927 bytes | |||
| -rw-r--r-- | docs/screenshots/gui-roulette-result-1080p.png | bin | 0 -> 127523 bytes | |||
| -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 | ||||
| -rw-r--r-- | tests/version.rs | 27 |
18 files changed, 1722 insertions, 12 deletions
| @@ -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`, |
| @@ -1,6 +1,9 @@ | |||
| 1 | [workspace] | ||
| 2 | members = ["gui"] | ||
| 3 | |||
| 1 | [package] | 4 | [package] |
| 2 | name = "zagan-bet" | 5 | name = "zagan-bet" |
| 3 | version = "4.0.7" | 6 | version = "4.1.0" |
| 4 | edition = "2024" | 7 | edition = "2024" |
| 5 | license = "GPL-3.0-or-later" | 8 | license = "GPL-3.0-or-later" |
| 6 | 9 | ||
| @@ -33,3 +36,13 @@ serde_json = "1.0" | |||
| 33 | uuid = { version = "1.0", features = ["v4"] } | 36 | uuid = { version = "1.0", features = ["v4"] } |
| 34 | native-tls = { version = "0.2", optional = true } | 37 | native-tls = { version = "0.2", optional = true } |
| 35 | rustls = { version = "0.21", features = ["dangerous_configuration"] } | 38 | rustls = { 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] | ||
| 44 | inherits = "release" | ||
| 45 | opt-level = "z" | ||
| 46 | lto = true | ||
| 47 | codegen-units = 1 | ||
| 48 | strip = 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 | |||
| 91 | Measured 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 | |||
| 101 | The approved reference lives at `docs/screenshots/gui-roulette-1080p.png`, with | ||
| 102 | the result state at `docs/screenshots/gui-roulette-result-1080p.png`. | ||
| 103 | |||
| 104 | **Gate:** the screenshot needs your approval. Frame rate on integrated graphics | ||
| 105 | still 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 |
| 6 | cargo test | 6 | cargo test --workspace |
| 7 | ``` | 7 | ``` |
| 8 | 8 | ||
| 9 | The suite is deterministic and headless: no network, no PTY, no sleeps, no | 9 | The 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 |
| 51 | on a machine where the `openssl` CLI is available. | 52 | on a machine where the `openssl` CLI is available. |
| 52 | 53 | ||
| 54 | The GUI has its own integration tests because the root package cannot depend on | ||
| 55 | the GUI (`gui/tests/`); `cargo test --workspace` runs both halves. | ||
| 56 | |||
| 53 | ## Development resets | 57 | ## Development resets |
| 54 | 58 | ||
| 55 | The server creates `server.db` in its working directory. The client keeps | 59 | The server creates `server.db` in its working directory. The client keeps |
| @@ -67,6 +71,21 @@ rm -rf ~/.local/share/zagan-bet | |||
| 67 | The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` | 71 | The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` |
| 68 | table. Starting against an old database will fail until it is removed. | 72 | table. Starting against an old database will fail until it is removed. |
| 69 | 73 | ||
| 74 | ## Building the GUI | ||
| 75 | |||
| 76 | The GUI is a workspace member, so it never enters the TUI or server build graph: | ||
| 77 | |||
| 78 | ```sh | ||
| 79 | cargo build -p zagan-bet-gui # debug | ||
| 80 | cargo build -p zagan-bet-gui --profile gui-release # release | ||
| 81 | cargo 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 | ||
| 86 | manifest would be ignored. Fonts are embedded, so there is no runtime asset | ||
| 87 | directory to ship. | ||
| 88 | |||
| 70 | ## Manual TUI checklist | 89 | ## Manual TUI checklist |
| 71 | 90 | ||
| 72 | There is no CI. Run this on Linux and on a Windows VM before a release. | 91 | There 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. |
| 98 | 11. Windows: repeat 2-10 and confirm keys are not duplicated or printed twice. | 117 | 11. Windows: repeat 2-10 and confirm keys are not duplicated or printed twice. |
| 99 | 118 | ||
| 119 | ## Manual GUI checklist | ||
| 120 | |||
| 121 | The GUI is a style spike until Phase 4: the table and wheel are painted from a | ||
| 122 | demo `RouletteView`, and no network connection is made. Run it on Linux and on | ||
| 123 | Windows 10 before approving the look. | ||
| 124 | |||
| 125 | 1. `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. | ||
| 128 | 2. 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. | ||
| 131 | 3. 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. | ||
| 133 | 4. 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. | ||
| 136 | 5. 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. | ||
| 140 | 6. 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. | ||
| 142 | 7. Compare the 1080p window against the approved reference screenshot. | ||
| 143 | |||
| 100 | ## Manual roulette checklist | 144 | ## Manual roulette checklist |
| 101 | 145 | ||
| 102 | 1. `play roulette` draws the full felt: 0, the 3x12 numbers, dozens, columns, | 146 | 1. `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] | ||
| 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 | } | ||
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] | ||
| 76 | fn 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] | ||
| 90 | fn 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 | } | ||
