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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user