//! 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); }