diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 21:09:56 +0100 |
| commit | bf4cf78ab26896faf457273e37d30fc96381c4a4 (patch) | |
| tree | 71d67c300b6be191016a988ed5338e15c8b1bdd3 /gui/src/widgets.rs | |
| parent | v4.1.1: pointer-first betting and visible payouts in the GUI (diff) | |
| download | zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.tar.gz zagan-bet-bf4cf78ab26896faf457273e37d30fc96381c4a4.zip | |
v4.2.0: wire the GUI to the core, transport and all four games
Phase 4. The egui frontend now shares ClientCore and the client database with the terminal: one background worker owns TLS, trust answers go through a channel, and a 10 s request deadline mirrors the terminal socket timeout. First contact, a changed key and a version mismatch are explicit screens; login can remember the credential and auto-login signs in before the login form appears.
Adds the lobby with an update notice, a full-width scrollable history table, and the four games painted from View: slots with reel animation and win lines that stop at the last matching reel, roulette with the hover lattice and wheel spin, blackjack with vector-drawn suits, and a smooth crash curve. Sidebars pin their action block; leaving a live hand or round asks before forfeiting.
The header balance holds the staked amount while the reels or wheel turn and only shows the payout when they stop. Refused spins cancel the animation. Live crash multipliers keep full precision for the chart; the terminal still displays two decimals.
Verified live against a 4.2.0 server: register, login, auto-login, trust, all four games settling, history (50 rows), logout, disconnect, forfeit confirmation, and the version-mismatch screen. Release: 8.3 MB Linux, 6.8 MB Windows, 61 ms to window, 131 MB idle RSS.
Diffstat (limited to '')
| -rw-r--r-- | gui/src/widgets.rs | 255 |
1 files changed, 255 insertions, 0 deletions
diff --git a/gui/src/widgets.rs b/gui/src/widgets.rs new file mode 100644 index 0000000..f7d1512 --- /dev/null +++ b/gui/src/widgets.rs | |||
| @@ -0,0 +1,255 @@ | |||
| 1 | //! Small shared controls. Every button and panel comes from here so the | ||
| 2 | //! screens cannot drift apart. | ||
| 3 | |||
| 4 | use eframe::egui::{ | ||
| 5 | Align, Button, Color32, Frame, Layout, Margin, RichText, Sense, Stroke, Ui, Vec2, vec2, | ||
| 6 | }; | ||
| 7 | |||
| 8 | use crate::theme; | ||
| 9 | |||
| 10 | pub fn panel_frame() -> Frame { | ||
| 11 | Frame::new() | ||
| 12 | .fill(theme::SURFACE) | ||
| 13 | .corner_radius(theme::RADIUS_LG) | ||
| 14 | .inner_margin(Margin::same(theme::SPACE_4 as i8)) | ||
| 15 | } | ||
| 16 | |||
| 17 | pub fn inset_frame() -> Frame { | ||
| 18 | Frame::new() | ||
| 19 | .fill(theme::SURFACE_RAISED) | ||
| 20 | .corner_radius(theme::RADIUS_MD) | ||
| 21 | .inner_margin(Margin::same(theme::SPACE_3 as i8)) | ||
| 22 | } | ||
| 23 | |||
| 24 | /// The one green action on a screen. | ||
| 25 | pub fn primary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { | ||
| 26 | let fill = if enabled { | ||
| 27 | theme::WIN | ||
| 28 | } else { | ||
| 29 | theme::WIN.gamma_multiply(0.35) | ||
| 30 | }; | ||
| 31 | let response = ui.add_enabled( | ||
| 32 | enabled, | ||
| 33 | Button::new(RichText::new(text).font(theme::mono(13.0)).color(theme::BG)) | ||
| 34 | .fill(fill) | ||
| 35 | .corner_radius(theme::RADIUS_MD) | ||
| 36 | .min_size(vec2(width, height)), | ||
| 37 | ); | ||
| 38 | if response.hovered() { | ||
| 39 | ui.painter().rect_filled( | ||
| 40 | response.rect, | ||
| 41 | theme::RADIUS_MD, | ||
| 42 | Color32::WHITE.gamma_multiply(0.10), | ||
| 43 | ); | ||
| 44 | ui.ctx() | ||
| 45 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 46 | } | ||
| 47 | response.clicked() | ||
| 48 | } | ||
| 49 | |||
| 50 | /// A bordered action that is not the main verb. | ||
| 51 | pub fn secondary(ui: &mut Ui, text: &str, enabled: bool, width: f32, height: f32) -> bool { | ||
| 52 | let stroke = if enabled { | ||
| 53 | theme::LINE | ||
| 54 | } else { | ||
| 55 | theme::LINE.gamma_multiply(0.5) | ||
| 56 | }; | ||
| 57 | let response = ui.add_enabled( | ||
| 58 | enabled, | ||
| 59 | Button::new( | ||
| 60 | RichText::new(text) | ||
| 61 | .font(theme::mono(12.0)) | ||
| 62 | .color(theme::TEXT_DIM), | ||
| 63 | ) | ||
| 64 | .fill(theme::SURFACE_RAISED) | ||
| 65 | .stroke(Stroke::new(1.0, stroke)) | ||
| 66 | .corner_radius(theme::RADIUS_MD) | ||
| 67 | .min_size(vec2(width, height)), | ||
| 68 | ); | ||
| 69 | if response.hovered() { | ||
| 70 | ui.painter().rect_filled( | ||
| 71 | response.rect, | ||
| 72 | theme::RADIUS_MD, | ||
| 73 | Color32::WHITE.gamma_multiply(0.06), | ||
| 74 | ); | ||
| 75 | ui.ctx() | ||
| 76 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 77 | } | ||
| 78 | response.clicked() | ||
| 79 | } | ||
| 80 | |||
| 81 | /// A quiet text action, for "back" and "forget". | ||
| 82 | pub fn ghost(ui: &mut Ui, text: &str) -> bool { | ||
| 83 | let response = ui.add( | ||
| 84 | Button::new( | ||
| 85 | RichText::new(text) | ||
| 86 | .font(theme::sans(12.0)) | ||
| 87 | .color(theme::TEXT_DIM), | ||
| 88 | ) | ||
| 89 | .frame(false), | ||
| 90 | ); | ||
| 91 | if response.hovered() { | ||
| 92 | ui.painter().rect_filled( | ||
| 93 | response.rect.expand(2.0), | ||
| 94 | theme::RADIUS_SM, | ||
| 95 | Color32::WHITE.gamma_multiply(0.05), | ||
| 96 | ); | ||
| 97 | ui.ctx() | ||
| 98 | .set_cursor_icon(eframe::egui::CursorIcon::PointingHand); | ||
| 99 | } | ||
| 100 | response.clicked() | ||
| 101 | } | ||
| 102 | |||
| 103 | /// Split a game sidebar into a flexible content area and a pinned action bar, | ||
| 104 | /// so the primary action never moves as the bet slip grows. | ||
| 105 | pub fn sidebar_layout(ui: &Ui, actions_height: f32) -> (eframe::egui::Rect, eframe::egui::Rect) { | ||
| 106 | let full = ui.max_rect(); | ||
| 107 | let actions_top = (full.bottom() - actions_height).max(full.top()); | ||
| 108 | let actions = eframe::egui::Rect::from_min_max( | ||
| 109 | eframe::egui::pos2(full.left(), actions_top), | ||
| 110 | full.right_bottom(), | ||
| 111 | ); | ||
| 112 | let content = eframe::egui::Rect::from_min_max( | ||
| 113 | full.min, | ||
| 114 | eframe::egui::pos2(full.right(), (actions_top - theme::SPACE_3).max(full.top())), | ||
| 115 | ); | ||
| 116 | (content, actions) | ||
| 117 | } | ||
| 118 | |||
| 119 | pub fn square_button(ui: &mut Ui, text: &str, enabled: bool) -> bool { | ||
| 120 | ui.add_enabled( | ||
| 121 | enabled, | ||
| 122 | Button::new( | ||
| 123 | RichText::new(text) | ||
| 124 | .font(theme::mono(16.0)) | ||
| 125 | .color(theme::TEXT), | ||
| 126 | ) | ||
| 127 | .fill(theme::SURFACE_RAISED) | ||
| 128 | .stroke(Stroke::new(1.0, theme::LINE)) | ||
| 129 | .corner_radius(theme::RADIUS_MD) | ||
| 130 | .min_size(Vec2::splat(34.0)), | ||
| 131 | ) | ||
| 132 | .clicked() | ||
| 133 | } | ||
| 134 | |||
| 135 | /// `- [ 50 Kr ] +`; returns the change in the bet. | ||
| 136 | pub fn bet_control(ui: &mut Ui, bet: u64, enabled: bool) -> Option<i64> { | ||
| 137 | let mut delta = None; | ||
| 138 | ui.horizontal(|ui| { | ||
| 139 | if square_button(ui, "−", enabled) { | ||
| 140 | delta = Some(-10); | ||
| 141 | } | ||
| 142 | Frame::new() | ||
| 143 | .fill(theme::BG) | ||
| 144 | .corner_radius(theme::RADIUS_SM) | ||
| 145 | .inner_margin(Margin::symmetric( | ||
| 146 | theme::SPACE_3 as i8, | ||
| 147 | theme::SPACE_2 as i8, | ||
| 148 | )) | ||
| 149 | .show(ui, |ui| { | ||
| 150 | ui.label( | ||
| 151 | RichText::new(theme::money(bet as i64)) | ||
| 152 | .font(theme::mono(15.0)) | ||
| 153 | .color(theme::TEXT), | ||
| 154 | ); | ||
| 155 | }); | ||
| 156 | if square_button(ui, "+", enabled) { | ||
| 157 | delta = Some(10); | ||
| 158 | } | ||
| 159 | }); | ||
| 160 | delta | ||
| 161 | } | ||
| 162 | |||
| 163 | /// Label on the left, value on the right, for slips and summaries. | ||
| 164 | pub fn row(ui: &mut Ui, label: &str, value: &str, color: Color32) { | ||
| 165 | ui.horizontal(|ui| { | ||
| 166 | ui.label( | ||
| 167 | RichText::new(label) | ||
| 168 | .font(theme::sans(13.0)) | ||
| 169 | .color(theme::TEXT_DIM), | ||
| 170 | ); | ||
| 171 | ui.with_layout(Layout::right_to_left(Align::Center), |ui| { | ||
| 172 | ui.label(RichText::new(value).font(theme::mono(13.0)).color(color)); | ||
| 173 | }); | ||
| 174 | }); | ||
| 175 | } | ||
| 176 | |||
| 177 | pub fn note(ui: &mut Ui, text: &str) { | ||
| 178 | ui.label( | ||
| 179 | RichText::new(text) | ||
| 180 | .font(theme::sans(12.0)) | ||
| 181 | .color(theme::TEXT_DIM), | ||
| 182 | ); | ||
| 183 | } | ||
| 184 | |||
| 185 | pub fn error(ui: &mut Ui, text: &str) { | ||
| 186 | Frame::new() | ||
| 187 | .fill(theme::LOSS.gamma_multiply(0.12)) | ||
| 188 | .corner_radius(theme::RADIUS_SM) | ||
| 189 | .inner_margin(Margin::symmetric( | ||
| 190 | theme::SPACE_3 as i8, | ||
| 191 | theme::SPACE_2 as i8, | ||
| 192 | )) | ||
| 193 | .show(ui, |ui| { | ||
| 194 | ui.label( | ||
| 195 | RichText::new(text) | ||
| 196 | .font(theme::sans(12.0)) | ||
| 197 | .color(theme::LOSS), | ||
| 198 | ); | ||
| 199 | }); | ||
| 200 | } | ||
| 201 | |||
| 202 | /// A monospaced label that can be selected (fingerprints, addresses, URLs). | ||
| 203 | pub fn mono_text(ui: &mut Ui, text: &str, color: Color32) { | ||
| 204 | ui.add( | ||
| 205 | eframe::egui::Label::new( | ||
| 206 | RichText::new(text) | ||
| 207 | .font(theme::mono(theme::FONT_SMALL)) | ||
| 208 | .color(color), | ||
| 209 | ) | ||
| 210 | .selectable(true), | ||
| 211 | ); | ||
| 212 | } | ||
| 213 | |||
| 214 | pub fn chip_button(ui: &mut Ui, amount: u64, selected: bool, enabled: bool) -> bool { | ||
| 215 | let sense = if enabled { | ||
| 216 | Sense::click() | ||
| 217 | } else { | ||
| 218 | Sense::hover() | ||
| 219 | }; | ||
| 220 | let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), sense); | ||
| 221 | let center = rect.center(); | ||
| 222 | let radius = 20.0; | ||
| 223 | let painter = ui.painter(); | ||
| 224 | if selected || response.hovered() { | ||
| 225 | painter.circle_stroke( | ||
| 226 | center, | ||
| 227 | radius + 3.0, | ||
| 228 | Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT), | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | if enabled { | ||
| 232 | super::roulette::paint_chip(painter, center, radius, amount); | ||
| 233 | } else { | ||
| 234 | super::roulette::paint_chip_preview(painter, center, radius, amount); | ||
| 235 | } | ||
| 236 | response.clicked() | ||
| 237 | } | ||
| 238 | |||
| 239 | pub fn chip_row<const N: usize>( | ||
| 240 | ui: &mut Ui, | ||
| 241 | amounts: [u64; N], | ||
| 242 | selected: u64, | ||
| 243 | enabled: bool, | ||
| 244 | ) -> Option<u64> { | ||
| 245 | let mut picked = None; | ||
| 246 | ui.horizontal(|ui| { | ||
| 247 | ui.spacing_mut().item_spacing.x = theme::SPACE_2; | ||
| 248 | for amount in amounts { | ||
| 249 | if chip_button(ui, amount, selected == amount, enabled) { | ||
| 250 | picked = Some(amount); | ||
| 251 | } | ||
| 252 | } | ||
| 253 | }); | ||
| 254 | picked | ||
| 255 | } | ||
