summaryrefslogtreecommitdiffstats
path: root/gui/src/app.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:58:59 +0100
commit10ff558853f549d3f3c7c8432488bfad9da6973a (patch)
tree271b05fead384e2d91e5c8332fea9d00b540f0cc /gui/src/app.rs
parentv4.0.7: finish the Phase 1 boundary with a command shell (diff)
downloadzagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.tar.gz
zagan-bet-10ff558853f549d3f3c7c8432488bfad9da6973a.zip
v4.1.0: add the egui workspace member and the roulette style spike
Phase 3 of the GUI plan. gui/ is a separate package that depends on the core with default features off, so egui never enters the TUI or server build graph. It carries the theme tokens and two embedded fonts (Open Sans OFL, Hack MIT), paints the felt, chips, cursor and wheel from RouletteView, and composes the persistent header with the betting sidebar. The reference screen runs on demo state: arrow keys move the shared Cursor, clicks place chips, and SPIN animates a 2.4 s wheel that settles with the winning pocket under the pointer. No network until Phase 4. Verified x86_64-pc-windows-gnu cross-compile. Measured on Linux at 1080p with llvmpipe: 6.0 MB binary (4.7 MB on Windows), 52 ms to window, 137 MB idle RSS, 0% idle CPU.
Diffstat (limited to '')
-rw-r--r--gui/src/app.rs587
1 files changed, 587 insertions, 0 deletions
diff --git a/gui/src/app.rs b/gui/src/app.rs
new file mode 100644
index 0000000..fde117a
--- /dev/null
+++ b/gui/src/app.rs
@@ -0,0 +1,587 @@
1//! The desktop shell: header, roulette table, and betting sidebar.
2//!
3//! Phase 3 is a style spike. The table is painted from a demo `RouletteView`
4//! built with the core's own types, but no command is sent yet: Phase 4 wires
5//! the transport and replaces the demo state with `ClientCore`.
6
7use std::f32::consts::TAU;
8
9use eframe::egui::{
10 self, Align, Button, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel,
11 RichText, Sense, Stroke, Ui, Vec2, vec2,
12};
13use zagan_bet::client::games::roulette::{Cursor, Direction};
14use zagan_bet::client::view::{RoulettePhase, RouletteView};
15use zagan_bet::roulette::{BetTarget, Bets, Outside, WHEEL, color as wheel_color};
16use zagan_bet::version::VERSION;
17
18use crate::{roulette, theme};
19
20const HEADER_HEIGHT: f32 = 64.0;
21const SIDEBAR_WIDTH: f32 = 336.0;
22const SPIN_SECONDS: f64 = 2.4;
23/// Demo winners, cycled: a red, a zero, a red, a black, a black, a black.
24const DEMO_TARGETS: [u8; 6] = [17, 0, 32, 26, 8, 11];
25const CHIP_SIZES: [u64; 4] = [10, 25, 50, 100];
26
27pub struct ZaganApp {
28 demo: Demo,
29}
30
31impl ZaganApp {
32 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
33 theme::install(&cc.egui_ctx);
34 Self { demo: Demo::new() }
35 }
36}
37
38impl eframe::App for ZaganApp {
39 fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
40 self.demo.tick(ui.ctx());
41 self.demo.keys(ui.ctx());
42
43 Panel::top("header")
44 .exact_size(HEADER_HEIGHT)
45 .frame(
46 Frame::new()
47 .fill(theme::SURFACE)
48 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)),
49 )
50 .show(ui, |ui| header(ui, &self.demo));
51
52 Panel::right("sidebar")
53 .exact_size(SIDEBAR_WIDTH)
54 .frame(
55 Frame::new()
56 .fill(theme::SURFACE)
57 .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 20)),
58 )
59 .show(ui, |ui| {
60 egui::ScrollArea::vertical()
61 .auto_shrink([false, false])
62 .show(ui, |ui| sidebar(ui, &mut self.demo));
63 });
64
65 CentralPanel::default()
66 .frame(
67 Frame::new()
68 .fill(theme::BG)
69 .inner_margin(Margin::same(theme::SPACE_5 as i8)),
70 )
71 .show(ui, |ui| table(ui, &mut self.demo));
72 }
73}
74
75// ---------------------------------------------------------------------------
76// Header
77// ---------------------------------------------------------------------------
78
79fn header(ui: &mut Ui, demo: &Demo) {
80 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
81 let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
82 ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN);
83 ui.add_space(theme::SPACE_2);
84 ui.label(
85 RichText::new("ZAGAN")
86 .font(theme::mono(18.0))
87 .color(theme::TEXT),
88 );
89 ui.label(
90 RichText::new("BET")
91 .font(theme::mono(18.0))
92 .color(theme::WIN),
93 );
94 ui.add_space(theme::SPACE_3);
95 pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT);
96 ui.add_space(theme::SPACE_1);
97 pill(ui, "PREVIEW", theme::GOLD);
98 ui.add_space(theme::SPACE_5);
99 connection_pill(ui, demo);
100
101 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
102 stat_block(
103 ui,
104 150.0,
105 "Balance",
106 &theme::money(demo.balance),
107 theme::mono(20.0),
108 theme::TEXT,
109 None,
110 );
111 ui.add_space(theme::SPACE_5);
112 stat_block(
113 ui,
114 190.0,
115 "Account",
116 demo.user,
117 theme::sans(theme::FONT_BODY),
118 theme::TEXT,
119 Some(demo.account),
120 );
121 });
122 });
123}
124
125fn pill(ui: &mut Ui, text: &str, color: Color32) {
126 Frame::new()
127 .fill(theme::SURFACE_RAISED)
128 .corner_radius(theme::RADIUS_SM)
129 .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3))
130 .show(ui, |ui| {
131 ui.label(RichText::new(text).font(theme::mono(10.0)).color(color));
132 });
133}
134
135fn connection_pill(ui: &mut Ui, demo: &Demo) {
136 Frame::new()
137 .fill(theme::SURFACE_RAISED)
138 .corner_radius(theme::RADIUS_SM)
139 .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5))
140 .show(ui, |ui| {
141 ui.horizontal(|ui| {
142 let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover());
143 ui.painter().circle_filled(dot.center(), 3.0, theme::WIN);
144 ui.label(
145 RichText::new("ONLINE")
146 .font(theme::mono(10.0))
147 .color(theme::WIN),
148 );
149 ui.label(
150 RichText::new(demo.host)
151 .font(theme::mono(12.0))
152 .color(theme::TEXT_DIM),
153 );
154 ui.label(
155 RichText::new(format!("{} ms", demo.ping_ms))
156 .font(theme::mono(12.0))
157 .color(theme::TEXT_FAINT),
158 );
159 });
160 });
161}
162
163/// A label over a value, right-aligned in a fixed slot so the header's right
164/// edge stays put as the numbers change.
165fn stat_block(
166 ui: &mut Ui,
167 width: f32,
168 label: &str,
169 value: &str,
170 value_font: egui::FontId,
171 value_color: Color32,
172 detail: Option<&str>,
173) {
174 ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| {
175 ui.spacing_mut().item_spacing.y = 0.0;
176 ui.label(theme::label(label));
177 ui.label(RichText::new(value).font(value_font).color(value_color));
178 if let Some(detail) = detail {
179 ui.label(
180 RichText::new(detail)
181 .font(theme::mono(10.0))
182 .color(theme::TEXT_FAINT),
183 );
184 }
185 });
186}
187
188// ---------------------------------------------------------------------------
189// Sidebar
190// ---------------------------------------------------------------------------
191
192fn sidebar(ui: &mut Ui, demo: &mut Demo) {
193 ui.label(
194 RichText::new("Roulette")
195 .font(theme::sans(22.0))
196 .color(theme::TEXT),
197 );
198 ui.label(
199 RichText::new("European · single zero")
200 .font(theme::sans(12.0))
201 .color(theme::TEXT_DIM),
202 );
203
204 ui.add_space(theme::SPACE_4);
205 let view = demo.view();
206 let wheel_size = ui.available_width().min(210.0);
207 ui.horizontal(|ui| {
208 let pad = ((ui.available_width() - wheel_size) * 0.5).max(0.0);
209 ui.add_space(pad);
210 roulette::wheel(ui, wheel_size, &view, demo.rotation);
211 });
212 ui.add_space(theme::SPACE_1);
213 result_line(ui, demo);
214
215 ui.add_space(theme::SPACE_3);
216 ui.separator();
217 ui.add_space(theme::SPACE_3);
218
219 ui.label(theme::label("Chip"));
220 ui.add_space(theme::SPACE_2);
221 ui.horizontal(|ui| {
222 ui.spacing_mut().item_spacing.x = theme::SPACE_2;
223 for amount in CHIP_SIZES {
224 if chip_button(ui, amount, demo.chip == amount).clicked() {
225 demo.chip = amount;
226 }
227 }
228 });
229
230 ui.add_space(theme::SPACE_3);
231 ui.label(theme::label("Bets"));
232 ui.add_space(theme::SPACE_1);
233 if demo.bets.chips.is_empty() {
234 ui.label(
235 RichText::new("No chips on the felt")
236 .font(theme::sans(12.0))
237 .color(theme::TEXT_FAINT),
238 );
239 } else {
240 for chip in &demo.bets.chips {
241 bet_row(ui, &roulette::target_name(&chip.target), chip.amount);
242 }
243 }
244
245 ui.add_space(theme::SPACE_3);
246 ui.separator();
247 ui.add_space(theme::SPACE_2);
248 bet_row(ui, "Total", demo.bets.total());
249
250 ui.add_space(theme::SPACE_3);
251 let spinning = demo.phase == RoulettePhase::Spinning;
252 let spin = Button::new(
253 RichText::new("SPIN")
254 .font(theme::mono(theme::FONT_MONO))
255 .color(theme::BG),
256 )
257 .fill(if spinning {
258 theme::WIN.gamma_multiply(0.45)
259 } else {
260 theme::WIN
261 })
262 .corner_radius(theme::RADIUS_MD)
263 .min_size(vec2(ui.available_width(), 44.0));
264 if ui.add_enabled(!spinning, spin).clicked() {
265 demo.start_spin(ui.ctx());
266 }
267
268 ui.add_space(theme::SPACE_2);
269 let clear = Button::new(
270 RichText::new("Clear bets")
271 .font(theme::sans(12.0))
272 .color(theme::TEXT_DIM),
273 )
274 .frame(false);
275 if ui
276 .add_enabled(!demo.bets.is_empty() && !spinning, clear)
277 .clicked()
278 {
279 demo.bets = Bets::default();
280 }
281}
282
283fn result_line(ui: &mut Ui, demo: &Demo) {
284 ui.horizontal(|ui| {
285 match (demo.phase, demo.number) {
286 (RoulettePhase::Result, Some(number)) => {
287 ui.label(theme::label("Result"));
288 ui.label(
289 RichText::new(number.to_string())
290 .font(theme::mono(15.0))
291 .color(color_of(number)),
292 );
293 ui.label(
294 RichText::new(wheel_color(number).to_uppercase())
295 .font(theme::mono(12.0))
296 .color(theme::TEXT_DIM),
297 );
298 }
299 (RoulettePhase::Spinning, _) => {
300 ui.label(
301 RichText::new("SPINNING…")
302 .font(theme::mono(12.0))
303 .color(theme::GOLD),
304 );
305 }
306 _ => {
307 ui.label(
308 RichText::new("Place your bets")
309 .font(theme::sans(12.0))
310 .color(theme::TEXT_DIM),
311 );
312 }
313 }
314 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
315 ui.label(
316 RichText::new(format!("{} chips", demo.bets.chips.len()))
317 .font(theme::mono(10.0))
318 .color(theme::TEXT_FAINT),
319 );
320 });
321 });
322}
323
324fn bet_row(ui: &mut Ui, name: &str, amount: u64) {
325 ui.horizontal(|ui| {
326 ui.label(
327 RichText::new(name)
328 .font(theme::sans(13.0))
329 .color(theme::TEXT_DIM),
330 );
331 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
332 ui.label(
333 RichText::new(theme::money(amount as i64))
334 .font(theme::mono(13.0))
335 .color(theme::GOLD),
336 );
337 });
338 });
339}
340
341fn chip_button(ui: &mut Ui, amount: u64, selected: bool) -> egui::Response {
342 let (rect, response) = ui.allocate_exact_size(Vec2::splat(46.0), Sense::click());
343 let center = rect.center();
344 let radius = 20.0;
345 let painter = ui.painter();
346 if selected || response.hovered() {
347 painter.circle_stroke(
348 center,
349 radius + 3.0,
350 Stroke::new(if selected { 2.0 } else { 1.0 }, theme::ACCENT),
351 );
352 }
353 roulette::paint_chip(painter, center, radius, amount);
354 response
355}
356
357fn color_of(number: u8) -> Color32 {
358 match wheel_color(number) {
359 "red" => theme::ROULETTE_RED,
360 "green" => theme::WIN,
361 _ => theme::TEXT,
362 }
363}
364
365// ---------------------------------------------------------------------------
366// Table
367// ---------------------------------------------------------------------------
368
369fn table(ui: &mut Ui, demo: &mut Demo) {
370 ui.horizontal(|ui| {
371 ui.label(
372 RichText::new("Table 01")
373 .font(theme::sans(15.0))
374 .color(theme::TEXT),
375 );
376 ui.label(
377 RichText::new("European · single zero")
378 .font(theme::sans(12.0))
379 .color(theme::TEXT_DIM),
380 );
381 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
382 ui.label(
383 RichText::new("Limits 10 – 500 Kr")
384 .font(theme::mono(12.0))
385 .color(theme::TEXT_FAINT),
386 );
387 });
388 });
389 ui.add_space(theme::SPACE_4);
390
391 let view = demo.view();
392 let strip_height = 104.0;
393 let available = ui.available_size();
394 let size = roulette::size_for(vec2(available.x, (available.y - strip_height).max(220.0)));
395 let side_pad = ((available.x - size.x) * 0.5).max(0.0);
396 let top_pad = ((available.y - size.y - strip_height) * 0.5).max(0.0);
397 let chip = demo.chip;
398 ui.add_space(top_pad);
399 ui.horizontal(|ui| {
400 ui.add_space(side_pad);
401 if let Some(target) = roulette::felt(ui, size, &view) {
402 demo.bets.add(target, chip);
403 }
404 });
405
406 ui.add_space(theme::SPACE_4);
407 ui.horizontal(|ui| {
408 ui.label(theme::label("Last"));
409 if demo.last.is_empty() {
410 ui.label(
411 RichText::new("No spins yet")
412 .font(theme::sans(12.0))
413 .color(theme::TEXT_FAINT),
414 );
415 }
416 for number in &demo.last {
417 let (dot, _) = ui.allocate_exact_size(Vec2::splat(26.0), Sense::hover());
418 roulette::paint_result_dot(ui.painter(), dot.center(), 11.0, *number);
419 ui.add_space(theme::SPACE_1);
420 }
421 });
422 ui.add_space(theme::SPACE_2);
423 ui.label(
424 RichText::new(
425 "Click a pocket to place a chip · arrow keys move the cursor · space spins",
426 )
427 .font(theme::sans(12.0))
428 .color(theme::TEXT_FAINT),
429 );
430}
431
432// ---------------------------------------------------------------------------
433// Demo state
434// ---------------------------------------------------------------------------
435
436struct Spin {
437 started: f64,
438 from: f32,
439 delta: f32,
440 target: u8,
441}
442
443struct Demo {
444 bets: Bets,
445 cursor: Cursor,
446 chip: u64,
447 phase: RoulettePhase,
448 number: Option<u8>,
449 rotation: f32,
450 spin: Option<Spin>,
451 spins: usize,
452 last: Vec<u8>,
453 balance: i64,
454 host: &'static str,
455 ping_ms: u32,
456 user: &'static str,
457 account: &'static str,
458}
459
460impl Demo {
461 fn new() -> Self {
462 let mut bets = Bets::default();
463 bets.add(BetTarget::Straight { number: 17 }, 50);
464 bets.add(
465 BetTarget::Corner {
466 numbers: [17, 18, 20, 21],
467 },
468 25,
469 );
470 bets.add(BetTarget::Outside { kind: Outside::Red }, 25);
471
472 Self {
473 bets,
474 // A split with no chip on it, so the fine lattice cursor shows.
475 cursor: Cursor::Lattice { x: 18, y: 3 },
476 chip: 25,
477 phase: RoulettePhase::Placing,
478 number: None,
479 rotation: 0.0,
480 spin: None,
481 spins: 0,
482 last: Vec::new(),
483 balance: 1000,
484 host: "127.0.0.1:11525",
485 ping_ms: 41,
486 user: "alice",
487 account: "#4161673306",
488 }
489 }
490
491 fn view(&self) -> RouletteView {
492 RouletteView {
493 bet_amount: 10,
494 balance: Some(self.balance),
495 chip: self.chip,
496 bets: self.bets.clone(),
497 cursor: self.cursor,
498 phase: self.phase,
499 number: self.number,
500 color: self.number.map(|number| wheel_color(number).to_string()),
501 winnings: None,
502 error: None,
503 }
504 }
505
506 fn tick(&mut self, ctx: &Context) {
507 let now = ctx.input(|input| input.time);
508 let mut finished = None;
509 if let Some(spin) = &self.spin {
510 let t = (((now - spin.started) / SPIN_SECONDS) as f32).clamp(0.0, 1.0);
511 let eased = 1.0 - (1.0 - t).powi(3);
512 self.rotation = spin.from + spin.delta * eased;
513 if t >= 1.0 {
514 finished = Some(spin.target);
515 }
516 ctx.request_repaint();
517 }
518 if let Some(number) = finished {
519 self.spin = None;
520 self.phase = RoulettePhase::Result;
521 self.number = Some(number);
522 self.last.insert(0, number);
523 self.last.truncate(12);
524 }
525 }
526
527 fn keys(&mut self, ctx: &Context) {
528 if self.phase == RoulettePhase::Spinning {
529 return;
530 }
531 let (left, right, up, down) = ctx.input(|input| {
532 (
533 input.key_pressed(Key::ArrowLeft),
534 input.key_pressed(Key::ArrowRight),
535 input.key_pressed(Key::ArrowUp),
536 input.key_pressed(Key::ArrowDown),
537 )
538 });
539 for (pressed, direction) in [
540 (left, Direction::Left),
541 (right, Direction::Right),
542 (up, Direction::Up),
543 (down, Direction::Down),
544 ] {
545 if pressed {
546 self.cursor = self.cursor.moved(direction);
547 }
548 }
549 if ctx.memory(|memory| memory.focused().is_none()) {
550 if ctx.input(|input| input.key_pressed(Key::Enter)) {
551 if let Some(target) = self.cursor.target() {
552 let chip = self.chip;
553 self.bets.add(target, chip);
554 }
555 }
556 if ctx.input(|input| input.key_pressed(Key::Space)) {
557 self.start_spin(ctx);
558 }
559 }
560 }
561
562 fn start_spin(&mut self, ctx: &Context) {
563 if self.phase == RoulettePhase::Spinning {
564 return;
565 }
566 let target = DEMO_TARGETS[self.spins % DEMO_TARGETS.len()];
567 self.spins += 1;
568
569 let segment = TAU / WHEEL.len() as f32;
570 let index = WHEEL
571 .iter()
572 .position(|number| *number == target)
573 .unwrap_or(0) as f32;
574 let desired = -index * segment;
575 let delta = (desired - self.rotation).rem_euclid(TAU) + 3.0 * TAU;
576
577 self.spin = Some(Spin {
578 started: ctx.input(|input| input.time),
579 from: self.rotation,
580 delta,
581 target,
582 });
583 self.phase = RoulettePhase::Spinning;
584 self.number = None;
585 ctx.request_repaint();
586 }
587}