feat(ui): shareholder-grade redesign + live "Your Club" account panel

- New theme.rs design system: palette, embedded fonts, egui Visuals/Style,
  card()/status_pill() helpers.
- Branded hero header (OF monogram), left nav rail, card-based dashboard,
  console-style Logs, themed Config tab, window/taskbar icon.
- New account_monitor.rs: background AccountMonitor (mirrors HealthMonitor,
  5s non-blocking poll) driving a live "Your Club" dashboard card (club
  name/abbr, manager, COINS hero number, level + XP bar, unopened packs,
  funds) with loading/offline/error states.
- account_sync.rs/app.rs/config.rs/main.rs wired to the monitor + theme.
  All existing launch/health/preflight/service/config logic preserved.
This commit is contained in:
funman300
2026-08-17 16:03:47 +00:00
parent d1a71bd5a1
commit c2772132c1
9 changed files with 1385 additions and 429 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+122
View File
@@ -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<AccountSummary>,
/// The error from the latest failed attempt (cleared on success).
pub error: Option<String>,
/// 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<Instant>,
}
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<Mutex<AccountState>>,
target: Arc<Mutex<Option<LauncherConfig>>>,
running: Arc<AtomicBool>,
}
impl AccountMonitor {
pub fn new() -> Self {
let state = Arc::new(Mutex::new(AccountState::default()));
let target: Arc<Mutex<Option<LauncherConfig>>> = 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<LauncherConfig>) {
*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);
}
}
+12 -1
View File
@@ -24,14 +24,25 @@ pub struct AccountSyncResult {
pub account: AccountSummary, pub account: AccountSummary,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AccountSummary { pub struct AccountSummary {
pub persona_id: u64, pub persona_id: u64,
pub persona_name: String, 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 level: u32,
pub experience: u32, pub experience: u32,
/// XP required for the next level. Optional; 0 means "unknown".
#[serde(default)]
pub experience_max: u32,
pub account_funds: u32, pub account_funds: u32,
/// EASFC funds ceiling. Optional; 0 means "unknown".
#[serde(default)]
pub account_funds_cap: u32,
pub coins: i64, pub coins: i64,
pub unopened_packs: usize, pub unopened_packs: usize,
} }
+841 -426
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -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<LauncherConfig> {
if self.openfut_server_host.trim().is_empty() {
None
} else {
Some(self.clone())
}
}
/// Build the shared [`ServerConfig`] from the launcher's configured server /// Build the shared [`ServerConfig`] from the launcher's configured server
/// host + destination ports. This is the single place the launcher turns UI /// host + destination ports. This is the single place the launcher turns UI
/// fields into the canonical config consumed by the hook. /// fields into the canonical config consumed by the hook.
+91 -2
View File
@@ -1,4 +1,5 @@
mod account_sync; mod account_sync;
mod account_monitor;
mod app; mod app;
mod arm; mod arm;
mod config; mod config;
@@ -10,13 +11,16 @@ mod logs;
mod netcheck; mod netcheck;
mod preflight; mod preflight;
mod setup; mod setup;
mod theme;
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_title("OpenFUT Launcher") .with_title("OpenFUT Launcher")
.with_inner_size([780.0, 560.0]) .with_app_id("openfut-launcher")
.with_min_inner_size([600.0, 400.0]), .with_icon(app_icon())
.with_inner_size([1040.0, 720.0])
.with_min_inner_size([880.0, 600.0]),
..Default::default() ..Default::default()
}; };
@@ -26,3 +30,88 @@ fn main() -> eframe::Result<()> {
Box::new(|cc| Ok(Box::new(app::LauncherApp::new(cc)))), 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,
}
}
+308
View File
@@ -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);
}