//! The desktop shell: screens, session state, and the bridge to the core. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::mpsc::Sender; use std::time::{Duration, Instant}; use eframe::egui::{ self, Align, Align2, CentralPanel, Color32, Context, Frame, Key, Layout, Margin, Panel, RichText, Sense, Stroke, TextEdit, Ui, Vec2, vec2, }; use rusqlite::Connection; use sha2::{Digest, Sha256}; use zagan_bet::client::core::{ClientCore, Effect}; use zagan_bet::client::database; use zagan_bet::client::paths; use zagan_bet::client::view::{Command, LobbyView, View}; use zagan_bet::history::PlayRecord; use zagan_bet::version::{VERSION, is_newer}; use crate::net::{Command as NetCommand, Connected, Event, Failure, Network}; use crate::{blackjack, crash, roulette, slots, theme, widgets}; /// A request that has no reply after this long kills the connection, matching /// the terminal adapter's socket timeout. const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); const NOTICE_SECONDS: u64 = 6; const DEFAULT_BET: u64 = 10; /// Where the app keeps its files and whether TLS is bypassed. pub struct Startup { pub data_dir: PathBuf, pub unsafe_mode: bool, } pub struct ZaganApp { core: ClientCore, net: Network, db: Option, db_error: Option, unsafe_mode: bool, session: Option, connect: ConnectState, login: LoginState, trust: Option, key_changed: Option, leave_confirm: bool, notices: VecDeque, awaiting: Option, need_info: bool, pending_login: Option<(String, String)>, /// Displayed balance while a result animation runs: stake deducted, no /// winnings yet. The view already knows the outcome; the player must not. balance_hold: Option, balance: Option, account: Option, fx: Fx, } struct Session { host: String, fingerprint: Option, ping_ms: f32, server_version: String, latest: String, url: String, user: Option, } #[derive(Default)] struct ConnectState { addr: String, error: Option, version_issue: bool, pending: bool, } #[derive(Default)] struct LoginState { user: String, password: String, error: Option, remember: bool, } impl LoginState { fn new() -> Self { Self { remember: true, ..Self::default() } } } struct TrustState { addr: String, fingerprint: String, reply: Sender, } struct KeyChanged { addr: String, expected: String, observed: String, } struct Notice { text: String, at: Instant, tone: Tone, } #[derive(Clone, Copy, PartialEq, Eq)] enum Tone { Info, Good, Bad, } #[derive(Default)] struct Fx { game: Option, roulette: roulette::Fx, slots: slots::Fx, crash: crash::Fx, } impl Fx { /// Reset the animation state when the player enters or leaves a game. fn sync(&mut self, game: Option<&str>) -> bool { if self.game.as_deref() != game { let game = game.map(str::to_string); *self = Fx { game, ..Fx::default() }; return true; } false } } impl ZaganApp { pub fn new(cc: &eframe::CreationContext<'_>, startup: Startup) -> Self { theme::install(&cc.egui_ctx); let db_path = paths::database_path(&startup.data_dir); let (db, db_error) = match Connection::open(&db_path) { Ok(conn) => { let _ = conn.busy_timeout(Duration::from_secs(2)); database::init_db(&conn); (Some(conn), None) } Err(error) => ( None, Some(format!("Cannot open {}: {error}", db_path.display())), ), }; let addr = db .as_ref() .and_then(database::get_last_host) .unwrap_or_else(|| "127.0.0.1:11525".to_string()); let net = Network::spawn( cc.egui_ctx.clone(), startup.data_dir.clone(), startup.unsafe_mode, ); Self { core: ClientCore::new(), net, db, db_error, unsafe_mode: startup.unsafe_mode, session: None, connect: ConnectState { addr, ..ConnectState::default() }, login: LoginState::new(), trust: None, key_changed: None, leave_confirm: false, notices: VecDeque::new(), awaiting: None, need_info: false, pending_login: None, balance_hold: None, balance: None, account: None, fx: Fx::default(), } } fn screen(&mut self, ui: &mut Ui) { if let Some(error) = self.db_error.clone() { fatal(ui, &error); return; } let view = self.core.view(); if self.session.is_none() { connect_screen(ui, self); return; } match view { Some(View::Lobby(lobby)) if lobby.user.is_none() => login_screen(ui, self, &lobby), Some(View::Lobby(lobby)) => lobby_screen(ui, self, &lobby), Some(View::History(history)) => history_screen(ui, self, &history.records), Some(View::Slots(view)) => { let command = slots::show(ui, &view, &mut self.fx.slots); if command == Some(Command::Spin) { self.balance_hold = self .balance .map(|balance| (balance - view.bet as i64).max(0)); } if let Some(command) = command { self.command(ui.ctx(), command); } } Some(View::Roulette(view)) => { let command = roulette::show(ui, &view, &mut self.fx.roulette); if command == Some(Command::Spin) { self.balance_hold = self .balance .map(|balance| (balance - view.bets.total() as i64).max(0)); } if let Some(command) = command { self.command(ui.ctx(), command); } } Some(View::Blackjack(view)) => { if let Some(command) = blackjack::show(ui, &view) { self.command(ui.ctx(), command); } } Some(View::Crash(view)) => { let command = crash::show(ui, &view, &mut self.fx.crash); if let Some(command) = command { self.command(ui.ctx(), command); } } None => {} } } /// Run one core command, holding back a leave that would forfeit a live /// round until the player confirms. fn command(&mut self, ctx: &Context, command: Command) { if command == Command::Leave && self.core.is_live_game() { self.leave_confirm = true; return; } if command == Command::Logout { self.need_info = false; self.account = None; self.balance = None; self.pending_login = None; if let Some(session) = &mut self.session { session.user = None; } } let effects = self.core.command(command); self.apply(ctx, effects); } fn apply(&mut self, ctx: &Context, effects: Vec) { for effect in effects { match effect { Effect::Print(lines) => { // Multi-line prints are terminal tables (history, help, // info); the GUI draws those from `View` instead. if lines.len() == 1 { if let Some(line) = lines.first() { self.notice(line); } } } Effect::SendLine(line) => { self.net.send(NetCommand::Send(line)); self.awaiting = Some(Instant::now()); } Effect::Disconnect => { self.net.send(NetCommand::Disconnect); self.core.reset_after_disconnect(); self.session = None; self.awaiting = None; self.need_info = false; self.account = None; self.balance = None; self.balance_hold = None; } Effect::ClearRegion | Effect::Render(_) => {} Effect::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } } } fn notice(&mut self, text: &str) { // `info` is fetched to fill the header; its terminal lines are not // news to the GUI. if text.starts_with("Connected to:") || text.starts_with("Fingerprint:") || text.starts_with("User:") || text.starts_with("Account:") || text.starts_with("Balance:") { return; } let tone = if text.starts_with("ERROR") || text.contains("failed") || text.contains("Failed") || text.contains("lost") { Tone::Bad } else if text.starts_with("Logged in") || text.starts_with("Account created") || text.contains("saved") { Tone::Good } else { Tone::Info }; self.notices.push_back(Notice { text: text.to_string(), at: Instant::now(), tone, }); while self.notices.len() > 3 { self.notices.pop_front(); } } fn pump(&mut self, ctx: &Context) { while let Some(event) = self.net.try_recv() { match event { Event::Trust { addr, fingerprint, reply, } => { self.trust = Some(TrustState { addr, fingerprint, reply, }); } Event::Connected(connected) => self.connected(ctx, connected), Event::Failed { addr, failure, message, } => self.failed(addr, failure, message), Event::Line(line) => { if self.core.is_async(&line) { if let Some(effects) = self.core.handle_async_line(&line) { self.apply(ctx, effects); } } else { self.awaiting = None; let effects = self.core.handle_reply(&line); self.apply(ctx, effects); } } Event::Closed => { self.session = None; self.awaiting = None; self.core.reset_after_disconnect(); self.notice("Connection lost"); ctx.request_repaint(); } } } } fn connected(&mut self, ctx: &Context, connected: Connected) { self.connect.pending = false; self.connect.error = None; self.core.set_connected(true); self.core .set_host_info(Some(connected.addr.clone()), connected.fingerprint.clone()); self.core.set_logged_in(connected.user.clone()); self.login.error = connected.auth_error.clone(); self.login.user = connected.user.clone().unwrap_or_default(); if let Some(db) = &self.db { database::save_host(db, &connected.addr); } self.session = Some(Session { host: connected.addr, fingerprint: connected.fingerprint, ping_ms: connected.elapsed_ms, server_version: connected.server_version, latest: connected.latest, url: connected.url, user: connected.user, }); self.need_info = self.session.as_ref().is_some_and(|s| s.user.is_some()); self.account = None; if self.need_info { let effects = self.core.command(Command::Info); self.apply(ctx, effects); self.need_info = false; } } fn failed(&mut self, addr: String, failure: Failure, message: String) { self.connect.pending = false; self.session = None; self.core.set_connected(false); self.awaiting = None; match failure { Failure::KeyChanged { expected, observed } => { self.key_changed = Some(KeyChanged { addr, expected, observed, }); } Failure::VersionMismatch => { self.connect.version_issue = true; self.connect.error = Some(message); } Failure::TrustDeclined => { self.trust = None; self.connect.error = Some("Certificate was not trusted.".to_string()); } Failure::Timeout => { self.trust = None; self.connect.error = Some("Connection timed out.".to_string()); } Failure::Other => { self.trust = None; self.connect.error = Some(message); } } } fn begin_connect(&mut self, addr: &str) { let addr = normalize_addr(addr); self.connect.addr = addr.clone(); self.connect.error = None; self.connect.version_issue = false; self.connect.pending = true; self.login.error = None; self.net.send(NetCommand::Connect { addr }); } fn disconnect(&mut self) { self.net.send(NetCommand::Disconnect); self.core.reset_after_disconnect(); self.session = None; self.awaiting = None; self.trust = None; self.key_changed = None; self.need_info = false; self.account = None; self.balance = None; self.balance_hold = None; } fn trust_decision(&mut self, accept: bool) { if let Some(trust) = self.trust.take() { let _ = trust.reply.send(accept); if !accept { self.connect.error = Some("Certificate was not trusted.".to_string()); } } } fn forget_trusted_host(&mut self, addr: &str) { let Some(db) = &self.db else { return }; match database::forget_host(db, addr) { Ok(_) => { self.key_changed = None; self.begin_connect(addr); } Err(error) => self.connect.error = Some(format!("Failed to forget host: {error}")), } } fn forget_login(&mut self) { let Some(fingerprint) = self .session .as_ref() .and_then(|session| session.fingerprint.clone()) else { return; }; let Some(db) = &self.db else { return }; match database::forget_credentials(db, &fingerprint) { Ok(true) => self.notice("Saved login forgotten. Trust is unchanged."), Ok(false) => self.notice("No saved login for this server."), Err(error) => self.notice(&format!("Failed to forget the login: {error}")), } } fn login(&mut self, ctx: &Context, registering: bool) { let user = self.login.user.trim().to_string(); if user.is_empty() || self.login.password.is_empty() { self.login.error = Some("Enter a username and a password.".to_string()); return; } let credential = format!("{:x}", Sha256::digest(self.login.password.as_bytes())); self.login.password.clear(); self.login.error = None; let command = if registering { Command::Register { user, credential } } else { self.need_info = true; if self.login.remember { self.pending_login = Some((user.clone(), credential.clone())); } Command::Login { user, credential } }; let effects = self.core.command(command); self.apply(ctx, effects); } fn tick(&mut self, ctx: &Context) { // Request deadline, mirroring the terminal's socket timeout. if self.need_info && self .session .as_ref() .is_some_and(|session| session.user.is_some()) && self.account.is_none() { let effects = self.core.command(Command::Info); self.apply(ctx, effects); self.need_info = false; } if let Some(sent) = self.awaiting { if sent.elapsed() > REQUEST_TIMEOUT { self.awaiting = None; self.net.send(NetCommand::Disconnect); self.session = None; self.core.reset_after_disconnect(); let effects = self.core.handle_reply("ERROR Request timed out"); self.apply(ctx, effects); self.notice("Request timed out. Disconnected."); } } let game = self.core.current_game_type().map(str::to_string); if self.fx.sync(game.as_deref()) { self.balance_hold = None; } match game.as_deref() { Some("crash") => { let effects = self.core.tick(); self.apply(ctx, effects); if let Some(View::Crash(view)) = self.core.view() { self.fx.crash.tick(ctx, &view); if view.status == "running" || view.status == "starting" { ctx.request_repaint_after(Duration::from_millis(16)); } } } Some("slots") => { if let Some(View::Slots(view)) = self.core.view() { self.fx.slots.tick(ctx, &view); } } Some("roulette") => { if let Some(View::Roulette(view)) = self.core.view() { self.fx.roulette.tick(ctx, &view); } } _ => {} } // Hold the balance at the staked amount until the reels or wheel stop, // so the payout is not visible before the animation reveals it. if self.balance_hold.is_some() && !self.result_animating(ctx, game.as_deref()) { self.balance_hold = None; } self.keys(ctx, game.as_deref()); // A successful manual login can be remembered for the next connect. if let Some((user, credential)) = self.pending_login.clone() { if self .core .view() .as_ref() .is_some_and(|view| matches!(view, View::Lobby(lobby) if lobby.user.is_some())) { if let Some(fingerprint) = self .session .as_ref() .and_then(|session| session.fingerprint.as_deref()) { if let Some(db) = &self.db { let _ = database::save_credentials(db, fingerprint, &user, &credential); self.notice("Login saved for this server."); } } self.pending_login = None; } } // Cache the header data, which game views do not all carry. if let Some(view) = self.core.view() { match view { View::Lobby(lobby) => { if let Some(session) = &mut self.session { session.user = lobby.user.clone(); } if lobby.user.is_none() { self.balance = None; self.account = None; } else { if let Some(account) = lobby.account { self.account = Some(account); } if let Some(balance) = lobby.balance { if self.balance_hold.is_none() { self.balance = Some(balance); } } } } View::History(_) => {} other => { if self.balance_hold.is_none() { if let Some(balance) = balance_of(&other) { self.balance = Some(balance); } } } } } self.notices .retain(|notice| notice.at.elapsed() < Duration::from_secs(NOTICE_SECONDS)); if !self.notices.is_empty() { ctx.request_repaint_after(Duration::from_millis(500)); } } /// True while a spin's result is still being animated. fn result_animating(&self, ctx: &Context, game: Option<&str>) -> bool { match game { Some("slots") => match self.core.view() { Some(View::Slots(view)) if view.error.is_none() => { !self.fx.slots.revealed(ctx, &view) } _ => false, }, Some("roulette") => match self.core.view() { Some(View::Roulette(view)) if view.error.is_none() => { !self.fx.roulette.revealed(&view) } _ => false, }, _ => false, } } /// The few keys that stay useful when the pointer is elsewhere. Buttons /// keep focus, so this only fires when nothing is focused. fn keys(&mut self, ctx: &Context, game: Option<&str>) { if self.session.is_none() || !ctx.memory(|memory| memory.focused().is_none()) { return; } let (space, clear, cash, hit, stand, double) = ctx.input(|input| { ( input.key_pressed(Key::Space), input.key_pressed(Key::C), input.key_pressed(Key::C), input.key_pressed(Key::H), input.key_pressed(Key::S), input.key_pressed(Key::D), ) }); let command = match game { Some("roulette") => { if space { self.fx.roulette.spin_requested(); Some(Command::Spin) } else if clear { Some(Command::ClearBets) } else { None } } Some("slots") if space => Some(Command::Spin), Some("crash") => { if cash { Some(Command::CashOut) } else if space { Some(Command::Start) } else { None } } Some("blackjack") => { if hit { Some(Command::Hit) } else if stand { Some(Command::Stand) } else if double { Some(Command::Double) } else if space { Some(Command::Deal) } else { None } } _ => None, }; if let Some(command) = command { self.command(ctx, command); } } fn modals(&mut self, ctx: &Context) { if let Some(trust) = &self.trust { let addr = trust.addr.clone(); let fingerprint = trust.fingerprint.clone(); let mut decision = None; egui::Modal::new(egui::Id::unique("trust")).show(ctx, |ui| { ui.set_width(460.0); ui.label( RichText::new("New server certificate") .font(theme::sans(20.0)) .color(theme::TEXT), ); ui.add_space(theme::SPACE_2); widgets::note( ui, "This is the first time this client has seen this server. \ Check the fingerprint before trusting it.", ); ui.add_space(theme::SPACE_3); widgets::row(ui, "Address", &addr, theme::TEXT); widgets::mono_text(ui, &fingerprint, theme::ACCENT); ui.add_space(theme::SPACE_3); widgets::note( ui, "Future connections must present the same key. A changed key is refused.", ); ui.add_space(theme::SPACE_4); ui.horizontal(|ui| { if widgets::primary(ui, "TRUST", true, 130.0, 40.0) { decision = Some(true); } ui.add_space(theme::SPACE_2); if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { decision = Some(false); } }); }); if let Some(accept) = decision { self.trust_decision(accept); } } if let Some(changed) = &self.key_changed { let addr = changed.addr.clone(); let expected = changed.expected.clone(); let observed = changed.observed.clone(); let mut action = None; egui::Modal::new(egui::Id::unique("key-changed")).show(ctx, |ui| { ui.set_width(520.0); ui.label( RichText::new("SERVER KEY CHANGED") .font(theme::mono(16.0)) .color(theme::LOSS), ); ui.add_space(theme::SPACE_2); widgets::note( ui, "A server that used to present a different certificate is answering. \ This can mean the server was reinstalled, or that someone is \ intercepting the connection.", ); ui.add_space(theme::SPACE_3); widgets::row(ui, "Address", &addr, theme::TEXT); widgets::mono_text(ui, &format!("expected {expected}"), theme::TEXT_DIM); widgets::mono_text(ui, &format!("observed {observed}"), theme::LOSS); ui.add_space(theme::SPACE_4); ui.horizontal(|ui| { if widgets::primary(ui, "FORGET AND RECONNECT", true, 220.0, 40.0) { action = Some(true); } ui.add_space(theme::SPACE_2); if widgets::secondary(ui, "CANCEL", true, 110.0, 40.0) { action = Some(false); } }); }); match action { Some(true) => self.forget_trusted_host(&addr), Some(false) => { self.key_changed = None; self.connect.error = Some("Server key changed. Trust not reset.".to_string()); } None => {} } } if self.leave_confirm { let mut action = None; egui::Modal::new(egui::Id::unique("leave-confirm")).show(ctx, |ui| { ui.set_width(400.0); ui.label( RichText::new("Leave the table?") .font(theme::sans(19.0)) .color(theme::TEXT), ); ui.add_space(theme::SPACE_2); widgets::note( ui, "A live hand or round is forfeited. The reserved bet is lost.", ); ui.add_space(theme::SPACE_4); ui.horizontal(|ui| { if widgets::primary(ui, "FORFEIT AND LEAVE", true, 190.0, 40.0) { action = Some(true); } ui.add_space(theme::SPACE_2); if widgets::secondary(ui, "STAY", true, 100.0, 40.0) { action = Some(false); } }); }); if let Some(leave) = action { self.leave_confirm = false; if leave { let effects = self.core.command(Command::Leave); self.apply(ctx, effects); } } } } fn toasts(&self, ctx: &Context) { if self.notices.is_empty() { return; } egui::Area::new(egui::Id::unique("toasts")) .anchor(Align2::LEFT_BOTTOM, vec2(24.0, -24.0)) .order(egui::Order::Foreground) .interactable(false) .show(ctx, |ui| { ui.set_min_width(220.0); ui.set_max_width(460.0); ui.spacing_mut().item_spacing.y = theme::SPACE_2; for notice in &self.notices { let color = match notice.tone { Tone::Bad => theme::LOSS, Tone::Good => theme::WIN, Tone::Info => theme::TEXT_DIM, }; Frame::new() .fill(theme::SURFACE_RAISED) .stroke(Stroke::new(1.0, theme::LINE)) .corner_radius(theme::RADIUS_MD) .inner_margin(Margin::symmetric(14, 8)) .show(ui, |ui| { ui.set_min_width(226.0); ui.set_max_width(440.0); ui.label( RichText::new(¬ice.text) .font(theme::sans(12.0)) .color(color), ); }); } }); } } impl eframe::App for ZaganApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); self.pump(&ctx); self.tick(&ctx); header(ui, self); self.screen(ui); self.modals(&ctx); self.toasts(&ctx); } } fn balance_of(view: &View) -> Option { match view { View::Lobby(lobby) => lobby.balance, View::Slots(view) => view.balance, View::Roulette(view) => view.balance, View::Blackjack(view) => view.balance, View::Crash(view) => view.balance, View::History(_) => None, } } fn normalize_addr(addr: &str) -> String { let addr = addr.trim(); if addr.is_empty() { return "127.0.0.1:11525".to_string(); } if addr.contains(':') { addr.to_string() } else { format!("{addr}:11525") } } // --------------------------------------------------------------------------- // Header // --------------------------------------------------------------------------- fn header(ui: &mut Ui, app: &ZaganApp) { Panel::top("header") .exact_size(64.0) .frame( Frame::new() .fill(theme::SURFACE) .inner_margin(Margin::symmetric(theme::SPACE_5 as i8, 0)), ) .show(ui, |ui| { ui.with_layout(Layout::left_to_right(Align::Center), |ui| { let (mark, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); ui.painter().rect_filled(mark, theme::RADIUS_SM, theme::WIN); ui.add_space(theme::SPACE_2); ui.label( RichText::new("ZAGAN") .font(theme::mono(18.0)) .color(theme::TEXT), ); ui.label( RichText::new("BET") .font(theme::mono(18.0)) .color(theme::WIN), ); ui.add_space(theme::SPACE_3); pill(ui, &format!("v{VERSION}"), theme::TEXT_FAINT); if app.unsafe_mode { ui.add_space(theme::SPACE_1); pill(ui, "UNSAFE", theme::LOSS); } ui.add_space(theme::SPACE_5); match &app.session { Some(session) => connection_pill(ui, session), None => pill(ui, "OFFLINE", theme::TEXT_FAINT), } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let signed_in = app .session .as_ref() .is_some_and(|session| session.user.is_some()); if signed_in { if let Some(balance) = app.balance_hold.or(app.balance) { stat_block( ui, 150.0, "Balance", &theme::money(balance), theme::mono(20.0), theme::TEXT, None, ); ui.add_space(theme::SPACE_5); } if let Some(user) = app.session.as_ref().and_then(|s| s.user.as_deref()) { let account = app.account.as_deref().map(|account| format!("#{account}")); stat_block( ui, 190.0, "Account", user, theme::sans(theme::FONT_BODY), theme::TEXT, account.as_deref(), ); } } }); }); }); } fn pill(ui: &mut Ui, text: &str, color: Color32) { Frame::new() .fill(theme::SURFACE_RAISED) .corner_radius(theme::RADIUS_SM) .inner_margin(Margin::symmetric(theme::SPACE_2 as i8, 3)) .show(ui, |ui| { ui.label(RichText::new(text).font(theme::mono(10.0)).color(color)); }); } fn connection_pill(ui: &mut Ui, session: &Session) { Frame::new() .fill(theme::SURFACE_RAISED) .corner_radius(theme::RADIUS_SM) .inner_margin(Margin::symmetric(theme::SPACE_3 as i8, 5)) .show(ui, |ui| { ui.horizontal(|ui| { let (dot, _) = ui.allocate_exact_size(Vec2::splat(8.0), Sense::hover()); ui.painter().circle_filled(dot.center(), 3.0, theme::WIN); ui.label( RichText::new("ONLINE") .font(theme::mono(10.0)) .color(theme::WIN), ); ui.label( RichText::new(&session.host) .font(theme::mono(12.0)) .color(theme::TEXT_DIM), ); ui.label( RichText::new(format!("{:.1} ms", session.ping_ms)) .font(theme::mono(12.0)) .color(theme::TEXT_FAINT), ); }); }); } fn stat_block( ui: &mut Ui, width: f32, label: &str, value: &str, value_font: egui::FontId, value_color: Color32, detail: Option<&str>, ) { ui.allocate_ui_with_layout(vec2(width, 44.0), Layout::top_down(Align::Max), |ui| { ui.spacing_mut().item_spacing.y = 0.0; ui.label(theme::label(label)); ui.label(RichText::new(value).font(value_font).color(value_color)); if let Some(detail) = detail { ui.label( RichText::new(detail) .font(theme::mono(10.0)) .color(theme::TEXT_FAINT), ); } }); } // --------------------------------------------------------------------------- // Connect // --------------------------------------------------------------------------- fn connect_screen(ui: &mut Ui, app: &mut ZaganApp) { CentralPanel::default() .frame( Frame::new() .fill(theme::BG) .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.add_space(60.0); let width = 560.0; ui.allocate_ui_with_layout(vec2(width, 0.0), Layout::top_down(Align::Min), |ui| { Frame::new() .fill(theme::SURFACE) .corner_radius(theme::RADIUS_LG) .inner_margin(Margin::same(theme::SPACE_5 as i8)) .show(ui, |ui| { ui.label( RichText::new("Connect to a server") .font(theme::sans(24.0)) .color(theme::TEXT), ); ui.add_space(theme::SPACE_1); widgets::note( ui, "TLS with certificate pinning. First contact asks before \ any credential is sent.", ); ui.add_space(theme::SPACE_4); let response = ui.add( TextEdit::singleline(&mut app.connect.addr) .hint_text("host:port") .desired_width(f32::INFINITY) .font(theme::mono(14.0)), ); let submitted = response.lost_focus() && ui.input(|input| input.key_pressed(Key::Enter)); ui.add_space(theme::SPACE_3); let connect = widgets::primary( ui, if app.connect.pending { "CONNECTING…" } else { "CONNECT" }, !app.connect.pending, ui.available_width(), 44.0, ); if (connect || submitted) && !app.connect.pending { let addr = app.connect.addr.clone(); app.begin_connect(&addr); } if let Some(error) = &app.connect.error { ui.add_space(theme::SPACE_3); widgets::error(ui, error); } if app.connect.version_issue { ui.add_space(theme::SPACE_2); widgets::note( ui, &format!( "Download the matching client from {}", zagan_bet::version::DEFAULT_DOWNLOAD_URL ), ); } }); ui.add_space(theme::SPACE_4); Frame::new() .fill(theme::SURFACE) .corner_radius(theme::RADIUS_LG) .inner_margin(Margin::same(theme::SPACE_4 as i8)) .show(ui, |ui| { ui.label(theme::label("Remembered hosts")); ui.add_space(theme::SPACE_2); let hosts = app .db .as_ref() .map(database::get_all_hosts) .unwrap_or_default(); if hosts.is_empty() { widgets::note(ui, "No remembered hosts yet."); } for (host, fingerprint) in hosts { remembered_host(ui, app, &host, fingerprint.as_deref()); } }); }); }); }); } fn remembered_host(ui: &mut Ui, app: &mut ZaganApp, host: &str, fingerprint: Option<&str>) { ui.horizontal(|ui| { ui.label( RichText::new(host) .font(theme::mono(13.0)) .color(theme::TEXT), ); if let Some(fingerprint) = fingerprint { ui.label( RichText::new(short_fingerprint(fingerprint)) .font(theme::mono(10.0)) .color(theme::TEXT_FAINT), ); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::ghost(ui, "Forget") { if let Some(db) = &app.db { if let Err(error) = database::forget_host(db, host) { app.connect.error = Some(format!("Failed to forget host: {error}")); } } } if widgets::ghost(ui, "Connect") { app.begin_connect(host); } }); }); } fn short_fingerprint(fingerprint: &str) -> String { let head: String = fingerprint.chars().take(16).collect(); format!("{head}…") } // --------------------------------------------------------------------------- // Login // --------------------------------------------------------------------------- fn login_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { CentralPanel::default() .frame( Frame::new() .fill(theme::BG) .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.add_space(100.0); ui.allocate_ui_with_layout(vec2(420.0, 0.0), Layout::top_down(Align::Min), |ui| { Frame::new() .fill(theme::SURFACE) .corner_radius(theme::RADIUS_LG) .inner_margin(Margin::same(theme::SPACE_5 as i8)) .show(ui, |ui| { ui.label( RichText::new("Sign in") .font(theme::sans(24.0)) .color(theme::TEXT), ); if let Some(session) = &app.session { widgets::mono_text( ui, &format!( "{} · {}", session.host, short_fingerprint( session.fingerprint.as_deref().unwrap_or("unknown") ) ), theme::TEXT_DIM, ); } ui.add_space(theme::SPACE_4); ui.label(theme::label("Username")); ui.add( TextEdit::singleline(&mut app.login.user) .desired_width(f32::INFINITY), ); ui.add_space(theme::SPACE_3); ui.label(theme::label("Password")); ui.add( TextEdit::singleline(&mut app.login.password) .password(true) .desired_width(f32::INFINITY), ); ui.add_space(theme::SPACE_2); ui.checkbox( &mut app.login.remember, RichText::new("Remember this login") .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); let enter = ui.input(|input| input.key_pressed(Key::Enter)); ui.add_space(theme::SPACE_4); if widgets::primary(ui, "LOG IN", true, ui.available_width(), 44.0) || enter { app.login(ui.ctx(), false); } ui.add_space(theme::SPACE_2); if widgets::secondary( ui, "CREATE ACCOUNT", true, ui.available_width(), 38.0, ) { app.login(ui.ctx(), true); } if let Some(error) = lobby.login_error.as_ref().or(app.login.error.as_ref()) { ui.add_space(theme::SPACE_3); widgets::error(ui, error); } }); ui.add_space(theme::SPACE_3); ui.allocate_ui_with_layout( vec2(420.0, 24.0), Layout::top_down(Align::Center), |ui| { if widgets::ghost(ui, "Disconnect") { app.disconnect(); } }, ); }); }); }); } // --------------------------------------------------------------------------- // Lobby // --------------------------------------------------------------------------- fn lobby_screen(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { CentralPanel::default() .frame( Frame::new() .fill(theme::BG) .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( RichText::new("Lobby") .font(theme::sans(24.0)) .color(theme::TEXT), ); ui.label( RichText::new(format!("{} games", lobby.games.len())) .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::secondary(ui, "LOG OUT", true, 100.0, 34.0) { app.command(ui.ctx(), Command::Logout); } if widgets::secondary(ui, "HISTORY", true, 104.0, 34.0) { let effects = app.core.command(Command::History { limit: 20 }); app.apply(ui.ctx(), effects); } }); }); if let Some(session) = &app.session { if is_newer(&session.latest, VERSION) { ui.add_space(theme::SPACE_3); Frame::new() .fill(theme::GOLD.gamma_multiply(0.10)) .stroke(Stroke::new(1.0, theme::GOLD.gamma_multiply(0.5))) .corner_radius(theme::RADIUS_MD) .inner_margin(Margin::symmetric(14, 10)) .show(ui, |ui| { ui.horizontal(|ui| { ui.label( RichText::new(format!("Update available: v{}", session.latest)) .font(theme::mono(12.0)) .color(theme::GOLD), ); widgets::mono_text(ui, &session.url, theme::TEXT_DIM); }); }); } } ui.add_space(theme::SPACE_4); games_grid(ui, app, lobby); ui.add_space(theme::SPACE_4); ui.separator(); ui.add_space(theme::SPACE_2); ui.horizontal(|ui| { if let Some(session) = &app.session { widgets::note( ui, &format!( "Signed in to {} · server v{}", session.host, session.server_version ), ); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::ghost(ui, "Forget saved login") { app.forget_login(); } }); }); }); } fn games_grid(ui: &mut Ui, app: &mut ZaganApp, lobby: &LobbyView) { const ORDER: [&str; 4] = ["slots", "roulette", "blackjack", "crash"]; for row in ORDER.chunks(2) { ui.columns(2, |columns| { for (index, game) in row.iter().enumerate() { let Some((key, name)) = lobby.games.iter().find(|(key, _)| key == game) else { continue; }; game_card(&mut columns[index], app, key, name); } }); ui.add_space(theme::SPACE_4); } } fn game_card(ui: &mut Ui, app: &mut ZaganApp, key: &str, name: &str) { Frame::new() .fill(theme::SURFACE) .corner_radius(theme::RADIUS_LG) .inner_margin(Margin::same(theme::SPACE_4 as i8)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.label( RichText::new(name) .font(theme::sans(22.0)) .color(theme::TEXT), ); ui.add_space(2.0); widgets::note(ui, tagline(key)); ui.add_space(theme::SPACE_1); ui.label( RichText::new(meta(key)) .font(theme::mono(10.0)) .color(theme::TEXT_FAINT), ); ui.add_space(theme::SPACE_3); if widgets::primary(ui, "PLAY", true, 110.0, 36.0) { let effects = app.core.command(Command::Play { game: key.to_string(), bet: Some(DEFAULT_BET), }); app.apply(ui.ctx(), effects); } }); } fn tagline(game: &str) -> &'static str { match game { "slots" => "Five reels, five paylines, one lever.", "roulette" => "The European wheel, thirty-seven pockets.", "blackjack" => "Six decks, dealer stands on seventeen. Pays 3:2.", "crash" => "The multiplier rises. Cash out before it busts.", _ => "", } } fn meta(game: &str) -> &'static str { match game { "slots" => "MIN 10 KR · RTP 96%", "roulette" => "MIN 10 KR · SINGLE ZERO", "blackjack" => "MIN 10 KR · S17 · 3:2", "crash" => "MIN 10 KR · 1% EDGE", _ => "", } } // --------------------------------------------------------------------------- // History // --------------------------------------------------------------------------- fn history_screen(ui: &mut Ui, app: &mut ZaganApp, records: &[PlayRecord]) { CentralPanel::default() .frame( Frame::new() .fill(theme::BG) .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) .show(ui, |ui| { ui.horizontal(|ui| { if widgets::ghost(ui, "Back to lobby") { app.command(ui.ctx(), Command::ShowLobby); } ui.label( RichText::new("Play history") .font(theme::sans(24.0)) .color(theme::TEXT), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { for limit in [50, 20, 10] { if widgets::ghost(ui, &limit.to_string()) { let effects = app.core.command(Command::History { limit }); app.apply(ui.ctx(), effects); } } ui.label(theme::label("Show")); }); }); ui.add_space(theme::SPACE_4); if records.is_empty() { widgets::note(ui, "No plays recorded yet."); return; } Frame::new() .fill(theme::SURFACE) .corner_radius(theme::RADIUS_LG) .inner_margin(Margin::same(theme::SPACE_3 as i8)) .show(ui, |ui| { ui.set_width(ui.available_width()); ui.set_min_height(ui.available_height()); let widths = history_columns(ui.available_width()); history_header(ui, &widths); ui.add_space(theme::SPACE_1); ui.separator(); egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { for (index, record) in records.iter().enumerate() { history_row(ui, &widths, record, index % 2 == 1); } }); }); }); } /// Column widths for the play log, filling the available width. fn history_columns(width: f32) -> [f32; 6] { let usable = (width - theme::SPACE_3 * 5.0).max(320.0); [ usable * 0.18, usable * 0.12, usable * 0.10, usable * 0.10, usable * 0.12, usable * 0.38, ] } fn history_header(ui: &mut Ui, widths: &[f32; 6]) { history_line( ui, widths, [ theme::label("When"), theme::label("Game"), theme::label("Bet"), theme::label("Won"), theme::label("Balance"), theme::label("Result"), ], false, ); } fn history_row(ui: &mut Ui, widths: &[f32; 6], record: &PlayRecord, stripe: bool) { history_line( ui, widths, [ RichText::new(record.short_time()) .font(theme::mono(11.0)) .color(theme::TEXT_DIM), RichText::new(record.game.to_uppercase()) .font(theme::mono(11.0)) .color(theme::TEXT), RichText::new(theme::money(record.bet)) .font(theme::mono(12.0)) .color(theme::TEXT_DIM), RichText::new(theme::money(record.winnings)) .font(theme::mono(12.0)) .color(if record.winnings > 0 { theme::WIN } else { theme::TEXT_FAINT }), RichText::new(theme::money(record.balance_after)) .font(theme::mono(12.0)) .color(theme::TEXT), RichText::new(record.summary()) .font(theme::sans(12.0)) .color(theme::TEXT_DIM), ], stripe, ); } fn history_line(ui: &mut Ui, widths: &[f32; 6], cells: [RichText; 6], stripe: bool) { let spacing = theme::SPACE_3; let width = widths.iter().sum::() + spacing * 5.0; let (rect, _) = ui.allocate_exact_size(vec2(width, 26.0), Sense::hover()); if stripe { ui.painter().rect_filled( rect, theme::RADIUS_SM, theme::SURFACE_RAISED.gamma_multiply(0.6), ); } let mut child = ui.new_child( eframe::egui::UiBuilder::new() .max_rect(rect.shrink2(vec2(6.0, 0.0))) .layout(Layout::left_to_right(Align::Center)), ); child.spacing_mut().item_spacing.x = spacing; for (width, cell) in widths.iter().zip(cells) { child.add_sized( vec2(*width, 18.0), egui::Label::new(cell).selectable(false).truncate(), ); } } fn fatal(ui: &mut Ui, message: &str) { CentralPanel::default() .frame( Frame::new() .fill(theme::BG) .inner_margin(Margin::same(theme::SPACE_5 as i8)), ) .show(ui, |ui| { ui.vertical_centered(|ui| { ui.add_space(120.0); ui.label( RichText::new("Cannot start") .font(theme::sans(24.0)) .color(theme::LOSS), ); ui.add_space(theme::SPACE_2); widgets::error(ui, message); }); }); }