diff --git a/assets/fonts/DejaVuSansMono.ttf b/assets/fonts/DejaVuSansMono.ttf new file mode 100644 index 0000000..37b0269 Binary files /dev/null and b/assets/fonts/DejaVuSansMono.ttf differ diff --git a/assets/fonts/LiberationSans-Bold.ttf b/assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..ee23715 Binary files /dev/null and b/assets/fonts/LiberationSans-Bold.ttf differ diff --git a/assets/fonts/LiberationSans-Regular.ttf b/assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..366d148 Binary files /dev/null and b/assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/account_monitor.rs b/src/account_monitor.rs new file mode 100644 index 0000000..326be67 --- /dev/null +++ b/src/account_monitor.rs @@ -0,0 +1,122 @@ +//! Read-only background polling of the OpenFUT account summary. +//! +//! The launcher already POSTs `/openfut/account/sync` once at launch time +//! (see [`crate::account_sync::sync`]) to select the active profile. This +//! module reuses that request in a background thread so the Dashboard can show +//! a live "Your Club" card — coins, level, packs — without ever blocking the UI +//! thread on the network. It mirrors [`crate::health::HealthMonitor`]: a shared +//! target the UI re-points when the server config changes, and a shared state +//! snapshot the UI renders each frame. + +use std::{ + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + thread, + time::{Duration, Instant}, +}; + +use crate::account_sync::{self, AccountSummary}; +use crate::config::LauncherConfig; + +const POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// A snapshot of the last account fetch, rendered by the dashboard. +#[derive(Clone, Default)] +pub struct AccountState { + /// The most recently fetched summary, or None while none has succeeded. + pub summary: Option, + /// The error from the latest failed attempt (cleared on success). + pub error: Option, + /// Whether a server target is currently configured. `false` = idle: the + /// launcher has nothing to poll, so the UI shows the "connect" prompt. + pub configured: bool, + pub last_checked: Option, +} + +impl AccountState { + /// True when the latest error looks like a connectivity failure (server + /// down / unresolvable) rather than a protocol/validation error. Lets the + /// UI show the calm "offline" prompt for the common "server not up" case + /// and reserve the loud error state for genuinely broken responses. + pub fn unreachable(&self) -> bool { + self.error.as_deref().is_some_and(|e| { + e.contains("cannot connect") + || e.contains("cannot resolve") + || e.contains("resolved to no addresses") + }) + } +} + +/// Background poller. Holds a shared target config the UI can update when the +/// user changes the server address/account, and a shared state the UI reads. +pub struct AccountMonitor { + pub state: Arc>, + target: Arc>>, + running: Arc, +} + +impl AccountMonitor { + pub fn new() -> Self { + let state = Arc::new(Mutex::new(AccountState::default())); + let target: Arc>> = Arc::new(Mutex::new(None)); + let running = Arc::new(AtomicBool::new(true)); + + let t_state = Arc::clone(&state); + let t_target = Arc::clone(&target); + let t_running = Arc::clone(&running); + thread::spawn(move || { + while t_running.load(Ordering::Relaxed) { + let target = t_target.lock().unwrap().clone(); + match target { + None => { + // No server configured — reset to the idle prompt state. + *t_state.lock().unwrap() = AccountState::default(); + } + Some(config) => { + let result = account_sync::sync(&config); + let mut state = t_state.lock().unwrap(); + state.configured = true; + state.last_checked = Some(Instant::now()); + match result { + Ok(summary) => { + state.summary = Some(summary); + state.error = None; + } + Err(error) => { + // Drop the stale summary so the card never shows + // populated data alongside an error/offline pill. + state.summary = None; + state.error = Some(error); + } + } + } + } + thread::sleep(POLL_INTERVAL); + } + }); + + Self { + state, + target, + running, + } + } + + /// Point the monitor at a new server/account. `None` (no server configured) + /// puts it back into the idle prompt state. + pub fn set_target(&self, target: Option) { + *self.target.lock().unwrap() = target; + } + + pub fn snapshot(&self) -> AccountState { + self.state.lock().unwrap().clone() + } +} + +impl Drop for AccountMonitor { + fn drop(&mut self) { + self.running.store(false, Ordering::Relaxed); + } +} diff --git a/src/account_sync.rs b/src/account_sync.rs index cddaf2e..4d367d1 100644 --- a/src/account_sync.rs +++ b/src/account_sync.rs @@ -24,14 +24,25 @@ pub struct AccountSyncResult { pub account: AccountSummary, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AccountSummary { pub persona_id: u64, pub persona_name: String, + /// Club identity for the account bar. Optional in older envelopes. + #[serde(default)] + pub club_name: String, + #[serde(default)] + pub club_abbr: String, pub level: u32, pub experience: u32, + /// XP required for the next level. Optional; 0 means "unknown". + #[serde(default)] + pub experience_max: u32, pub account_funds: u32, + /// EASFC funds ceiling. Optional; 0 means "unknown". + #[serde(default)] + pub account_funds_cap: u32, pub coins: i64, pub unopened_packs: usize, } diff --git a/src/app.rs b/src/app.rs index a6d204a..75a04fa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,15 @@ use std::sync::{Arc, Mutex}; -use egui::{Color32, FontId, RichText, ScrollArea, Ui, Vec2}; +use egui::{Color32, RichText, ScrollArea, Ui, Vec2}; + +use crate::theme::{self, Status}; use crate::{ - config::LauncherConfig, game_launch, health::HealthMonitor, logs::LogBuffer, netcheck, - preflight, setup, + account_monitor::AccountMonitor, config::LauncherConfig, game_launch, health::HealthMonitor, + logs::LogBuffer, netcheck, preflight, setup, }; -#[derive(PartialEq)] +#[derive(Clone, Copy, PartialEq)] enum Tab { Dashboard, Logs, @@ -15,6 +17,24 @@ enum Tab { Config, } +impl Tab { + /// The tab shown on startup. Defaults to the Dashboard; an optional + /// `OPENFUT_LAUNCHER_TAB` env var (dashboard|logs|setup|config) overrides + /// it — handy for screenshotting a specific tab without clicking. + fn default_active() -> Tab { + match std::env::var("OPENFUT_LAUNCHER_TAB") + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "logs" => Tab::Logs, + "setup" => Tab::Setup, + "config" => Tab::Config, + _ => Tab::Dashboard, + } + } +} + /// The launcher is a *client-side* tool: the OpenFUT servers run elsewhere /// (e.g. Docker on the server host). It monitors server health read-only, /// prepares the FIFA integration (hook DLL + cert), and launches the game. @@ -24,6 +44,8 @@ pub struct LauncherApp { /// Read-only health monitor for the configured (remote) server. health: HealthMonitor, + /// Read-only background poller for the account summary ("Your Club" card). + account: AccountMonitor, /// Game process output + launcher messages. game_logs: Arc>, @@ -59,14 +81,7 @@ pub struct LauncherApp { impl LauncherApp { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { - let mut style = (*cc.egui_ctx.style()).clone(); - style - .text_styles - .insert(egui::TextStyle::Body, FontId::proportional(14.0)); - style - .text_styles - .insert(egui::TextStyle::Monospace, FontId::monospace(13.0)); - cc.egui_ctx.set_style(style); + crate::theme::install(&cc.egui_ctx); let config = LauncherConfig::load(); let cert_path = setup::find_bridge_cert(&config.bridge_captures_dir); @@ -75,12 +90,16 @@ impl LauncherApp { let health = HealthMonitor::new(); health.set_target(config.health_target()); + let account = AccountMonitor::new(); + account.set_target(config.account_target()); + Self { config, config_dirty: false, health, + account, game_logs: Arc::new(Mutex::new(LogBuffer::new())), - active_tab: Tab::Dashboard, + active_tab: Tab::default_active(), log_follow: true, hook_deployed, cert_path, @@ -95,186 +114,179 @@ impl LauncherApp { } } - /// Re-point the health monitor whenever the server address may have changed. + /// Re-point the background monitors whenever the server address may have + /// changed. Both the health probe and the account poller track the config. fn refresh_health_target(&self) { self.health.set_target(self.config.health_target()); + self.account.set_target(self.config.account_target()); } // ── UI sections ─────────────────────────────────────────────────────────── fn ui_dashboard(&mut self, ui: &mut Ui) { - ui.add_space(8.0); - ui.heading("OpenFUT Server"); - ui.add_space(6.0); - let server_ok = self.config.validate_server().is_ok(); - - if !server_ok { - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "No OpenFUT server configured. Enter the hostname or IP address of \ - your OpenFUT server in the Setup tab.", - ); - ui.add_space(6.0); - } - - // ── Server health (read-only) ───────────────────────────────────────── let health = self.health.snapshot(); - egui::Grid::new("health_grid") - .num_columns(2) - .spacing([16.0, 8.0]) - .show(ui, |ui| { - ui.strong("Server"); - ui.monospace(if self.config.openfut_server_host.is_empty() { - "—".to_string() - } else { - format!( - "{}:{}", - self.config.openfut_server_host, self.config.openfut_https_port - ) - }); - ui.end_row(); + let account = self.account.snapshot(); - ui.strong("Status"); - match health.reachable { - None => ui.colored_label(Color32::from_rgb(150, 150, 150), "Unknown"), - Some(true) => ui.colored_label(Color32::from_rgb(80, 200, 120), "● Online"), - Some(false) => { - ui.colored_label(Color32::from_rgb(220, 60, 60), "● Unreachable") - } - }; - ui.end_row(); + // ── Your Club card ──────────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + account_card(ui, &account); + }); - ui.label("Detail"); - ui.label(RichText::new(&health.detail).weak()); - ui.end_row(); + ui.add_space(14.0); - if let Some(t) = health.last_checked { - ui.label("Checked"); - ui.label(RichText::new(format!("{}s ago", t.elapsed().as_secs())).weak()); + // ── Server status card ──────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + let (status, pill) = match health.reachable { + None => (Status::Unknown, "Unknown"), + Some(true) => (Status::Ok, "Online"), + Some(false) => (Status::Error, "Unreachable"), + }; + card_header(ui, "Server status", Some((pill, status))); + + egui::Grid::new("health_grid") + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.label(RichText::new("Address").color(theme::TEXT_WEAK)); + ui.monospace(if self.config.openfut_server_host.is_empty() { + "—".to_string() + } else { + format!( + "{}:{}", + self.config.openfut_server_host, self.config.openfut_https_port + ) + }); ui.end_row(); - } - }); - ui.add_space(6.0); - ui.label( - RichText::new( - "The server runs elsewhere (e.g. Docker on the server host). This \ - launcher only monitors it — it does not start or stop it.", - ) - .weak() - .small(), - ); + ui.label(RichText::new("Detail").color(theme::TEXT_WEAK)); + ui.label(RichText::new(&health.detail).color(theme::TEXT)); + ui.end_row(); - ui.add_space(16.0); - ui.separator(); - ui.add_space(8.0); + if let Some(t) = health.last_checked { + ui.label(RichText::new("Checked").color(theme::TEXT_WEAK)); + ui.label( + RichText::new(format!("{}s ago", t.elapsed().as_secs())) + .color(theme::TEXT_FAINT), + ); + ui.end_row(); + } + }); - // ── FIFA 17 local companion services ────────────────────────────────── - self.ui_local_services(ui); + if !server_ok { + ui.add_space(8.0); + ui.colored_label( + theme::WARN, + "No OpenFUT server configured — set the host in the Setup tab.", + ); + } + ui.add_space(8.0); + ui.label( + RichText::new( + "The server runs elsewhere (e.g. Docker on the server host). This \ + launcher monitors it read-only — it does not start or stop it.", + ) + .color(theme::TEXT_FAINT) + .small(), + ); + }); - ui.add_space(16.0); - ui.separator(); - ui.add_space(8.0); + ui.add_space(14.0); - // ── Game launch ─────────────────────────────────────────────────────── - ui.heading("Game"); - ui.add_space(6.0); + // ── Local services card ─────────────────────────────────────────────── + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + self.ui_local_services(ui); + }); + ui.add_space(14.0); + + // ── Game & launch card ──────────────────────────────────────────────── let launch_config = self.config.validate_launch_config(); let hook_ready = self.hook_deployed; let can_launch = launch_config.is_ok() && hook_ready; - egui::Grid::new("game_status_grid") - .num_columns(2) - .spacing([12.0, 4.0]) - .show(ui, |ui| { - ui.label("Hook DLL:"); - if hook_ready { - ui.colored_label(Color32::from_rgb(80, 200, 120), "Deployed (version.dll)"); - } else { - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "Not deployed — see Setup tab", - ); - } - ui.end_row(); + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + let (gstatus, glabel) = if can_launch { + (Status::Ok, "Ready") + } else { + (Status::Warn, "Setup needed") + }; + card_header(ui, "Game & launch", Some((glabel, gstatus))); - ui.label("Launch command:"); - if self.config.game_profile.configured() { - ui.monospace(format!( - "{} {}", - self.config.game_profile.runner, self.config.game_profile.executable - )); - } else if can_launch { - ui.monospace(&self.config.game_launch_command); - } else { - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "Not set — configure it in the Config tab", - ); - } - ui.end_row(); - }); + egui::Grid::new("game_status_grid") + .num_columns(2) + .spacing([18.0, 8.0]) + .show(ui, |ui| { + ui.label(RichText::new("Hook DLL").color(theme::TEXT_WEAK)); + if hook_ready { + status_text(ui, Status::Ok, "Deployed (version.dll)"); + } else { + status_text(ui, Status::Warn, "Not deployed — see Setup tab"); + } + ui.end_row(); - ui.add_space(10.0); - self.preflight_ui(ui); - ui.add_space(10.0); + ui.label(RichText::new("Launch command").color(theme::TEXT_WEAK)); + if self.config.game_profile.configured() { + ui.monospace(format!( + "{} {}", + self.config.game_profile.runner, self.config.game_profile.executable + )); + } else if can_launch { + ui.monospace(&self.config.game_launch_command); + } else { + status_text(ui, Status::Warn, "Not set — configure it in the Config tab"); + } + ui.end_row(); + }); - if ui - .add_enabled( - can_launch, - egui::Button::new( - RichText::new("▶ Start Local Services & Launch Game").size(16.0), + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + self.preflight_ui(ui); + ui.add_space(14.0); + + let cta_w = ui.available_width().min(380.0); + if ui + .add_enabled( + can_launch, + egui::Button::new( + RichText::new("▶ Start Services & Launch Game") + .size(15.0) + .color(theme::ON_ACCENT), + ) + .fill(theme::ACCENT) + .min_size(Vec2::new(cta_w, 46.0)) + .rounding(egui::Rounding::same(10.0)), ) - .min_size(Vec2::new(160.0, 40.0)), - ) - .clicked() - { - self.launch_game(); - } + .clicked() + { + self.launch_game(); + } - if let Err(message) = &launch_config { - ui.add_space(4.0); - ui.colored_label(Color32::from_rgb(220, 150, 0), message); - } - - if !server_ok { - ui.add_space(4.0); - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "Tip: the game can launch, but without a configured/reachable server \ - FUT features won't connect.", - ); - } + if let Err(message) = &launch_config { + ui.add_space(6.0); + ui.colored_label(theme::WARN, message); + } + if !server_ok { + ui.add_space(6.0); + ui.colored_label( + theme::WARN, + "Tip: the game can launch, but without a reachable server FUT features \ + won't connect.", + ); + } + }); } /// Dashboard section for the two client-side FIFA 17 daemons. fn ui_local_services(&mut self, ui: &mut Ui) { use crate::local_services::Service; - ui.heading("FIFA 17 local services"); - ui.add_space(4.0); - ui.label( - RichText::new( - "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ - run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \ - responders run in the server container. Start these before launching the game.", - ) - .weak() - .small(), - ); - ui.add_space(6.0); - let configured = !self.config.fifa17_tools_dir.trim().is_empty(); - if !configured { - ui.colored_label( - Color32::from_rgb(220, 150, 0), - "FIFA 17 tools dir not set — configure it in the Config tab.", - ); - return; - } - let lsx_running = self.lsx.running(&self.game_logs, Service::Lsx.label()); let ap_running = self .autopatch @@ -282,18 +294,49 @@ impl LauncherApp { let lsx_stopping = self.lsx.stopping(); let ap_stopping = self.autopatch.stopping(); + let (sum_status, sum_label) = if !configured { + (Status::Warn, "Not configured") + } else if lsx_running && ap_running { + (Status::Ok, "Both running") + } else if lsx_running || ap_running { + (Status::Warn, "Partial") + } else { + (Status::Idle, "Stopped") + }; + card_header(ui, "Local services", Some((sum_label, sum_status))); + + ui.label( + RichText::new( + "LSX (Origin emulator, loopback 4216) and autopatch (ProtoSSL cert-verify) \ + run on THIS machine — the game needs them locally. The Blaze/UTAS/roster/POW \ + responders run in the server container. Start these before launching.", + ) + .color(theme::TEXT_FAINT) + .small(), + ); + ui.add_space(12.0); + + if !configured { + ui.colored_label( + theme::WARN, + "FIFA 17 tools dir not set — configure it in the Config tab.", + ); + return; + } + egui::Grid::new("local_services_grid") .num_columns(3) - .spacing([12.0, 8.0]) + .spacing([14.0, 10.0]) + .min_col_width(96.0) .show(ui, |ui| { // LSX row - ui.strong("LSX"); + ui.label(RichText::new("LSX").color(theme::TEXT).strong()); if lsx_stopping { - ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); + theme::status_pill(ui, "Stopping", Status::Busy); } else if lsx_running { - ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); + theme::status_pill(ui, "Running", Status::Ok); } else { - ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); + theme::status_pill(ui, "Stopped", Status::Idle); } if lsx_stopping { ui.add_enabled(false, egui::Button::new("Stopping…")); @@ -307,13 +350,13 @@ impl LauncherApp { ui.end_row(); // autopatch row - ui.strong("autopatch"); + ui.label(RichText::new("autopatch").color(theme::TEXT).strong()); if ap_stopping { - ui.colored_label(Color32::from_rgb(220, 150, 0), "◌ Stopping"); + theme::status_pill(ui, "Stopping", Status::Busy); } else if ap_running { - ui.colored_label(Color32::from_rgb(80, 200, 120), "● Running"); + theme::status_pill(ui, "Running", Status::Ok); } else { - ui.colored_label(Color32::from_rgb(150, 150, 150), "○ Stopped"); + theme::status_pill(ui, "Stopped", Status::Idle); } if ap_stopping { ui.add_enabled(false, egui::Button::new("Stopping…")); @@ -330,11 +373,8 @@ impl LauncherApp { ui.end_row(); }); - ui.add_space(6.0); - if ui - .button(RichText::new("Start both local services").size(14.0)) - .clicked() - { + ui.add_space(10.0); + if ui.button("Start both local services").clicked() { if !lsx_running { let _ = self.start_local_service(Service::Lsx); } @@ -344,13 +384,8 @@ impl LauncherApp { } if let Some((ok, msg)) = &self.local_services_message { - let color = if *ok { - Color32::from_rgb(80, 200, 120) - } else { - Color32::from_rgb(220, 90, 90) - }; - ui.add_space(4.0); - ui.colored_label(color, msg); + ui.add_space(6.0); + ui.colored_label(if *ok { theme::SUCCESS } else { theme::ERROR }, msg); } } @@ -406,33 +441,23 @@ impl LauncherApp { // and the game then reached the FUT hub — a checklist that // overstates its own findings gets ignored. let (color, text) = match (bad, warn) { - (0, 0) => ( - Color32::from_rgb(80, 200, 120), - "no problems found".to_string(), - ), + (0, 0) => (theme::SUCCESS, "no problems found".to_string()), (0, w) => ( - Color32::from_rgb(220, 150, 0), + theme::WARN, format!("{w} warning(s) — worth fixing, usually not fatal"), ), (b, 0) => ( - Color32::from_rgb(220, 90, 90), + theme::ERROR, format!("{b} problem(s) — expect the game to fail"), ), - (b, w) => ( - Color32::from_rgb(220, 90, 90), - format!("{b} problem(s), {w} warning(s)"), - ), + (b, w) => (theme::ERROR, format!("{b} problem(s), {w} warning(s)")), }; ui.colored_label(color, text); } }); if let Some((ok, msg)) = &self.arm_status { - let color = if *ok { - Color32::from_rgb(80, 200, 120) - } else { - Color32::from_rgb(220, 90, 90) - }; + let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } @@ -442,16 +467,16 @@ impl LauncherApp { ui.add_space(4.0); for c in checks { let (mark, color) = match c.state { - preflight::State::Pass => ("OK ", Color32::from_rgb(80, 200, 120)), - preflight::State::Warn => ("WARN", Color32::from_rgb(220, 150, 0)), - preflight::State::Fail => ("FAIL", Color32::from_rgb(220, 90, 90)), + preflight::State::Pass => ("OK ", theme::SUCCESS), + preflight::State::Warn => ("WARN", theme::WARN), + preflight::State::Fail => ("FAIL", theme::ERROR), // Grey, never green: "not checked" must not read as "fine". - preflight::State::Skipped => ("-- ", Color32::from_gray(140)), + preflight::State::Skipped => ("-- ", theme::TEXT_FAINT), }; ui.horizontal(|ui| { ui.colored_label(color, RichText::new(mark).monospace()); ui.colored_label(color, &c.name); - ui.label(RichText::new(&c.detail).weak()); + ui.label(RichText::new(&c.detail).color(theme::TEXT_WEAK)); }); } } @@ -592,8 +617,15 @@ impl LauncherApp { } fn ui_logs(&mut self, ui: &mut Ui) { + let line_count = self.game_logs.lock().unwrap().lines().count(); + ui.horizontal(|ui| { - ui.strong("Game / launcher output"); + ui.label(RichText::new("Console").text_style(theme::text_style(theme::SUBHEADING))); + ui.label( + RichText::new(format!("{line_count} lines")) + .color(theme::TEXT_FAINT) + .small(), + ); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Clear").clicked() { self.game_logs.lock().unwrap().clear(); @@ -601,40 +633,63 @@ impl LauncherApp { ui.checkbox(&mut self.log_follow, "Follow"); }); }); - ui.separator(); + ui.add_space(8.0); let follow = self.log_follow; - ScrollArea::vertical() - .auto_shrink([false, false]) - .stick_to_bottom(follow) + egui::Frame::none() + .fill(theme::INSET) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)) + .rounding(egui::Rounding::same(10.0)) + .inner_margin(egui::Margin::same(12.0)) .show(ui, |ui| { - let guard = self.game_logs.lock().unwrap(); - for line in guard.lines() { - let color = log_line_color(line); - ui.add( - egui::Label::new(RichText::new(line).monospace().color(color).size(12.5)) - .wrap(), - ); - } + ui.set_width(ui.available_width()); + ScrollArea::vertical() + .auto_shrink([false, false]) + .stick_to_bottom(follow) + .show(ui, |ui| { + let guard = self.game_logs.lock().unwrap(); + if guard.lines().next().is_none() { + ui.add_space(6.0); + ui.label( + RichText::new( + "No output yet. Launch the game or start a service to \ + see logs here.", + ) + .color(theme::TEXT_FAINT), + ); + return; + } + for line in guard.lines() { + let color = log_line_color(line); + ui.add( + egui::Label::new( + RichText::new(line).monospace().color(color).size(12.5), + ) + .wrap(), + ); + } + }); }); } fn ui_setup(&mut self, ui: &mut Ui) { use std::path::Path; - ui.add_space(8.0); - ui.heading("FIFA Integration Setup"); + ui.heading("FIFA integration setup"); ui.add_space(4.0); ui.label( - "These steps route the game's EA traffic to your OpenFUT server via DLL \ - injection (no hosts file changes needed).", + RichText::new( + "These steps route the game's EA traffic to your OpenFUT server via DLL \ + injection (no hosts file changes needed).", + ) + .color(theme::TEXT_WEAK), ); - ui.add_space(12.0); + ui.add_space(16.0); // ── Server address ──────────────────────────────────────────────────── - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.strong("Step 1 — OpenFUT server address"); + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "Step 1 · OpenFUT server address", None); ui.add_space(4.0); ui.label( "The IP or hostname of your OpenFUT server. FIFA's intercepted EA \ @@ -734,21 +789,17 @@ impl LauncherApp { } }); if let Some((ok, msg)) = &self.test_message { - let color = if *ok { - Color32::from_rgb(80, 200, 120) - } else { - Color32::from_rgb(220, 90, 90) - }; + let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } }); - ui.add_space(12.0); + ui.add_space(14.0); // ── Hook DLL deployment ─────────────────────────────────────────────── - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.strong("Step 2 — Deploy network hook DLL"); + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "Step 2 · Deploy network hook DLL", None); ui.add_space(4.0); ui.label( "Copies openfut_hook.dll into the game folder as version.dll. When \ @@ -765,7 +816,7 @@ impl LauncherApp { if !dll_built { ui.colored_label( - Color32::from_rgb(220, 150, 0), + theme::WARN, "⚠ Hook DLL not built yet. Build in openfut-hook/:", ); ui.monospace("cargo build --release --target x86_64-pc-windows-gnu"); @@ -774,7 +825,7 @@ impl LauncherApp { ui.horizontal(|ui| { if self.hook_deployed { - ui.colored_label(Color32::from_rgb(80, 200, 120), "✔ Deployed (version.dll)"); + status_text(ui, Status::Ok, "Deployed (version.dll)"); ui.add_space(8.0); if ui.button("Remove").clicked() { match setup::remove_hook_dll(game_dir) { @@ -786,7 +837,7 @@ impl LauncherApp { } } } else { - ui.colored_label(Color32::from_rgb(220, 60, 60), "✘ Not deployed"); + status_text(ui, Status::Error, "Not deployed"); ui.add_space(8.0); if ui .add_enabled(dll_built, egui::Button::new("Deploy")) @@ -827,12 +878,12 @@ impl LauncherApp { }); }); - ui.add_space(12.0); + ui.add_space(14.0); // ── Cert install ────────────────────────────────────────────────────── - ui.group(|ui| { - ui.set_min_width(ui.available_width()); - ui.strong("Step 3 — Install TLS certificate"); + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "Step 3 · Install TLS certificate", None); ui.add_space(4.0); ui.label( "Installs the bridge's self-signed cert into the Wine/Proton cert store \ @@ -846,7 +897,7 @@ impl LauncherApp { match &self.cert_path.clone() { None => { ui.colored_label( - Color32::from_rgb(220, 150, 0), + theme::WARN, "⚠ Cert not found at the captures path. Copy bridge_cert.pem \ from the server into that directory.", ); @@ -866,194 +917,214 @@ impl LauncherApp { } }); - ui.add_space(12.0); + ui.add_space(14.0); if let Some((ok, msg)) = &self.setup_message { - let color = if *ok { - Color32::from_rgb(80, 200, 120) - } else { - Color32::from_rgb(220, 60, 60) - }; + let color = if *ok { theme::SUCCESS } else { theme::ERROR }; ui.colored_label(color, msg); } } fn ui_config(&mut self, ui: &mut Ui) { - ui.add_space(8.0); ui.heading("Configuration"); - ui.add_space(8.0); + ui.add_space(4.0); + ui.label( + RichText::new( + "Everything the launcher needs to reach your server and start the game.", + ) + .color(theme::TEXT_WEAK), + ); + ui.add_space(16.0); let mut changed = false; let mut server_changed = false; - egui::Grid::new("config_grid") - .num_columns(2) - .spacing([12.0, 8.0]) - .min_col_width(140.0) - .show(ui, |ui| { - ui.strong("Game"); - ui.label(""); - ui.end_row(); - - ui.label("Launch command:"); - changed |= ui - .add( - egui::TextEdit::singleline(&mut self.config.game_launch_command) - .hint_text("e.g. ~/Desktop/launch-fifa17.sh"), - ) - .changed(); - ui.end_row(); - - ui.label("Launch workdir:"); - changed |= ui - .add( - egui::TextEdit::singleline(&mut self.config.game_launch_workdir) - .hint_text("optional, e.g. /mnt/games/FIFA 17"), - ) - .changed(); - ui.end_row(); - - ui.label("FIFA game dir:"); - changed |= ui - .text_edit_singleline(&mut self.config.fifa_game_dir) - .changed(); - ui.end_row(); - - ui.label("FIFA17 tools dir:"); - changed |= ui - .add( - egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir) - .hint_text("fifa17-recon/tools (LSX + autopatch scripts)"), - ) - .changed(); - ui.end_row(); - - ui.label("Python:"); - changed |= ui - .add( - egui::TextEdit::singleline(&mut self.config.fifa17_python) - .hint_text("python3"), - ) - .changed(); - ui.end_row(); - - ui.label(""); - ui.label(""); - ui.end_row(); - - ui.strong("OpenFUT server"); - ui.label(""); - ui.end_row(); - - ui.label("Server host:"); - let r = ui.text_edit_singleline(&mut self.config.openfut_server_host); - if r.changed() { - changed = true; - server_changed = true; - } - ui.end_row(); - - ui.label("HTTPS port:"); - let mut p = self.config.openfut_https_port.to_string(); - if ui.text_edit_singleline(&mut p).changed() { - if let Ok(v) = p.parse() { - self.config.openfut_https_port = v; - } - changed = true; - server_changed = true; - } - ui.end_row(); - - ui.label("Account/UTAS port:"); - let mut p = self.config.openfut_account_sync_port.to_string(); - if ui.text_edit_singleline(&mut p).changed() { - if let Ok(v) = p.parse() { - self.config.openfut_account_sync_port = v; - } - changed = true; - } - ui.end_row(); - - ui.label(""); - ui.label(""); - ui.end_row(); - - ui.strong("EA / Origin account"); - ui.label(""); - ui.end_row(); - - ui.label("Persona ID:"); - changed |= ui - .add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1)) - .changed(); - ui.end_row(); - - ui.label("Persona name:"); - changed |= ui - .add( - egui::TextEdit::singleline(&mut self.config.fut_persona_name) - .hint_text("EA/Origin display name"), - ) - .changed(); - ui.end_row(); - - ui.label("Account-bar level:"); - changed |= ui - .add( - egui::DragValue::new(&mut self.config.fut_account_level) - .range(1..=u32::MAX), - ) - .changed(); - ui.end_row(); - - ui.label("Account-bar XP:"); - ui.horizontal(|ui| { + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "Game", None); + egui::Grid::new("config_game_grid") + .num_columns(2) + .spacing([14.0, 9.0]) + .min_col_width(150.0) + .show(ui, |ui| { + ui.label(RichText::new("Launch command:").color(theme::TEXT_WEAK)); changed |= ui - .add(egui::DragValue::new( - &mut self.config.fut_account_experience, - )) + .add( + egui::TextEdit::singleline(&mut self.config.game_launch_command) + .hint_text("e.g. ~/Desktop/launch-fifa17.sh"), + ) .changed(); - ui.label("/"); + ui.end_row(); + + ui.label(RichText::new("Launch workdir:").color(theme::TEXT_WEAK)); changed |= ui - .add(egui::DragValue::new( - &mut self.config.fut_account_experience_max, - )) + .add( + egui::TextEdit::singleline(&mut self.config.game_launch_workdir) + .hint_text("optional, e.g. /mnt/games/FIFA 17"), + ) .changed(); + ui.end_row(); + + ui.label(RichText::new("FIFA game dir:").color(theme::TEXT_WEAK)); + changed |= ui + .text_edit_singleline(&mut self.config.fifa_game_dir) + .changed(); + ui.end_row(); + + ui.label(RichText::new("FIFA17 tools dir:").color(theme::TEXT_WEAK)); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fifa17_tools_dir) + .hint_text("fifa17-recon/tools (LSX + autopatch scripts)"), + ) + .changed(); + ui.end_row(); + + ui.label(RichText::new("Python:").color(theme::TEXT_WEAK)); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fifa17_python) + .hint_text("python3"), + ) + .changed(); + ui.end_row(); }); - ui.end_row(); + }); - ui.label("Account-bar funds:"); - ui.horizontal(|ui| { - changed |= ui - .add(egui::DragValue::new(&mut self.config.fut_account_funds)) - .changed(); - ui.label("/ cap"); - changed |= ui - .add(egui::DragValue::new(&mut self.config.fut_account_funds_cap)) - .changed(); + ui.add_space(14.0); + + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "OpenFUT server", None); + egui::Grid::new("config_server_grid") + .num_columns(2) + .spacing([14.0, 9.0]) + .min_col_width(150.0) + .show(ui, |ui| { + ui.label(RichText::new("Server host:").color(theme::TEXT_WEAK)); + let r = ui.text_edit_singleline(&mut self.config.openfut_server_host); + if r.changed() { + changed = true; + server_changed = true; + } + ui.end_row(); + + ui.label(RichText::new("HTTPS port:").color(theme::TEXT_WEAK)); + let mut p = self.config.openfut_https_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut p).desired_width(90.0)) + .changed() + { + if let Ok(v) = p.parse() { + self.config.openfut_https_port = v; + } + changed = true; + server_changed = true; + } + ui.end_row(); + + ui.label(RichText::new("Account/UTAS port:").color(theme::TEXT_WEAK)); + let mut p = self.config.openfut_account_sync_port.to_string(); + if ui + .add(egui::TextEdit::singleline(&mut p).desired_width(90.0)) + .changed() + { + if let Ok(v) = p.parse() { + self.config.openfut_account_sync_port = v; + } + changed = true; + } + ui.end_row(); }); - ui.end_row(); + }); - ui.label("Captures dir:"); - changed |= ui - .text_edit_singleline(&mut self.config.bridge_captures_dir) - .changed(); - ui.end_row(); + ui.add_space(14.0); - ui.label(""); - ui.label(""); - ui.end_row(); + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "EA / Origin account", None); + egui::Grid::new("config_account_grid") + .num_columns(2) + .spacing([14.0, 9.0]) + .min_col_width(150.0) + .show(ui, |ui| { + ui.label(RichText::new("Persona ID:").color(theme::TEXT_WEAK)); + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_persona_id).speed(1)) + .changed(); + ui.end_row(); - ui.strong("Hook DLL"); - ui.label(""); - ui.end_row(); + ui.label(RichText::new("Persona name:").color(theme::TEXT_WEAK)); + changed |= ui + .add( + egui::TextEdit::singleline(&mut self.config.fut_persona_name) + .hint_text("EA/Origin display name"), + ) + .changed(); + ui.end_row(); - ui.label("Hook DLL path:"); - changed |= ui - .text_edit_singleline(&mut self.config.hook_dll_path) - .changed(); - ui.end_row(); - }); + ui.label(RichText::new("Account-bar level:").color(theme::TEXT_WEAK)); + changed |= ui + .add( + egui::DragValue::new(&mut self.config.fut_account_level) + .range(1..=u32::MAX), + ) + .changed(); + ui.end_row(); + + ui.label(RichText::new("Account-bar XP:").color(theme::TEXT_WEAK)); + ui.horizontal(|ui| { + changed |= ui + .add(egui::DragValue::new( + &mut self.config.fut_account_experience, + )) + .changed(); + ui.label(RichText::new("/").color(theme::TEXT_FAINT)); + changed |= ui + .add(egui::DragValue::new( + &mut self.config.fut_account_experience_max, + )) + .changed(); + }); + ui.end_row(); + + ui.label(RichText::new("Account-bar funds:").color(theme::TEXT_WEAK)); + ui.horizontal(|ui| { + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_account_funds)) + .changed(); + ui.label(RichText::new("/ cap").color(theme::TEXT_FAINT)); + changed |= ui + .add(egui::DragValue::new(&mut self.config.fut_account_funds_cap)) + .changed(); + }); + ui.end_row(); + + ui.label(RichText::new("Captures dir:").color(theme::TEXT_WEAK)); + changed |= ui + .text_edit_singleline(&mut self.config.bridge_captures_dir) + .changed(); + ui.end_row(); + }); + }); + + ui.add_space(14.0); + + theme::card().show(ui, |ui| { + ui.set_width(ui.available_width()); + card_header(ui, "Hook DLL", None); + egui::Grid::new("config_hook_grid") + .num_columns(2) + .spacing([14.0, 9.0]) + .min_col_width(150.0) + .show(ui, |ui| { + ui.label(RichText::new("Hook DLL path:").color(theme::TEXT_WEAK)); + changed |= ui + .text_edit_singleline(&mut self.config.hook_dll_path) + .changed(); + ui.end_row(); + }); + }); if changed { self.config_dirty = true; @@ -1066,7 +1137,11 @@ impl LauncherApp { ui.horizontal(|ui| { if ui - .add_enabled(self.config_dirty, egui::Button::new("Save")) + .add_enabled( + self.config_dirty, + egui::Button::new(RichText::new("Save").color(theme::ON_ACCENT)) + .fill(theme::ACCENT), + ) .clicked() { self.config.save(); @@ -1080,44 +1155,384 @@ impl LauncherApp { } }); } + + /// The branded top bar: wordmark badge, product name, and a live server + /// status pill on the right. + fn ui_header(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + // Wordmark badge — an accent tile with the OpenFUT monogram. + egui::Frame::none() + .fill(theme::ACCENT) + .rounding(egui::Rounding::same(9.0)) + .inner_margin(egui::Margin::symmetric(11.0, 6.0)) + .show(ui, |ui| { + ui.label( + RichText::new("OF") + .text_style(theme::text_style(theme::HERO)) + .size(22.0) + .color(theme::ON_ACCENT), + ); + }); + ui.add_space(12.0); + ui.vertical(|ui| { + ui.add_space(1.0); + ui.label( + RichText::new("OpenFUT") + .text_style(theme::text_style(theme::HERO)) + .size(24.0) + .color(theme::TEXT), + ); + ui.label( + RichText::new("FIFA 17 Ultimate Team · Launcher") + .color(theme::TEXT_FAINT) + .small(), + ); + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let health = self.health.snapshot(); + let (status, label) = match health.reachable { + None => (Status::Unknown, "Server unknown"), + Some(true) => (Status::Ok, "Server online"), + Some(false) => (Status::Error, "Server offline"), + }; + theme::status_pill(ui, label, status); + }); + }); + } + + /// One row in the left navigation rail. Draws its own background/active + /// accent so the rail reads as a branded nav, not a plain button column. + fn nav_item(&mut self, ui: &mut Ui, tab: Tab, glyph: &str, label: &str) { + let active = self.active_tab == tab; + let full_w = ui.available_width(); + let (rect, resp) = + ui.allocate_exact_size(egui::vec2(full_w, 40.0), egui::Sense::click()); + if resp.clicked() { + self.active_tab = tab; + } + let bg = if active { + theme::ACCENT_WASH + } else if resp.hovered() { + theme::SURFACE_HOVER + } else { + Color32::TRANSPARENT + }; + let painter = ui.painter(); + painter.rect_filled(rect, egui::Rounding::same(8.0), bg); + if active { + let bar = egui::Rect::from_min_size( + rect.min + egui::vec2(0.0, 8.0), + egui::vec2(3.0, rect.height() - 16.0), + ); + painter.rect_filled(bar, egui::Rounding::same(2.0), theme::ACCENT); + } + let text_color = if active { theme::TEXT } else { theme::TEXT_WEAK }; + let icon_color = if active { theme::ACCENT_HOVER } else { theme::TEXT_FAINT }; + let mid = rect.left_center(); + painter.text( + mid + egui::vec2(16.0, 0.0), + egui::Align2::LEFT_CENTER, + glyph, + egui::FontId::proportional(15.0), + icon_color, + ); + painter.text( + mid + egui::vec2(42.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(14.5), + text_color, + ); + } } impl eframe::App for LauncherApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { ctx.request_repaint_after(std::time::Duration::from_millis(500)); - egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| { - ui.add_space(4.0); - ui.horizontal(|ui| { - ui.selectable_value(&mut self.active_tab, Tab::Dashboard, "Dashboard"); - ui.selectable_value(&mut self.active_tab, Tab::Logs, "Logs"); - ui.selectable_value(&mut self.active_tab, Tab::Setup, "Setup"); - ui.selectable_value(&mut self.active_tab, Tab::Config, "Config"); - }); - ui.add_space(2.0); - }); + egui::TopBottomPanel::top("header") + .frame( + egui::Frame::none() + .fill(theme::BG_DEEP) + .inner_margin(egui::Margin::symmetric(22.0, 14.0)) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)), + ) + .show(ctx, |ui| self.ui_header(ui)); - egui::CentralPanel::default().show(ctx, |ui| { - ui.set_min_size(Vec2::new(600.0, 400.0)); - match self.active_tab { - Tab::Dashboard => self.ui_dashboard(ui), - Tab::Logs => self.ui_logs(ui), - Tab::Setup => self.ui_setup(ui), - Tab::Config => self.ui_config(ui), - } - }); + egui::SidePanel::left("nav") + .resizable(false) + .exact_width(198.0) + .frame( + egui::Frame::none() + .fill(theme::BG_DEEP) + .inner_margin(egui::Margin::symmetric(12.0, 16.0)) + .stroke(egui::Stroke::new(1.0_f32, theme::BORDER)), + ) + .show(ctx, |ui| { + self.nav_item(ui, Tab::Dashboard, "▤", "Dashboard"); + ui.add_space(4.0); + self.nav_item(ui, Tab::Logs, "≡", "Logs"); + ui.add_space(4.0); + self.nav_item(ui, Tab::Setup, "🔧", "Setup"); + ui.add_space(4.0); + self.nav_item(ui, Tab::Config, "⚙", "Config"); + + // Version pinned to the bottom of the rail. + ui.with_layout(egui::Layout::bottom_up(egui::Align::LEFT), |ui| { + ui.add_space(2.0); + ui.label( + RichText::new(concat!("v", env!("CARGO_PKG_VERSION"))) + .color(theme::TEXT_FAINT) + .small(), + ); + ui.label( + RichText::new("Client-side tool") + .color(theme::TEXT_FAINT) + .small(), + ); + }); + }); + + egui::CentralPanel::default() + .frame( + egui::Frame::none() + .fill(theme::BG) + .inner_margin(egui::Margin::symmetric(24.0, 20.0)), + ) + .show(ctx, |ui| { + // Logs owns a full-height console with its own scroller; every + // other tab scrolls its stacked cards. + if self.active_tab == Tab::Logs { + self.ui_logs(ui); + } else { + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .show(ui, |ui| match self.active_tab { + Tab::Dashboard => self.ui_dashboard(ui), + Tab::Setup => self.ui_setup(ui), + Tab::Config => self.ui_config(ui), + Tab::Logs => {} + }); + } + }); } } fn log_line_color(line: &str) -> Color32 { let lower = line.to_lowercase(); if lower.contains("error") || lower.contains("panic") { - Color32::from_rgb(220, 80, 80) + theme::ERROR } else if lower.contains("warn") { - Color32::from_rgb(255, 200, 0) + theme::WARN } else if lower.contains("info") { - Color32::from_rgb(160, 210, 255) + theme::INFO } else { - Color32::from_rgb(210, 210, 210) + theme::TEXT } } + +/// A card title row: bold subheading on the left, optional status pill pushed to +/// the right edge. +fn card_header(ui: &mut Ui, title: &str, pill: Option<(&str, Status)>) { + ui.horizontal(|ui| { + ui.label( + RichText::new(title) + .text_style(theme::text_style(theme::SUBHEADING)) + .color(theme::TEXT), + ); + if let Some((label, status)) = pill { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + theme::status_pill(ui, label, status); + }); + } + }); + ui.add_space(12.0); +} + +/// Inline status: a coloured glyph followed by same-coloured text. Used inside +/// grids where a full pill would be too heavy. +fn status_text(ui: &mut Ui, status: Status, text: &str) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.label(RichText::new(status.glyph()).color(status.color()).size(11.0)); + ui.label(RichText::new(text).color(status.color())); + }); +} + +/// The dashboard's "Your Club" card. Renders the live account summary when one +/// has been fetched, and calm, on-brand states otherwise: idle (no server), +/// loading (fetch in flight), offline/unreachable, and a loud error only for a +/// genuinely broken response. Never draws an empty/populated-looking shell. +fn account_card(ui: &mut Ui, account: &crate::account_monitor::AccountState) { + let pill = if account.summary.is_some() { + ("Online", Status::Ok) + } else if !account.configured || account.unreachable() { + ("Offline", Status::Idle) + } else if account.error.is_some() { + ("Error", Status::Error) + } else { + ("Fetching…", Status::Busy) + }; + card_header(ui, "Your Club", Some(pill)); + + match &account.summary { + Some(summary) => account_body(ui, summary), + None => { + let (msg, color) = if !account.configured || account.unreachable() { + ( + "Connect to your OpenFUT server to see your club.".to_string(), + theme::TEXT_FAINT, + ) + } else if let Some(err) = &account.error { + (format!("Account unavailable — {err}"), theme::ERROR) + } else { + ("Fetching account…".to_string(), theme::TEXT_WEAK) + }; + ui.label(RichText::new(msg).color(color)); + } + } +} + +/// The populated body: club identity, the hero coin balance, an XP progress +/// bar, and a compact stat row. +fn account_body(ui: &mut Ui, s: &crate::account_sync::AccountSummary) { + // Club identity: name + abbreviation badge, with the manager beneath. + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 10.0; + let name = if s.club_name.trim().is_empty() { + s.persona_name.as_str() + } else { + s.club_name.as_str() + }; + ui.label( + RichText::new(name) + .text_style(theme::text_style(theme::SUBHEADING)) + .color(theme::TEXT), + ); + if !s.club_abbr.trim().is_empty() { + club_badge(ui, &s.club_abbr); + } + }); + ui.label( + RichText::new(format!("Manager · {}", s.persona_name)) + .color(theme::TEXT_FAINT) + .small(), + ); + + ui.add_space(16.0); + + // Hero: the coin balance is the single number that matters most. + ui.label(RichText::new("COINS").color(theme::TEXT_WEAK).small()); + ui.label( + RichText::new(thousands_i64(s.coins)) + .color(theme::ACCENT) + .size(34.0) + .strong(), + ); + + ui.add_space(16.0); + + // Level + XP progression. + ui.horizontal(|ui| { + ui.label(RichText::new(format!("Level {}", s.level)).color(theme::TEXT).strong()); + if s.experience_max > 0 { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format!( + "{} / {} XP", + thousands_u32(s.experience), + thousands_u32(s.experience_max) + )) + .color(theme::TEXT_WEAK) + .small(), + ); + }); + } + }); + if s.experience_max > 0 { + ui.add_space(4.0); + let frac = (s.experience as f32 / s.experience_max as f32).clamp(0.0, 1.0); + ui.add( + egui::ProgressBar::new(frac) + .desired_height(6.0) + .fill(theme::ACCENT) + .rounding(egui::Rounding::same(3.0)), + ); + } + + ui.add_space(14.0); + + // Compact stat row for the remaining figures. + egui::Grid::new("account_stats_grid") + .num_columns(2) + .spacing([18.0, 10.0]) + .show(ui, |ui| { + ui.label(RichText::new("Unopened packs").color(theme::TEXT_WEAK)); + if s.unopened_packs > 0 { + ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| { + theme::status_pill(ui, &s.unopened_packs.to_string(), Status::Warn); + }); + } else { + ui.label(RichText::new("0").color(theme::TEXT)); + } + ui.end_row(); + + ui.label(RichText::new("Account funds").color(theme::TEXT_WEAK)); + let funds = if s.account_funds_cap > 0 { + format!( + "{} / {}", + thousands_u32(s.account_funds), + thousands_u32(s.account_funds_cap) + ) + } else { + thousands_u32(s.account_funds) + }; + ui.label(RichText::new(funds).color(theme::TEXT)); + ui.end_row(); + }); +} + +/// A small accent-washed badge for the club abbreviation (e.g. "OFC"). +fn club_badge(ui: &mut Ui, abbr: &str) { + egui::Frame::none() + .fill(theme::ACCENT_WASH) + .rounding(egui::Rounding::same(6.0)) + .inner_margin(egui::Margin::symmetric(8.0, 2.0)) + .show(ui, |ui| { + ui.label( + RichText::new(abbr.trim().to_uppercase()) + .color(theme::ACCENT_HOVER) + .strong() + .size(12.0), + ); + }); +} + +/// Group a run of ASCII digits into thousands with commas ("29876776" → +/// "29,876,776"). +fn group_thousands(digits: &str) -> String { + let bytes = digits.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len + len / 3); + for (i, b) in bytes.iter().enumerate() { + if i > 0 && (len - i) % 3 == 0 { + out.push(','); + } + out.push(*b as char); + } + out +} + +fn thousands_i64(n: i64) -> String { + let s = group_thousands(&n.unsigned_abs().to_string()); + if n < 0 { + format!("-{s}") + } else { + s + } +} + +fn thousands_u32(n: u32) -> String { + group_thousands(&n.to_string()) +} diff --git a/src/config.rs b/src/config.rs index 19d7170..c991929 100644 --- a/src/config.rs +++ b/src/config.rs @@ -318,6 +318,17 @@ impl LauncherConfig { } } + /// The config the account monitor should poll with, or None when no server + /// is configured. Returns a clone so the background thread owns its own + /// snapshot and never races the UI's live config. + pub fn account_target(&self) -> Option { + if self.openfut_server_host.trim().is_empty() { + None + } else { + Some(self.clone()) + } + } + /// Build the shared [`ServerConfig`] from the launcher's configured server /// host + destination ports. This is the single place the launcher turns UI /// fields into the canonical config consumed by the hook. diff --git a/src/main.rs b/src/main.rs index 116be67..02ba4c7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod account_sync; +mod account_monitor; mod app; mod arm; mod config; @@ -10,13 +11,16 @@ mod logs; mod netcheck; mod preflight; mod setup; +mod theme; fn main() -> eframe::Result<()> { let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("OpenFUT Launcher") - .with_inner_size([780.0, 560.0]) - .with_min_inner_size([600.0, 400.0]), + .with_app_id("openfut-launcher") + .with_icon(app_icon()) + .with_inner_size([1040.0, 720.0]) + .with_min_inner_size([880.0, 600.0]), ..Default::default() }; @@ -26,3 +30,88 @@ fn main() -> eframe::Result<()> { Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))), ) } + +/// The application / taskbar icon: the same "OF" monogram the header wordmark +/// shows, drawn white on the signature accent tile. Generated in code (no PNG +/// dependency) at 4x supersampling and box-downsampled to a crisp 64x64 RGBA — +/// scales cleanly to the 32x32 the WM typically renders. Colours come from the +/// theme palette so the icon never drifts from the in-app brand. +fn app_icon() -> egui::IconData { + const SIZE: usize = 64; // output edge + const SS: usize = 4; // supersampling factor + + let accent = theme::ACCENT; + let fg = theme::ON_ACCENT; + + // Rounded-square background: point inside the [0,SIZE]² square with corners + // rounded to `round_r` (transparent outside, so the icon reads as a tile). + let round_r = 13.0_f32; + let inside_bg = |x: f32, y: f32| -> bool { + let s = SIZE as f32; + let cx = x.clamp(round_r, s - round_r); + let cy = y.clamp(round_r, s - round_r); + let (dx, dy) = (x - cx, y - cy); + dx * dx + dy * dy <= round_r * round_r + }; + + // "O" — an elliptical ring on the left. + let inside_o = |x: f32, y: f32| -> bool { + let (cx, cy) = (21.0_f32, 32.0_f32); + let (dx, dy) = (x - cx, y - cy); + let outer = (dx / 9.0).powi(2) + (dy / 14.0).powi(2) <= 1.0; + let inner = (dx / 4.8).powi(2) + (dy / 9.5).powi(2) < 1.0; + outer && !inner + }; + + // "F" — a stem plus a top and middle bar on the right. + let inside_f = |x: f32, y: f32| -> bool { + let stem = (34.0..=39.0).contains(&x) && (18.0..=46.0).contains(&y); + let top = (34.0..=52.0).contains(&x) && (18.0..=23.0).contains(&y); + let mid = (34.0..=48.0).contains(&x) && (29.5..=34.0).contains(&y); + stem || top || mid + }; + + // Premultiplied-alpha accumulation per output pixel so antialiased edges + // (both the rounded tile and the letters) never fringe dark. + let mut rgba = vec![0u8; SIZE * SIZE * 4]; + for oy in 0..SIZE { + for ox in 0..SIZE { + let (mut ar, mut ag, mut ab, mut aa) = (0.0_f32, 0.0_f32, 0.0_f32, 0.0_f32); + for sy in 0..SS { + for sx in 0..SS { + let x = ox as f32 + (sx as f32 + 0.5) / SS as f32; + let y = oy as f32 + (sy as f32 + 0.5) / SS as f32; + let (r, g, b, a) = if inside_o(x, y) || inside_f(x, y) { + (fg.r(), fg.g(), fg.b(), 255u16) + } else if inside_bg(x, y) { + (accent.r(), accent.g(), accent.b(), 255u16) + } else { + (0, 0, 0, 0) + }; + let af = a as f32 / 255.0; + ar += r as f32 * af; + ag += g as f32 * af; + ab += b as f32 * af; + aa += af; + } + } + let samples = (SS * SS) as f32; + let idx = (oy * SIZE + ox) * 4; + let (r, g, b) = if aa > 0.0 { + (ar / aa, ag / aa, ab / aa) + } else { + (0.0, 0.0, 0.0) + }; + rgba[idx] = r.round() as u8; + rgba[idx + 1] = g.round() as u8; + rgba[idx + 2] = b.round() as u8; + rgba[idx + 3] = (aa / samples * 255.0).round() as u8; + } + } + + egui::IconData { + rgba, + width: SIZE as u32, + height: SIZE as u32, + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..a5aa916 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,308 @@ +//! OpenFUT launcher visual system. +//! +//! A single place that owns the app's look: the semantic colour palette, the +//! type scale, embedded fonts, and the tuned egui [`Style`]/[`Visuals`]. UI code +//! composes *with* this system — it never hard-codes `Color32::from_rgb(...)` or +//! stray pixel radii. The palette is deliberately small: one signature accent +//! plus four status hues (success / warn / error / idle) and a tinted neutral +//! ramp. Nothing here changes launcher behaviour; it is presentation only. + +use egui::{ + Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Frame, Margin, Rounding, + Stroke, TextStyle, +}; + +// ── Semantic palette ──────────────────────────────────────────────────────── +// Neutrals are always *tinted* (a hint of cool blue), never pure #000/#fff. + +/// Window backdrop — the deepest surface. +pub const BG_DEEP: Color32 = Color32::from_rgb(0x10, 0x12, 0x18); +/// Standard panel fill (nav rail, central body). +pub const BG: Color32 = Color32::from_rgb(0x15, 0x18, 0x22); +/// Raised card / group surface. +pub const SURFACE: Color32 = Color32::from_rgb(0x1c, 0x20, 0x2e); +/// Hovered / interactive raised surface. +pub const SURFACE_HOVER: Color32 = Color32::from_rgb(0x24, 0x29, 0x3a); +/// Inset surface (text fields, console, code). +pub const INSET: Color32 = Color32::from_rgb(0x0e, 0x10, 0x17); + +/// Hairline divider / card border. +pub const BORDER: Color32 = Color32::from_rgb(0x2a, 0x31, 0x45); +/// Stronger border for emphasis / hover. +pub const BORDER_STRONG: Color32 = Color32::from_rgb(0x3a, 0x43, 0x5e); + +/// Primary text. +pub const TEXT: Color32 = Color32::from_rgb(0xe6, 0xe9, 0xf2); +/// Secondary / supporting text. +pub const TEXT_WEAK: Color32 = Color32::from_rgb(0x9a, 0xa3, 0xb8); +/// Tertiary / disabled-ish text. +pub const TEXT_FAINT: Color32 = Color32::from_rgb(0x6a, 0x73, 0x8a); + +/// Signature OpenFUT accent — a confident royal blue used for the wordmark, +/// active navigation, and primary calls-to-action. +pub const ACCENT: Color32 = Color32::from_rgb(0x4c, 0x6f, 0xff); +pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0x6a, 0x87, 0xff); +pub const ACCENT_PRESSED: Color32 = Color32::from_rgb(0x3b, 0x5b, 0xe0); +/// Faint accent wash for active-nav backgrounds / selection. +pub const ACCENT_WASH: Color32 = Color32::from_rgb(0x22, 0x2c, 0x50); +/// Text drawn on top of the solid accent. +pub const ON_ACCENT: Color32 = Color32::from_rgb(0xf5, 0xf7, 0xff); + +/// Status hues — distinct from the accent so "primary action" never reads as +/// "healthy" and vice-versa. +pub const SUCCESS: Color32 = Color32::from_rgb(0x3f, 0xcf, 0x8e); +pub const WARN: Color32 = Color32::from_rgb(0xf2, 0xb4, 0x4c); +pub const ERROR: Color32 = Color32::from_rgb(0xf2, 0x6d, 0x6d); +pub const IDLE: Color32 = Color32::from_rgb(0x7a, 0x83, 0x99); +/// Informational blue for log lines (lighter than the accent). +pub const INFO: Color32 = Color32::from_rgb(0x8f, 0xb6, 0xff); + +// ── Type scale (custom named text styles) ─────────────────────────────────── + +/// Large branded wordmark. +pub const HERO: &str = "Hero"; +/// Card / section titles. +pub const SUBHEADING: &str = "Subheading"; +/// Small monospace (console meta, launch command). +pub const MONO_SM: &str = "MonoSm"; + +fn bold_family() -> FontFamily { + FontFamily::Name("openfut-bold".into()) +} + +/// A [`TextStyle`] handle for one of our custom scale steps. +pub fn text_style(name: &str) -> TextStyle { + TextStyle::Name(name.into()) +} + +// ── Status semantics ──────────────────────────────────────────────────────── + +/// A coarse health/activity state, mapped to one palette hue + glyph. Using an +/// enum keeps status rendering consistent everywhere (dashboard, preflight, +/// services) instead of ad-hoc colour+string pairs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Status { + /// Healthy / online / running / passed. + Ok, + /// Advisory — worth attention, usually not fatal. + Warn, + /// Broken / unreachable / failed. + Error, + /// Not running / not configured / not checked. + Idle, + /// Transient (stopping / working). + Busy, + /// Unknown / not yet probed. + Unknown, +} + +impl Status { + pub fn color(self) -> Color32 { + match self { + Status::Ok => SUCCESS, + Status::Warn => WARN, + Status::Error => ERROR, + Status::Idle => IDLE, + Status::Busy => WARN, + Status::Unknown => TEXT_FAINT, + } + } + + /// A consistent status glyph: filled ● for active/terminal states, hollow ○ + /// for idle/unknown. (Kept to glyphs the bundled fonts render.) + pub fn glyph(self) -> &'static str { + match self { + Status::Ok | Status::Error | Status::Warn | Status::Busy => "●", + Status::Idle | Status::Unknown => "○", + } + } +} + +/// Draw a compact status pill: a tinted, rounded chip with a status dot and +/// label. Used for the at-a-glance state on each dashboard card. +pub fn status_pill(ui: &mut egui::Ui, label: &str, status: Status) { + let color = status.color(); + let bg = tint(color, 0.14); + Frame::none() + .fill(bg) + .rounding(Rounding::same(999.0)) + .inner_margin(Margin::symmetric(10.0, 3.0)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.label(egui::RichText::new(status.glyph()).color(color).size(11.0)); + ui.label( + egui::RichText::new(label) + .color(color) + .size(12.0) + .strong(), + ); + }); + }); +} + +/// A raised card surface: rounded, hairline-bordered, generously padded. The +/// building block for the dashboard and setup sections. +pub fn card() -> Frame { + Frame::none() + .fill(SURFACE) + .stroke(Stroke::new(1.0_f32, BORDER)) + .rounding(Rounding::same(12.0)) + .inner_margin(Margin::same(18.0)) +} + +/// Blend `color` toward the app background by `bg_weight` (0 = full colour, +/// 1 = pure background). Used for tinted chips and washes. +pub fn tint(color: Color32, weight: f32) -> Color32 { + let w = weight.clamp(0.0, 1.0); + let lerp = |c: u8, b: u8| ((c as f32) * w + (b as f32) * (1.0 - w)).round() as u8; + // Chips sit on card surfaces; lerp toward the surface, not the deep bg. + Color32::from_rgb( + lerp(color.r(), SURFACE.r()), + lerp(color.g(), SURFACE.g()), + lerp(color.b(), SURFACE.b()), + ) +} + +// ── Install ───────────────────────────────────────────────────────────────── + +/// Embed the bundled fonts and apply the OpenFUT style. Called once at startup. +pub fn install(ctx: &Context) { + install_fonts(ctx); + install_style(ctx); +} + +fn install_fonts(ctx: &Context) { + let mut fonts = FontDefinitions::default(); + + fonts.font_data.insert( + "openfut-sans".to_owned(), + FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Regular.ttf")), + ); + fonts.font_data.insert( + "openfut-bold".to_owned(), + FontData::from_static(include_bytes!("../assets/fonts/LiberationSans-Bold.ttf")), + ); + fonts.font_data.insert( + "openfut-mono".to_owned(), + FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansMono.ttf")), + ); + + // Proportional & monospace default to the bundled faces so the UI looks + // identical regardless of the host's installed fonts. + fonts + .families + .entry(FontFamily::Proportional) + .or_default() + .insert(0, "openfut-sans".to_owned()); + fonts + .families + .entry(FontFamily::Monospace) + .or_default() + .insert(0, "openfut-mono".to_owned()); + + // A dedicated bold family — egui does not synthesize weight, so headings + // reference this explicitly for a real type hierarchy. + fonts.families.insert( + FontFamily::Name("openfut-bold".into()), + vec!["openfut-bold".to_owned(), "openfut-sans".to_owned()], + ); + + ctx.set_fonts(fonts); +} + +fn install_style(ctx: &Context) { + let mut style = (*ctx.style()).clone(); + + // ── Type scale ────────────────────────────────────────────────────────── + let bold = bold_family(); + let prop = FontFamily::Proportional; + let mono = FontFamily::Monospace; + let ts = &mut style.text_styles; + ts.insert(text_style(HERO), FontId::new(28.0, bold.clone())); + ts.insert(TextStyle::Heading, FontId::new(19.0, bold.clone())); + ts.insert(text_style(SUBHEADING), FontId::new(15.0, bold)); + ts.insert(TextStyle::Body, FontId::new(14.0, prop.clone())); + ts.insert(TextStyle::Button, FontId::new(14.0, prop.clone())); + ts.insert(TextStyle::Small, FontId::new(12.0, prop)); + ts.insert(TextStyle::Monospace, FontId::new(13.0, mono.clone())); + ts.insert(text_style(MONO_SM), FontId::new(11.5, mono)); + + // ── Spacing scale (multiples of 4) ──────────────────────────────────────── + let sp = &mut style.spacing; + sp.item_spacing = egui::vec2(8.0, 8.0); + sp.button_padding = egui::vec2(12.0, 7.0); + sp.menu_margin = Margin::same(8.0); + sp.indent = 18.0; + sp.interact_size.y = 30.0; + sp.scroll.bar_width = 9.0; + + // ── Visuals ─────────────────────────────────────────────────────────────── + let mut v = egui::Visuals::dark(); + v.dark_mode = true; + v.override_text_color = Some(TEXT); + v.panel_fill = BG; + v.window_fill = BG; + v.extreme_bg_color = INSET; + v.faint_bg_color = SURFACE; + v.code_bg_color = INSET; + v.hyperlink_color = ACCENT_HOVER; + + v.window_rounding = Rounding::same(12.0); + v.window_stroke = Stroke::new(1.0_f32, BORDER); + v.menu_rounding = Rounding::same(8.0); + v.window_shadow = egui::epaint::Shadow::NONE; + v.popup_shadow = egui::epaint::Shadow { + offset: egui::vec2(0.0, 6.0), + blur: 18.0, + spread: 0.0, + color: Color32::from_black_alpha(120), + }; + + // Selection uses the accent wash so highlighted text/nav reads as branded. + v.selection.bg_fill = ACCENT_WASH; + v.selection.stroke = Stroke::new(1.0_f32, ACCENT_HOVER); + + // Separators / hairlines. + let radius = Rounding::same(8.0); + + // Non-interactive widgets (labels, separators). + v.widgets.noninteractive.bg_fill = SURFACE; + v.widgets.noninteractive.weak_bg_fill = SURFACE; + v.widgets.noninteractive.bg_stroke = Stroke::new(1.0_f32, BORDER); + v.widgets.noninteractive.fg_stroke = Stroke::new(1.0_f32, TEXT); + v.widgets.noninteractive.rounding = radius; + + // Inactive interactive widgets (idle buttons). + v.widgets.inactive.bg_fill = SURFACE_HOVER; + v.widgets.inactive.weak_bg_fill = SURFACE_HOVER; + v.widgets.inactive.bg_stroke = Stroke::new(1.0_f32, BORDER); + v.widgets.inactive.fg_stroke = Stroke::new(1.0_f32, TEXT); + v.widgets.inactive.rounding = radius; + + // Hovered. + v.widgets.hovered.bg_fill = tint(ACCENT, 0.30); + v.widgets.hovered.weak_bg_fill = tint(ACCENT, 0.30); + v.widgets.hovered.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG); + v.widgets.hovered.fg_stroke = Stroke::new(1.0_f32, TEXT); + v.widgets.hovered.rounding = radius; + v.widgets.hovered.expansion = 1.0; + + // Active / pressed. + v.widgets.active.bg_fill = ACCENT_PRESSED; + v.widgets.active.weak_bg_fill = ACCENT_PRESSED; + v.widgets.active.bg_stroke = Stroke::new(1.0_f32, ACCENT); + v.widgets.active.fg_stroke = Stroke::new(1.0_f32, ON_ACCENT); + v.widgets.active.rounding = radius; + v.widgets.active.expansion = 1.0; + + // Open (combo boxes / menus). + v.widgets.open.bg_fill = SURFACE_HOVER; + v.widgets.open.weak_bg_fill = SURFACE_HOVER; + v.widgets.open.bg_stroke = Stroke::new(1.0_f32, BORDER_STRONG); + v.widgets.open.fg_stroke = Stroke::new(1.0_f32, TEXT); + v.widgets.open.rounding = radius; + + style.visuals = v; + ctx.set_style(style); +}