057cf92c3b
Port the egui launcher to run natively on Windows (no Wine/Proton). The GUI,
launch state machine, config, health/account monitors, and openfut.cfg writing
are unchanged and cross-platform; only the effect layer is branched:
- game_launch: cfg(windows) launch spawns the game executable directly with its
working dir (the version.dll hijack loads from the game dir; no WINEDLLOVERRIDES,
Wine prefix, or licence regen). Requires the launcher to run elevated so the
child inherits admin. Linux Proton path gated cfg(unix).
- arm: cfg(windows) is a no-op (routing is openfut.cfg, written by the client-files
step; no ptrace_scope/DNAT/hosts). Linux arming gated cfg(unix).
- local_services: on Windows LSX/autopatch are in-process (stp-origin_emu.dll +
version.dll hook), so ensure_running reports ready without spawning. Gated the
unix-only CommandExt/process_group.
- preflight: cfg(windows) run() keeps only backend-reachable + hook-config checks.
- config: GameProfile configured()/validate() accept a runner-less Windows profile.
theme: fix a latent cross-platform panic — egui 0.29 keeps a Style per theme, so
set_style only reached the active one and TextStyle::resolve("Hero") panicked when
the other theme rendered. Install the full style into both themes and pin Dark.
Cross-built for x86_64-pc-windows-gnu; Linux build + 75 tests unchanged.
312 lines
13 KiB
Rust
312 lines
13 KiB
Rust
//! 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;
|
|
// egui 0.29 keeps a separate `Style` per theme (dark/light) and renders with
|
|
// whichever the theme preference resolves to. `set_style` touches only the
|
|
// currently-active theme, so a later switch to the other one would drop our
|
|
// named text styles ("Hero", "Subheading", …) and panic in `TextStyle::resolve`.
|
|
// Install the full style into BOTH themes and pin the preference to Dark so
|
|
// the branded look is stable regardless of the host's system theme.
|
|
ctx.set_style_of(egui::Theme::Dark, style.clone());
|
|
ctx.set_style_of(egui::Theme::Light, style);
|
|
ctx.set_theme(egui::ThemePreference::Dark);
|
|
}
|